From 3ebae337f1c0794b90537e619e0b8d186d943dad Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 16 Jun 2026 21:32:26 +0300 Subject: [PATCH] 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) --- .../Messages/components/MessageComposer.tsx | 19 ++++++++----- .../Messages/components/MessageInput.tsx | 20 +++++++++---- .../Messages/components/MessageInput.web.tsx | 15 ++++++---- .../Messages/components/MessagesList.tsx | 28 ++++++++++++------- 4 files changed, 54 insertions(+), 28 deletions(-) diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 926a3cccca..6cdc756bb3 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -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, ) => 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) } diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx index cbd47151d7..441fd75fa4 100644 --- a/src/screens/Messages/components/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -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, ) => Promise | 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() diff --git a/src/screens/Messages/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx index 2fe3d86fa1..6142528f85 100644 --- a/src/screens/Messages/components/MessageInput.web.tsx +++ b/src/screens/Messages/components/MessageInput.web.tsx @@ -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, ) => 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(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, diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index be8ebcb8a9..8fbfa88e04 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -378,7 +378,11 @@ export function MessagesList({ // -- Message sending const onSendMessage = useCallback( - async (text: string, reply?: $Typed) => { + async ( + text: string, + embedState?: MessageEmbedState, + reply?: $Typed, + ) => { 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, ) => Promise messageEmbed: MessageEmbedState | undefined @@ -745,8 +749,12 @@ function Composer({ useNewComposer: boolean }) { const handleSendMessage = useNonReactiveCallback( - (message: string, replyTo?: $Typed) => { - void onSendMessage(message, replyTo) + ( + message: string, + embed?: MessageEmbedState, + replyTo?: $Typed, + ) => { + void onSendMessage(message, embed, replyTo) }, ) @@ -761,7 +769,7 @@ function Composer({ {previews} @@ -770,7 +778,7 @@ function Composer({ {previews}