Fix DM embeds being dropped when sending (#10924)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-16 21:43:49 +03:00
committed by GitHub
parent 721965e67c
commit 8d4f59a146
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}