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 {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {type MessageEmbedState} from './MessageInputEmbed'
const MIN_HEIGHT = 40 const MIN_HEIGHT = 40
export function MessageComposer({ export function MessageComposer({
textInputId, textInputId,
onSendMessage, onSendMessage,
hasEmbed, messageEmbed,
setEmbed, setEmbed,
children, children,
loading = false, loading = false,
@@ -51,9 +52,10 @@ export function MessageComposer({
textInputId?: string textInputId?: string
onSendMessage: ( onSendMessage: (
message: string, message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>, replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => void ) => void
hasEmbed: boolean messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode children?: React.ReactNode
loading?: boolean 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 = ( const onSubmit = (
message: string, message: string,
embed: MessageEmbedState | undefined,
replyTo: ChatBskyConvoDefs.MessageView | null, replyTo: ChatBskyConvoDefs.MessageView | null,
) => { ) => {
if (!editable) return if (!editable) return
if (!hasEmbed && message.trim() === '') return if (!embed && message.trim() === '') return
const graphemeCount = countGraphemes(message) const graphemeCount = countGraphemes(message)
if (graphemeCount > MAX_DM_GRAPHEME_LENGTH) { if (graphemeCount > MAX_DM_GRAPHEME_LENGTH) {
Toast.show( Toast.show(
@@ -120,6 +124,7 @@ export function MessageComposer({
requestAnimationFrame(() => { requestAnimationFrame(() => {
onSendMessage( onSendMessage(
message, message,
embed,
replyTo replyTo
? { ? {
...replyTo, ...replyTo,
@@ -152,18 +157,18 @@ export function MessageComposer({
setTimeout(() => { setTimeout(() => {
if (isFlushingAutocorrectSuggestion.current) { if (isFlushingAutocorrectSuggestion.current) {
isFlushingAutocorrectSuggestion.current = false isFlushingAutocorrectSuggestion.current = false
onSubmit(text, replyTo) onSubmit(text, messageEmbed, replyTo)
} }
}, 20) }, 20)
} else { } else {
onSubmit(text, replyTo) onSubmit(text, messageEmbed, replyTo)
} }
} }
const handleChange = (nextText: string) => { const handleChange = (nextText: string) => {
if (IS_IOS && isFlushingAutocorrectSuggestion.current) { if (IS_IOS && isFlushingAutocorrectSuggestion.current) {
isFlushingAutocorrectSuggestion.current = false isFlushingAutocorrectSuggestion.current = false
onSubmit(nextText, replyTo) onSubmit(nextText, messageEmbed, replyTo)
} else { } else {
setText(nextText) setText(nextText)
} }
@@ -34,7 +34,10 @@ import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
import {ComposerContainer} from './MessageComposer' import {ComposerContainer} from './MessageComposer'
import {useExtractEmbedFromFacets} from './MessageInputEmbed' import {
type MessageEmbedState,
useExtractEmbedFromFacets,
} from './MessageInputEmbed'
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput) const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
@@ -43,7 +46,7 @@ const MIN_HEIGHT = 40
export function MessageInput({ export function MessageInput({
textInputId, textInputId,
onSendMessage, onSendMessage,
hasEmbed, messageEmbed,
setEmbed, setEmbed,
children, children,
loading = false, loading = false,
@@ -51,9 +54,10 @@ export function MessageInput({
textInputId?: string textInputId?: string
onSendMessage: ( onSendMessage: (
message: string, message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>, replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => Promise<void> | void ) => Promise<void> | void
hasEmbed: boolean messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode children?: React.ReactNode
loading?: boolean loading?: boolean
@@ -85,7 +89,7 @@ export function MessageInput({
if (!editable) { if (!editable) {
return return
} }
if (!hasEmbed && message.trim() === '') { if (!messageEmbed && message.trim() === '') {
return return
} }
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
@@ -96,6 +100,8 @@ export function MessageInput({
} }
clearDraft() clearDraft()
playHaptic() playHaptic()
// Capture the embed before clearing - the deferred send below reads it.
const embed = messageEmbed
setEmbed(undefined) setEmbed(undefined)
setMessage('') setMessage('')
// Capture the reply before clearing - the deferred send below reads it. // Capture the reply before clearing - the deferred send below reads it.
@@ -115,6 +121,7 @@ export function MessageInput({
requestAnimationFrame(() => { requestAnimationFrame(() => {
void onSendMessage( void onSendMessage(
message, message,
embed,
reply reply
? {...reply, $type: 'chat.bsky.convo.defs#messageView'} ? {...reply, $type: 'chat.bsky.convo.defs#messageView'}
: undefined, : undefined,
@@ -122,7 +129,7 @@ export function MessageInput({
}) })
}, [ }, [
editable, editable,
hasEmbed, messageEmbed,
message, message,
clearDraft, clearDraft,
onSendMessage, onSendMessage,
@@ -159,7 +166,8 @@ export function MessageInput({
scrollEnabled: isInputScrollable.get(), scrollEnabled: isInputScrollable.get(),
})) }))
const submitDisabled = !editable || (!hasEmbed && message.trim().length === 0) const submitDisabled =
!editable || (!messageEmbed && message.trim().length === 0)
const blur = useCallback(() => { const blur = useCallback(() => {
inputRef.current?.blur() 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 {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {IS_WEB_SAFARI, IS_WEB_TOUCH_DEVICE} from '#/env' import {IS_WEB_SAFARI, IS_WEB_TOUCH_DEVICE} from '#/env'
import {useExtractEmbedFromFacets} from './MessageInputEmbed' import {
type MessageEmbedState,
useExtractEmbedFromFacets,
} from './MessageInputEmbed'
export function MessageInput({ export function MessageInput({
onSendMessage, onSendMessage,
hasEmbed, messageEmbed,
setEmbed, setEmbed,
children, children,
loading = false, loading = false,
}: { }: {
onSendMessage: ( onSendMessage: (
message: string, message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>, replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => void ) => void
hasEmbed: boolean messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode children?: React.ReactNode
loading?: boolean loading?: boolean
@@ -54,7 +58,7 @@ export function MessageInput({
const textAreaRef = useRef<HTMLTextAreaElement>(null) const textAreaRef = useRef<HTMLTextAreaElement>(null)
const onSubmit = useCallback(() => { const onSubmit = useCallback(() => {
if (!hasEmbed && message.trim() === '') { if (!messageEmbed && message.trim() === '') {
return return
} }
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
@@ -66,6 +70,7 @@ export function MessageInput({
clearDraft() clearDraft()
onSendMessage( onSendMessage(
message, message,
messageEmbed,
replyTo replyTo
? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'} ? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'}
: undefined, : undefined,
@@ -78,7 +83,7 @@ export function MessageInput({
onSendMessage, onSendMessage,
l, l,
clearDraft, clearDraft,
hasEmbed, messageEmbed,
setEmbed, setEmbed,
replyTo, replyTo,
clearReply, clearReply,
@@ -378,7 +378,11 @@ export function MessagesList({
// -- Message sending // -- Message sending
const onSendMessage = useCallback( 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}) let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
// detect facets without resolution first - this is used to see if there's // 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 { try {
const post = await getPost({uri: messageEmbed.uri}) const post = await getPost({uri: embedState.uri})
if (post) { if (post) {
embed = { embed = {
$type: 'app.bsky.embed.record', $type: 'app.bsky.embed.record',
@@ -445,8 +449,8 @@ export function MessagesList({
} catch (error) { } catch (error) {
logger.error('Failed to get post as quote for DM', {error}) logger.error('Failed to get post as quote for DM', {error})
} }
} else if (messageEmbed?.type === 'invite') { } else if (embedState?.type === 'invite') {
const code = messageEmbed.code const code = embedState.code
embed = { embed = {
$type: 'chat.bsky.embed.joinLink', $type: 'chat.bsky.embed.joinLink',
code, code,
@@ -512,7 +516,6 @@ export function MessagesList({
[ [
agent, agent,
convoState, convoState,
messageEmbed,
getPost, getPost,
getJoinLinkPreview, getJoinLinkPreview,
hasSession, hasSession,
@@ -737,6 +740,7 @@ function Composer({
textInputId: string textInputId: string
onSendMessage: ( onSendMessage: (
message: string, message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>, replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => Promise<void> ) => Promise<void>
messageEmbed: MessageEmbedState | undefined messageEmbed: MessageEmbedState | undefined
@@ -745,8 +749,12 @@ function Composer({
useNewComposer: boolean useNewComposer: boolean
}) { }) {
const handleSendMessage = useNonReactiveCallback( 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 <MessageComposer
textInputId={textInputId} textInputId={textInputId}
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
hasEmbed={!!messageEmbed} messageEmbed={messageEmbed}
setEmbed={setEmbed} setEmbed={setEmbed}
loading={loading}> loading={loading}>
{previews} {previews}
@@ -770,7 +778,7 @@ function Composer({
<MessageInput <MessageInput
textInputId={textInputId} textInputId={textInputId}
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
hasEmbed={!!messageEmbed} messageEmbed={messageEmbed}
setEmbed={setEmbed} setEmbed={setEmbed}
loading={loading}> loading={loading}>
{previews} {previews}