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 (