diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index e8ba7003f2..d27e0f4d85 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -44,12 +44,13 @@ import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, native, platform, useTheme, utils} from '#/alf'
+import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies'
+import {useReplyPreviewText} from '#/components/dms/replyPreview'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
@@ -834,6 +835,7 @@ function ReplyQuote({
}) {
const t = useTheme()
const {t: l} = useLingui()
+ const getReplyPreviewText = useReplyPreviewText()
const senderProfile = useMaybeProfileShadow(
relatedProfiles.get(replyTo.sender.did),
@@ -857,22 +859,15 @@ function ReplyQuote({
let text: string
let subtle = false
if (isBlocked) {
- text = l`Blocked message hidden`
+ text = l({
+ message: '(blocked message hidden)',
+ comment: 'A reply summary in chat',
+ })
subtle = true
} else if (ChatBskyConvoDefs.isMessageView(replyTo)) {
- text = replyTo.text
- if (!text.trim()) {
- subtle = true
- if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) {
- text = l`(chat invite link)`
- } else if (AppBskyEmbedRecord.isView(replyTo.embed)) {
- text = l`(contains embedded content)`
- } else {
- text = l`No text`
- }
- }
+ ;({text, subtle} = getReplyPreviewText(replyTo))
} else {
- text = l`Deleted message`
+ text = l({message: '(deleted message)', comment: 'A reply summary in chat'})
subtle = true
}
@@ -888,6 +883,8 @@ function ReplyQuote({
a.mb_xs,
a.rounded_md,
a.p_sm,
+ // The padding above is a little loose, so we tighten it up here.
+ {paddingTop: tokens.space.sm - 2},
a.flex_col,
a.align_start,
a.border,
diff --git a/src/components/dms/replyPreview.ts b/src/components/dms/replyPreview.ts
new file mode 100644
index 0000000000..53c78b2c46
--- /dev/null
+++ b/src/components/dms/replyPreview.ts
@@ -0,0 +1,112 @@
+import {
+ AppBskyEmbedExternal,
+ AppBskyEmbedRecord,
+ type ChatBskyConvoDefs,
+ ChatBskyEmbedJoinLink,
+ ChatBskyGroupDefs,
+} from '@atproto/api'
+import {useLingui} from '@lingui/react/macro'
+
+import {BSKY_APP_HOST, toShortUrl} from '#/lib/strings/url-helpers'
+
+/**
+ * Describes the embed of a quoted message that has no text of its own, so the
+ * reply preview can show what was shared instead of a generic placeholder. For
+ * a link card we surface the URI directly; otherwise we classify the quoted
+ * record (post/feed/list/etc.) so the caller can render a translated label.
+ */
+type ReplyEmbedSummary =
+ | {type: 'external'; uri: string}
+ | {type: 'post'}
+ | {type: 'unknown'}
+
+function summarizeReplyEmbed(
+ embed: ChatBskyConvoDefs.MessageView['embed'],
+): ReplyEmbedSummary {
+ if (!AppBskyEmbedRecord.isView(embed)) return {type: 'unknown'}
+ const {record} = embed
+ if (AppBskyEmbedRecord.isViewRecord(record)) {
+ const inner = record.embeds?.[0]
+ if (AppBskyEmbedExternal.isView(inner)) {
+ return {type: 'external', uri: inner.external.uri}
+ }
+ return {type: 'post'}
+ }
+ return {type: 'unknown'}
+}
+
+/**
+ * Returns a formatter that computes the preview text for a message being quoted
+ * in a reply, shared between the staged-reply composer and the sent reply
+ * bubble so the two stay in sync. When the message has its own text we use it
+ * verbatim; otherwise we summarize the embed.
+ *
+ * `subtle` indicates the text is a placeholder (not real message content), so
+ * callers can render it in a muted/italic style.
+ */
+export function useReplyPreviewText(): (
+ message: ChatBskyConvoDefs.MessageView,
+) => {text: string; subtle: boolean} {
+ const {t: l} = useLingui()
+
+ return (message: ChatBskyConvoDefs.MessageView) => {
+ const text = message.text
+ if (text.trim()) {
+ return {text, subtle: false}
+ }
+
+ if (ChatBskyEmbedJoinLink.isView(message.embed)) {
+ const {joinLinkPreview} = message.embed
+ if (ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) {
+ return {
+ text: `${BSKY_APP_HOST}/chat/${joinLinkPreview.code}`,
+ subtle: true,
+ }
+ }
+ if (ChatBskyGroupDefs.isDisabledJoinLinkPreviewView(joinLinkPreview)) {
+ return {
+ text: l({
+ message: '(disabled chat invite link)',
+ comment: 'A reply summary in chat',
+ }),
+ subtle: true,
+ }
+ }
+ if (ChatBskyGroupDefs.isInvalidJoinLinkPreviewView(joinLinkPreview)) {
+ return {
+ text: l({
+ message: '(invalid chat invite link)',
+ comment: 'A reply summary in chat',
+ }),
+ subtle: true,
+ }
+ }
+ return {
+ text: l({
+ message: '(chat invite link)',
+ comment: 'A reply summary in chat',
+ }),
+ subtle: true,
+ }
+ }
+
+ const summary = summarizeReplyEmbed(message.embed)
+ switch (summary.type) {
+ case 'external':
+ return {text: toShortUrl(summary.uri), subtle: true}
+ case 'post':
+ return {
+ text: l({
+ message: '(quoted post)',
+ comment: 'A reply summary in chat',
+ }),
+ subtle: true,
+ }
+ default:
+ return {
+ text: l({message: '(no text)', comment: 'A reply summary in chat'}),
+ subtle: true,
+ }
+ }
+ }
+}
diff --git a/src/screens/Messages/components/MessageInputReply.tsx b/src/screens/Messages/components/MessageInputReply.tsx
index 5853e46d81..6b28b2f7b1 100644
--- a/src/screens/Messages/components/MessageInputReply.tsx
+++ b/src/screens/Messages/components/MessageInputReply.tsx
@@ -1,5 +1,5 @@
import {LayoutAnimation, View} from 'react-native'
-import {AppBskyEmbedRecord, ChatBskyEmbedJoinLink} from '@atproto/api'
+import {type ChatBskyConvoDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants'
@@ -8,6 +8,7 @@ import {useConvoActive} from '#/state/messages/convo'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useMessageReplies} from '#/components/dms/MessageReplies'
+import {useReplyPreviewText} from '#/components/dms/replyPreview'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
@@ -16,15 +17,27 @@ import {Text} from '#/components/Typography'
* being replied to, with a button to cancel the reply.
*/
export function MessageInputReply() {
- const t = useTheme()
- const {t: l} = useLingui()
- const convo = useConvoActive()
- const {replyTo, clearReply} = useMessageReplies()
+ const {replyTo} = useMessageReplies()
if (!replyTo) {
return null
}
+ return
+}
+
+function MessageInputReplyInner({
+ replyTo,
+}: {
+ replyTo: ChatBskyConvoDefs.MessageView
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+ const convo = useConvoActive()
+ const {clearReply} = useMessageReplies()
+ const getReplyPreviewText = useReplyPreviewText()
+ const {text, subtle} = getReplyPreviewText(replyTo)
+
const onRemove = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
clearReply()
@@ -35,19 +48,6 @@ export function MessageInputReply() {
? createSanitizedDisplayName(senderProfile, false)
: null
- let text = replyTo.text
- let subtle = false
- if (!text.trim()) {
- subtle = true
- if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) {
- text = l`(chat invite link)`
- } else if (AppBskyEmbedRecord.isView(replyTo.embed)) {
- text = l`(contains embedded content)`
- } else {
- text = l`No text`
- }
- }
-
return (