fix dm embeds being dropped when sending

embeds (quoted posts, invite links) were sometimes not attached to sent
messages. the composer clears the embed state synchronously, but the
actual send is deferred (a frame on native, a 20ms timeout on ios). by
the time the deferred send ran, onSendMessage had been re-created with
the now-cleared messageEmbed, so the embed was lost.

thread the embed through as an argument captured at submit time, the
same way replyTo already is, so an intermediate render can't drop it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-16 21:32:26 +03:00
parent bb6847f396
commit 3ebae337f1
4 changed files with 54 additions and 28 deletions
@@ -37,13 +37,14 @@ import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} fro
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {type MessageEmbedState} from './MessageInputEmbed'
const MIN_HEIGHT = 40
export function MessageComposer({
textInputId,
onSendMessage,
hasEmbed,
messageEmbed,
setEmbed,
children,
loading = false,
@@ -51,9 +52,10 @@ export function MessageComposer({
textInputId?: string
onSendMessage: (
message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => void
hasEmbed: boolean
messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
loading?: boolean
@@ -89,14 +91,16 @@ export function MessageComposer({
},
})
const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0)
const submitDisabled =
!editable || (!messageEmbed && text.trim().length === 0)
const onSubmit = (
message: string,
embed: MessageEmbedState | undefined,
replyTo: ChatBskyConvoDefs.MessageView | null,
) => {
if (!editable) return
if (!hasEmbed && message.trim() === '') return
if (!embed && message.trim() === '') return
const graphemeCount = countGraphemes(message)
if (graphemeCount > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(
@@ -120,6 +124,7 @@ export function MessageComposer({
requestAnimationFrame(() => {
onSendMessage(
message,
embed,
replyTo
? {
...replyTo,
@@ -152,18 +157,18 @@ export function MessageComposer({
setTimeout(() => {
if (isFlushingAutocorrectSuggestion.current) {
isFlushingAutocorrectSuggestion.current = false
onSubmit(text, replyTo)
onSubmit(text, messageEmbed, replyTo)
}
}, 20)
} else {
onSubmit(text, replyTo)
onSubmit(text, messageEmbed, replyTo)
}
}
const handleChange = (nextText: string) => {
if (IS_IOS && isFlushingAutocorrectSuggestion.current) {
isFlushingAutocorrectSuggestion.current = false
onSubmit(nextText, replyTo)
onSubmit(nextText, messageEmbed, replyTo)
} else {
setText(nextText)
}
@@ -34,7 +34,10 @@ import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
import {ComposerContainer} from './MessageComposer'
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
import {
type MessageEmbedState,
useExtractEmbedFromFacets,
} from './MessageInputEmbed'
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
@@ -43,7 +46,7 @@ const MIN_HEIGHT = 40
export function MessageInput({
textInputId,
onSendMessage,
hasEmbed,
messageEmbed,
setEmbed,
children,
loading = false,
@@ -51,9 +54,10 @@ export function MessageInput({
textInputId?: string
onSendMessage: (
message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => Promise<void> | void
hasEmbed: boolean
messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
loading?: boolean
@@ -85,7 +89,7 @@ export function MessageInput({
if (!editable) {
return
}
if (!hasEmbed && message.trim() === '') {
if (!messageEmbed && message.trim() === '') {
return
}
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
@@ -96,6 +100,8 @@ export function MessageInput({
}
clearDraft()
playHaptic()
// Capture the embed before clearing - the deferred send below reads it.
const embed = messageEmbed
setEmbed(undefined)
setMessage('')
// Capture the reply before clearing - the deferred send below reads it.
@@ -115,6 +121,7 @@ export function MessageInput({
requestAnimationFrame(() => {
void onSendMessage(
message,
embed,
reply
? {...reply, $type: 'chat.bsky.convo.defs#messageView'}
: undefined,
@@ -122,7 +129,7 @@ export function MessageInput({
})
}, [
editable,
hasEmbed,
messageEmbed,
message,
clearDraft,
onSendMessage,
@@ -159,7 +166,8 @@ export function MessageInput({
scrollEnabled: isInputScrollable.get(),
}))
const submitDisabled = !editable || (!hasEmbed && message.trim().length === 0)
const submitDisabled =
!editable || (!messageEmbed && message.trim().length === 0)
const blur = useCallback(() => {
inputRef.current?.blur()
@@ -21,20 +21,24 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import * as Toast from '#/components/Toast'
import {IS_WEB_SAFARI, IS_WEB_TOUCH_DEVICE} from '#/env'
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
import {
type MessageEmbedState,
useExtractEmbedFromFacets,
} from './MessageInputEmbed'
export function MessageInput({
onSendMessage,
hasEmbed,
messageEmbed,
setEmbed,
children,
loading = false,
}: {
onSendMessage: (
message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => void
hasEmbed: boolean
messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
loading?: boolean
@@ -54,7 +58,7 @@ export function MessageInput({
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const onSubmit = useCallback(() => {
if (!hasEmbed && message.trim() === '') {
if (!messageEmbed && message.trim() === '') {
return
}
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
@@ -66,6 +70,7 @@ export function MessageInput({
clearDraft()
onSendMessage(
message,
messageEmbed,
replyTo
? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'}
: undefined,
@@ -78,7 +83,7 @@ export function MessageInput({
onSendMessage,
l,
clearDraft,
hasEmbed,
messageEmbed,
setEmbed,
replyTo,
clearReply,
@@ -378,7 +378,11 @@ export function MessagesList({
// -- Message sending
const onSendMessage = useCallback(
async (text: string, reply?: $Typed<ChatBskyConvoDefs.MessageView>) => {
async (
text: string,
embedState?: MessageEmbedState,
reply?: $Typed<ChatBskyConvoDefs.MessageView>,
) => {
let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
// detect facets without resolution first - this is used to see if there's
@@ -416,9 +420,9 @@ export function MessagesList({
}
}
if (messageEmbed?.type === 'post') {
if (embedState?.type === 'post') {
try {
const post = await getPost({uri: messageEmbed.uri})
const post = await getPost({uri: embedState.uri})
if (post) {
embed = {
$type: 'app.bsky.embed.record',
@@ -445,8 +449,8 @@ export function MessagesList({
} catch (error) {
logger.error('Failed to get post as quote for DM', {error})
}
} else if (messageEmbed?.type === 'invite') {
const code = messageEmbed.code
} else if (embedState?.type === 'invite') {
const code = embedState.code
embed = {
$type: 'chat.bsky.embed.joinLink',
code,
@@ -512,7 +516,6 @@ export function MessagesList({
[
agent,
convoState,
messageEmbed,
getPost,
getJoinLinkPreview,
hasSession,
@@ -737,6 +740,7 @@ function Composer({
textInputId: string
onSendMessage: (
message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => Promise<void>
messageEmbed: MessageEmbedState | undefined
@@ -745,8 +749,12 @@ function Composer({
useNewComposer: boolean
}) {
const handleSendMessage = useNonReactiveCallback(
(message: string, replyTo?: $Typed<ChatBskyConvoDefs.MessageView>) => {
void onSendMessage(message, replyTo)
(
message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => {
void onSendMessage(message, embed, replyTo)
},
)
@@ -761,7 +769,7 @@ function Composer({
<MessageComposer
textInputId={textInputId}
onSendMessage={handleSendMessage}
hasEmbed={!!messageEmbed}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}>
{previews}
@@ -770,7 +778,7 @@ function Composer({
<MessageInput
textInputId={textInputId}
onSendMessage={handleSendMessage}
hasEmbed={!!messageEmbed}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}>
{previews}