From b8fdce64783778008234b667c7a225d439702eda Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 16 Jun 2026 20:26:48 +0300 Subject: [PATCH] Message replies in chat (#10903) Co-authored-by: Claude Opus 4.8 (1M context) --- ...rnerDownRight_stroke2_corner2_rounded.svg} | 0 ...ornerDownRight_stroke2_corner3_rounded.svg | 1 + package.json | 2 +- pnpm-lock.yaml | 10 +- src/analytics/metrics/types.ts | 8 + src/components/ContextMenu/index.tsx | 12 +- src/components/dms/MessageContextMenu.tsx | 12 +- src/components/dms/MessageItem.tsx | 285 ++++++++++++++- src/components/dms/MessageReplies.tsx | 124 +++++++ src/components/icons/ArrowCornerDownRight.tsx | 6 + src/screens/Messages/Conversation.tsx | 1 - .../Messages/components/MessageComposer.tsx | 37 +- .../Messages/components/MessageInput.tsx | 20 +- .../Messages/components/MessageInput.web.tsx | 27 +- .../Messages/components/MessageInputReply.tsx | 91 +++++ .../Messages/components/MessagesList.tsx | 337 +++++++++++------- src/state/messages/convo/agent.ts | 82 +++-- src/state/messages/convo/types.ts | 1 + src/view/com/util/List.web.tsx | 49 +++ 19 files changed, 925 insertions(+), 180 deletions(-) rename assets/icons/{arrowCornerDownRight_stroke2_rounded_2_rounded.svg => arrowCornerDownRight_stroke2_corner2_rounded.svg} (100%) create mode 100644 assets/icons/arrowCornerDownRight_stroke2_corner3_rounded.svg create mode 100644 src/components/dms/MessageReplies.tsx create mode 100644 src/screens/Messages/components/MessageInputReply.tsx diff --git a/assets/icons/arrowCornerDownRight_stroke2_rounded_2_rounded.svg b/assets/icons/arrowCornerDownRight_stroke2_corner2_rounded.svg similarity index 100% rename from assets/icons/arrowCornerDownRight_stroke2_rounded_2_rounded.svg rename to assets/icons/arrowCornerDownRight_stroke2_corner2_rounded.svg diff --git a/assets/icons/arrowCornerDownRight_stroke2_corner3_rounded.svg b/assets/icons/arrowCornerDownRight_stroke2_corner3_rounded.svg new file mode 100644 index 0000000000..ff7edc539e --- /dev/null +++ b/assets/icons/arrowCornerDownRight_stroke2_corner3_rounded.svg @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index 7499cf8114..7d61fb5264 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.12", + "@atproto/api": "0.20.15", "@atproto/syntax": "0.6.1", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1efbc5defc..80917f76c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,8 +242,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.12 - version: 0.20.12 + specifier: 0.20.15 + version: 0.20.15 '@atproto/syntax': specifier: 0.6.1 version: 0.6.1 @@ -877,8 +877,8 @@ packages: graphql: optional: true - '@atproto/api@0.20.12': - resolution: {integrity: sha512-pNCrl/BSmkjlrVu0W5A9zkOVRIWdWAdajuHX/EyhDZFxu8HExsiQi6j46H5kv85GrNRXS/QtULE+ocSuMtEJfw==} + '@atproto/api@0.20.15': + resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==} engines: {node: '>=22'} '@atproto/common-web@0.5.0': @@ -9494,7 +9494,7 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.12': + '@atproto/api@0.20.15': dependencies: '@atproto/common-web': 0.5.0 '@atproto/lexicon': 0.7.1 diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index eec5c3c207..c5b9ff28be 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -586,6 +586,14 @@ export type Events = { | 'SendViaChatDialog' | 'ConvoSettings' } + // Message replies + 'chat:message:reply:send': { + convoId: string + isGroup: boolean + } + 'chat:message:reply:tap': { + convoId: string + } // Group chat adoption 'groupchat:create': { diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 51bf83309f..0d8a2761e5 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -850,10 +850,11 @@ export function Item({ !unstyled && [ a.flex_row, a.align_center, - a.px_2xl, + a.px_lg, + a.gap_sm, a.rounded_md, t.atoms.bg_contrast_25, - {gap: 6, minHeight: 44, paddingVertical: 10}, + {minHeight: 44, paddingVertical: 10}, (focused || pressed || context.hoveredMenuItem === id) && !rest.disabled && t.atoms.bg_contrast_50, @@ -882,8 +883,7 @@ export function ItemText({children, style}: ItemTextProps) { style={[ a.flex_1, a.text_md, - a.font_semi_bold, - t.atoms.text_contrast_high, + a.font_medium, style, destructive && {color: t.palette.negative_500}, disabled && t.atoms.text_contrast_low, @@ -898,13 +898,13 @@ export function ItemIcon({icon: Comp}: ItemIconProps) { const {disabled, destructive} = useContextMenuItemContext() return ( ) diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index ddfa5118c0..a9d3108cef 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -20,6 +20,8 @@ import {atoms as a} from '#/alf' import * as ContextMenu from '#/components/ContextMenu' import {type TriggerProps} from '#/components/ContextMenu/types' import {useMessageDialogs} from '#/components/dms/MessageOverlays' +import {useMessageReplies} from '#/components/dms/MessageReplies' +import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language' @@ -47,6 +49,7 @@ export let MessageContextMenu = ({ const {currentAccount} = useSession() const convo = useConvoActive() const {openDeleteMessage, openReportMessage} = useMessageDialogs() + const {setReply} = useMessageReplies() const langPrefs = useLanguagePrefs() const translate = useGoogleTranslate() @@ -151,6 +154,13 @@ export let MessageContextMenu = ({ timeStyle: 'short', })}`} style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> + setReply(message)}> + + {l`Reply`} + {message.text.length > 0 && ( <> )} openDeleteMessage(message)}> @@ -181,7 +190,6 @@ export let MessageContextMenu = ({ {!isFromSelf && ( openReportMessage(message, senderProfile)}> diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 192e8f9e7b..05e165bed3 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -14,6 +14,8 @@ import Animated, { LinearTransition, useAnimatedStyle, useSharedValue, + withDelay, + withSequence, withTiming, ZoomIn, ZoomOut, @@ -41,11 +43,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} from '#/alf' +import {atoms as a, native, platform, 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 {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight' import {InlineLinkText} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import * as Prompt from '#/components/Prompt' @@ -63,28 +67,48 @@ import { const AVATAR_SIZE = 28 const CLUSTERED_MESSAGE_GAP = 2 -const BORDER_RADIUS = 18 +const BORDER_RADIUS = 20 const SQUARED_BORDER_RADIUS = 4 const DISPLAY_NAME_INSET = 20 +function messageIsReply( + message: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null, +): boolean { + return ( + ChatBskyConvoDefs.isMessageView(message) && + (ChatBskyConvoDefs.isMessageView(message.replyTo) || + ChatBskyConvoDefs.isDeletedMessageView(message.replyTo)) + ) +} + function isWithinClusterBoundary({ isPending, + message, adjacentMessage, isFromSameSender, - currentSentAt, direction, }: { isPending: boolean + message: ChatBskyConvoDefs.MessageView adjacentMessage: | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null isFromSameSender: boolean - currentSentAt: string direction: 'prev' | 'next' }): boolean { + // A reply always starts its own cluster, breaking grouping with the message + // above it. Looking back, that's a boundary if this message is a reply; + // looking forward, it's a boundary if the next message is a reply. + if (messageIsReply(direction === 'prev' ? message : adjacentMessage)) { + return true + } if (!isFromSameSender) return true if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) { + const currentSentAt = message.sentAt const thisDate = new Date(currentSentAt) const adjDate = new Date(adjacentMessage.sentAt) const diff = @@ -128,6 +152,16 @@ let MessageItem = ({ const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did)) const {openReactions} = useMessageDialogs() + const {scrollToMessage, highlightedMessage} = useMessageReplies() + + // `replyTo` comes back hydrated as the referenced message (or a deleted- + // message tombstone). Narrow away the open-union fallback so we only render + // shapes we understand. + const replyTo = + ChatBskyConvoDefs.isMessageView(message.replyTo) || + ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) + ? message.replyTo + : undefined const isPending = item.type === 'pending-message' @@ -150,17 +184,17 @@ let MessageItem = ({ const isFirstInCluster = isWithinClusterBoundary({ isPending, + message, adjacentMessage: prevMessage, isFromSameSender: isPrevFromSameSender, - currentSentAt: message.sentAt, direction: 'prev', }) const isLastInCluster = isWithinClusterBoundary({ isPending, + message, adjacentMessage: nextMessage, isFromSameSender: isNextFromSameSender, - currentSentAt: message.sentAt, direction: 'next', }) @@ -226,6 +260,26 @@ let MessageItem = ({ topRadiusSV.set(withTiming(targetTopRadius, {duration: 300})) }, [targetTopRadius, topRadiusSV]) + // Flash the message background when it's been scrolled to (e.g. by tapping a + // reply that quotes it), so it's easy to spot. Keyed on the highlight `key` + // so re-tapping the same message re-triggers the flash. + const highlightSV = useSharedValue(0) + const isHighlighted = highlightedMessage?.id === message.id + const highlightKey = isHighlighted ? highlightedMessage.key : null + useEffect(() => { + if (highlightKey === null) return + highlightSV.set( + withSequence( + withTiming(1, {duration: 150}), + withDelay(400, withTiming(0, {duration: 450})), + ), + ) + }, [highlightKey, highlightSV]) + + const highlightStyle = useAnimatedStyle(() => ({ + opacity: highlightSV.get(), + })) + const borderRadiusStyle = useAnimatedStyle(() => isFromSelf ? { @@ -372,6 +426,14 @@ let MessageItem = ({ web: a.mx_lg, }) + // Negative of `messageInset` so the flash bleeds past the row's horizontal + // margin to the screen edges. + const flashBleed = platform({ + android: -a.mx_sm.marginLeft, + ios: -a.mx_md.marginLeft, + web: -a.mx_lg.marginLeft, + }) + return ( <> {hasLargeGapFromPrev && } @@ -381,6 +443,20 @@ let MessageItem = ({ isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP}, hasReactions && {paddingBottom: 26}, ]}> + {showAvatar ? ( {avatar} @@ -391,7 +467,16 @@ let MessageItem = ({ a.flex_grow, !isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE}, ]}> - {displayName && showDisplayName ? ( + {replyTo ? ( + scrollToMessage(replyTo.id)} + /> + ) : displayName && showDisplayName ? ( + {replyTo && !isOnlyEmoji(message.text) ? ( + scrollToMessage(replyTo.id)} + /> + ) : null} ) } + +/** + * The "↪ X replied to Y" caption rendered above a reply message, in place of + * the display name. `X` is the person sending the reply (self -> "you"), `Y` is + * the original sender. Tapping it scrolls to the original (if loaded). + * + * Aligns with the sender's display name for others (left), or with the message + * bubble for self (right). + */ +function ReplyCaption({ + replyTo, + isFromSelf, + isGroupChat, + replierDisplayName, + relatedProfiles, + onPress, +}: { + replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + isFromSelf: boolean + isGroupChat: boolean + replierDisplayName: string | null + relatedProfiles: Map + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const {currentAccount} = useSession() + + const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did + const originalProfile = relatedProfiles.get(replyTo.sender.did) + const originalName = originalSenderIsSelf + ? null + : originalProfile + ? createSanitizedDisplayName(originalProfile) + : null + + return ( + + ) +} + +/** + * The nested quote of the original message, rendered at the top of a reply + * bubble. Tapping it scrolls to the original (if loaded). + */ +function ReplyQuote({ + replyTo, + isFromSelf, + relatedProfiles, + onPress, +}: { + replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + isFromSelf: boolean + relatedProfiles: Map + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + const senderProfile = useMaybeProfileShadow( + relatedProfiles.get(replyTo.sender.did), + ) + // Hide the quoted content if we block, or are blocked by, the original + // sender - mirroring how the message bubble itself is hidden. + const isBlocked = senderProfile ? isBlockedOrBlocking(senderProfile) : false + const senderName = + senderProfile && !isBlocked + ? createSanitizedDisplayName(senderProfile) + : null + + const tintColor = isFromSelf ? t.palette.white : t.atoms.text.color + const subtleColor = isFromSelf + ? t.palette.white + : t.atoms.text_contrast_high.color + const borderColor = isFromSelf + ? utils.alpha(t.palette.white, 0.5) + : t.atoms.border_contrast_high.borderColor + + let text: string + let subtle = false + if (isBlocked) { + text = l`Blocked message hidden` + 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` + } + } + } else { + text = l`Deleted message` + subtle = true + } + + return ( + + ) +} diff --git a/src/components/dms/MessageReplies.tsx b/src/components/dms/MessageReplies.tsx new file mode 100644 index 0000000000..5093d8d157 --- /dev/null +++ b/src/components/dms/MessageReplies.tsx @@ -0,0 +1,124 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import {type ChatBskyConvoDefs} from '@atproto/api' + +/** + * How long a message stays highlighted after scrolling to it, before the flash + * fades out. + */ +export const MESSAGE_HIGHLIGHT_DURATION_MS = 1500 + +type HighlightedMessage = { + id: string + /** + * Bumped on every highlight so that re-tapping the same reply re-triggers the + * flash even while the previous highlight is still active. + */ + key: number +} + +type MessageRepliesContextType = { + /** + * The message currently staged for reply in the composer, or null. + */ + replyTo: ChatBskyConvoDefs.MessageView | null + setReply: (message: ChatBskyConvoDefs.MessageView) => void + clearReply: () => void + /** + * Scroll the list to a message, if it's currently loaded, and flash it. No-op + * otherwise. + */ + scrollToMessage: (messageId: string) => void + /** + * The message to flash, or null. Consumers compare against their own id. + */ + highlightedMessage: HighlightedMessage | null +} + +const Context = createContext(null) + +export function useMessageReplies() { + const ctx = useContext(Context) + if (!ctx) { + throw new Error( + 'useMessageReplies must be used within a MessageRepliesProvider', + ) + } + return ctx +} + +export function MessageRepliesProvider({ + children, + scrollToMessage: scrollToMessageRaw, +}: { + children: React.ReactNode + /** + * Performs the actual scroll. Returns true if the message was found and + * scrolled to, false if it isn't currently loaded (so we know whether to + * flash it). + */ + scrollToMessage: (messageId: string) => boolean +}) { + const [replyTo, setReplyTo] = useState( + null, + ) + const [highlightedMessage, setHighlightedMessage] = + useState(null) + const highlightKey = useRef(0) + const clearHighlightTimeout = useRef | null>( + null, + ) + + const setReply = useCallback((message: ChatBskyConvoDefs.MessageView) => { + setReplyTo(message) + }, []) + + const clearReply = useCallback(() => { + setReplyTo(null) + }, []) + + const scrollToMessage = useCallback( + (messageId: string) => { + const didScroll = scrollToMessageRaw(messageId) + if (!didScroll) return + + highlightKey.current += 1 + setHighlightedMessage({id: messageId, key: highlightKey.current}) + if (clearHighlightTimeout.current) { + clearTimeout(clearHighlightTimeout.current) + } + clearHighlightTimeout.current = setTimeout(() => { + setHighlightedMessage(null) + }, MESSAGE_HIGHLIGHT_DURATION_MS) + }, + [scrollToMessageRaw], + ) + + useEffect(() => { + return () => { + if (clearHighlightTimeout.current) { + clearTimeout(clearHighlightTimeout.current) + } + } + }, []) + + const ctx = useMemo( + () => ({ + replyTo, + setReply, + clearReply, + scrollToMessage, + highlightedMessage, + }), + [replyTo, setReply, clearReply, scrollToMessage, highlightedMessage], + ) + + return {children} +} diff --git a/src/components/icons/ArrowCornerDownRight.tsx b/src/components/icons/ArrowCornerDownRight.tsx index 86dde70154..85346f270d 100644 --- a/src/components/icons/ArrowCornerDownRight.tsx +++ b/src/components/icons/ArrowCornerDownRight.tsx @@ -5,3 +5,9 @@ export const ArrowCornerDownRight_Stroke2_Corner2_Rounded = createSinglePathSVG( path: 'M15.793 10.293a1 1 0 0 1 1.338-.068l.076.068 3.293 3.293a2 2 0 0 1 .138 2.677l-.138.151-3.293 3.293a1 1 0 1 1-1.414-1.414L18.086 16H8a5 5 0 0 1-5-5V5a1 1 0 0 1 2 0v6a3 3 0 0 0 3 3h10.086l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z', }, ) + +export const ArrowCornerDownRight_Stroke2_Corner3_Rounded = createSinglePathSVG( + { + path: 'M5 5a1 1 0 0 0-2 0v4a7 7 0 0 0 7 7h8.086l-2.293 2.293a1 1 0 0 0 1.414 1.414l2.94-2.94a2.5 2.5 0 0 0 0-3.535l-2.94-2.94a1 1 0 1 0-1.414 1.415L18.086 14H10a5 5 0 0 1-5-5V5Z', + }, +) diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 36c0a1ec2f..1e2f4037a8 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -262,7 +262,6 @@ function InnerReady({ {IS_LIQUID_GLASS ? ( {header} diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 83bab02be3..926a3cccca 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -1,4 +1,4 @@ -import {useRef, useState} from 'react' +import {useEffect, useRef, useState} from 'react' import {Pressable, View} from 'react-native' import { useKeyboardHandler, @@ -13,6 +13,7 @@ import Animated, { import {useSafeAreaInsets} from 'react-native-safe-area-context' import {GlassContainer} from 'expo-glass-effect' import {LinearGradient} from 'expo-linear-gradient' +import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' import {ScrollEdgeEffect} from '@bsky.app/expo-scroll-edge-effect' import {useLingui} from '@lingui/react/macro' import {countGraphemes} from 'unicode-segmenter/grapheme' @@ -28,6 +29,7 @@ import { } from '#/state/messages/message-drafts' import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf' import {Composer, useComposerInternalApiRef} from '#/components/Composer' +import {useMessageReplies} from '#/components/dms/MessageReplies' import * as EmojiPicker from '#/components/EmojiPicker' import {GlassView} from '#/components/GlassView' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' @@ -47,7 +49,10 @@ export function MessageComposer({ loading = false, }: { textInputId?: string - onSendMessage: (message: string) => void + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode @@ -60,10 +65,16 @@ export function MessageComposer({ const editable = !needsEmailVerification && !loading const {getDraft, clearDraft} = useMessageDraft() const composerInternalApiRef = useComposerInternalApiRef() + const {replyTo, clearReply} = useMessageReplies() const [text, setText] = useState(getDraft) useSaveMessageDraft(text) + useEffect(() => { + if (!replyTo) return + composerInternalApiRef.current?.input?.focus() + }, [replyTo, composerInternalApiRef]) + // Android interactive dismiss sometimes doesn't blur the input const blur = useNonReactiveCallback(() => { composerInternalApiRef.current?.input?.blur() @@ -80,7 +91,10 @@ export function MessageComposer({ const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0) - const onSubmit = (message: string) => { + const onSubmit = ( + message: string, + replyTo: ChatBskyConvoDefs.MessageView | null, + ) => { if (!editable) return if (!hasEmbed && message.trim() === '') return const graphemeCount = countGraphemes(message) @@ -95,6 +109,7 @@ export function MessageComposer({ clearDraft() playHaptic() setEmbed(undefined) + clearReply() composerInternalApiRef.current?.clear() if (IS_WEB) { @@ -103,7 +118,15 @@ export function MessageComposer({ // defer send by a frame so that the textinput resizes before we send the message requestAnimationFrame(() => { - onSendMessage(message) + onSendMessage( + message, + replyTo + ? { + ...replyTo, + $type: 'chat.bsky.convo.defs#messageView', + } + : undefined, + ) }) } @@ -129,18 +152,18 @@ export function MessageComposer({ setTimeout(() => { if (isFlushingAutocorrectSuggestion.current) { isFlushingAutocorrectSuggestion.current = false - onSubmit(text) + onSubmit(text, replyTo) } }, 20) } else { - onSubmit(text) + onSubmit(text, replyTo) } } const handleChange = (nextText: string) => { if (IS_IOS && isFlushingAutocorrectSuggestion.current) { isFlushingAutocorrectSuggestion.current = false - onSubmit(nextText) + onSubmit(nextText, replyTo) } else { setText(nextText) } diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx index dbe716efb1..cbd47151d7 100644 --- a/src/screens/Messages/components/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -15,6 +15,7 @@ import Animated, { } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {GlassContainer} from 'expo-glass-effect' +import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' import {useLingui} from '@lingui/react/macro' import {countGraphemes} from 'unicode-segmenter/grapheme' @@ -26,6 +27,7 @@ import { useSaveMessageDraft, } from '#/state/messages/message-drafts' import {atoms as a, platform, tokens, useTheme} from '#/alf' +import {useMessageReplies} from '#/components/dms/MessageReplies' import {GlassView} from '#/components/GlassView' import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' import {Loader} from '#/components/Loader' @@ -47,7 +49,10 @@ export function MessageInput({ loading = false, }: { textInputId?: string - onSendMessage: (message: string) => Promise | void + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => Promise | void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode @@ -57,6 +62,7 @@ export function MessageInput({ const t = useTheme() const playHaptic = useHaptics() const {getDraft, clearDraft} = useMessageDraft() + const {replyTo, clearReply} = useMessageReplies() // Input layout const {top: topInset} = useSafeAreaInsets() @@ -92,6 +98,9 @@ export function MessageInput({ playHaptic() setEmbed(undefined) setMessage('') + // Capture the reply before clearing - the deferred send below reads it. + const reply = replyTo + clearReply() if (IS_IOS) { setShouldEnforceClear(true) } @@ -104,7 +113,12 @@ export function MessageInput({ } requestAnimationFrame(() => { - void onSendMessage(message) + void onSendMessage( + message, + reply + ? {...reply, $type: 'chat.bsky.convo.defs#messageView'} + : undefined, + ) }) }, [ editable, @@ -116,6 +130,8 @@ export function MessageInput({ setEmbed, inputRef, l, + replyTo, + clearReply, ]) useFocusedInputHandler( diff --git a/src/screens/Messages/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx index 17c996cec9..2fe3d86fa1 100644 --- a/src/screens/Messages/components/MessageInput.web.tsx +++ b/src/screens/Messages/components/MessageInput.web.tsx @@ -1,5 +1,6 @@ import {useCallback, useRef, useState} from 'react' import {Pressable, View} from 'react-native' +import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' import {useLingui} from '@lingui/react/macro' import {flushSync} from 'react-dom' import TextareaAutosize from 'react-textarea-autosize' @@ -13,6 +14,7 @@ import { } from '#/state/messages/message-drafts' import {atoms as a, flatten, useTheme} from '#/alf' import {Button} from '#/components/Button' +import {useMessageReplies} from '#/components/dms/MessageReplies' import * as EmojiPicker from '#/components/EmojiPicker' import {useSharedInputStyles} from '#/components/forms/TextField' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' @@ -28,7 +30,10 @@ export function MessageInput({ children, loading = false, }: { - onSendMessage: (message: string) => void + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode @@ -38,6 +43,7 @@ export function MessageInput({ const {t: l} = useLingui() const t = useTheme() const {getDraft, clearDraft} = useMessageDraft() + const {replyTo, clearReply} = useMessageReplies() const [message, setMessage] = useState(getDraft) const inputStyles = useSharedInputStyles() @@ -58,10 +64,25 @@ export function MessageInput({ return } clearDraft() - onSendMessage(message) + onSendMessage( + message, + replyTo + ? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'} + : undefined, + ) + clearReply() setMessage('') setEmbed(undefined) - }, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed]) + }, [ + message, + onSendMessage, + l, + clearDraft, + hasEmbed, + setEmbed, + replyTo, + clearReply, + ]) const onKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/src/screens/Messages/components/MessageInputReply.tsx b/src/screens/Messages/components/MessageInputReply.tsx new file mode 100644 index 0000000000..5853e46d81 --- /dev/null +++ b/src/screens/Messages/components/MessageInputReply.tsx @@ -0,0 +1,91 @@ +import {LayoutAnimation, View} from 'react-native' +import {AppBskyEmbedRecord, ChatBskyEmbedJoinLink} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' + +import {HITSLOP_20} from '#/lib/constants' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +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 {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +/** + * The reply staged in the message composer. Renders a preview of the message + * 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() + + if (!replyTo) { + return null + } + + const onRemove = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + clearReply() + } + + const senderProfile = convo.relatedProfiles.get(replyTo.sender.did) + const displayName = senderProfile + ? 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 ( + + + {displayName && ( + + {displayName} + + )} + + {text} + + + + + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index f0b1389b86..be8ebcb8a9 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -35,6 +35,7 @@ import { } from '@atproto/api' import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {mergeRefs} from '#/lib/merge-refs' import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' @@ -62,6 +63,7 @@ import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {DateDivider} from '#/components/dms/DateDivider' import {MessageItem} from '#/components/dms/MessageItem' import {MessageOverlays} from '#/components/dms/MessageOverlays' +import {MessageRepliesProvider} from '#/components/dms/MessageReplies' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup' import {SystemMessageItem} from '#/components/dms/SystemMessageItem' @@ -72,7 +74,12 @@ import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' import {ChatStatusInfo} from './ChatStatusInfo' import {groupSystemMessages, type RenderItem} from './groupSystemMessages' import {InviteLinkDialogProvider} from './InviteLinkDialogProvider' -import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' +import { + type MessageEmbedState, + MessageInputEmbed, + useMessageEmbed, +} from './MessageInputEmbed' +import {MessageInputReply} from './MessageInputReply' import {MessagesListGroupInfoPanel} from './MessagesListGroupInfoPanel' import {MessagesListInfoPanel} from './MessagesListInfoPanel' import {KeyboardStickyView} from './vendor/KeyboardStickyView' @@ -371,7 +378,7 @@ export function MessagesList({ // -- Message sending const onSendMessage = useCallback( - async (text: string) => { + async (text: string, reply?: $Typed) => { let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true}) // detect facets without resolution first - this is used to see if there's @@ -387,6 +394,7 @@ export function MessagesList({ | $Typed | $Typed | undefined + let replyTo: ChatBskyConvoDefs.ReplyRef | undefined // Find the embedded link facet and, if it's at the start or end of the // message, remove it from the text (the embed card replaces it). @@ -455,6 +463,10 @@ export function MessagesList({ stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code) } + if (reply) { + replyTo = {messageId: reply.id} + } + await rt.detectFacets(agent) rt = shortenLinks(rt) @@ -469,10 +481,18 @@ export function MessagesList({ text: rt.text, facets: rt.facets, embed, + replyTo, }, embedView, + reply, ) + if (replyTo) { + ax.metric('chat:message:reply:send', { + convoId: convoState.convo.view.id, + isGroup: convoState.convo.kind === 'group', + }) + } if (convoState.convo.kind === 'group') { ax.metric('groupchat:message:send', { convoId: convoState.convo.view.id, @@ -510,6 +530,29 @@ export function MessagesList({ }) }, [flatListRef]) + // Scroll to a message by id, if it's currently loaded in the list. Per the + // feature scope, we don't fetch history to find unloaded messages - tapping a + // reply to an out-of-window message is a no-op. Returns whether the message + // was found, so the caller knows whether to flash it. + const scrollToMessage = useNonReactiveCallback((messageId: string) => { + const index = renderItems.findIndex( + item => + (item.type === 'message' || + item.type === 'pending-message' || + item.type === 'deleted-message') && + item.message.id === messageId, + ) + if (index === -1) return false + + ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id}) + flatListRef.current?.scrollToIndex({ + index, + viewPosition: 0.3, + animated: true, + }) + return true + }) + const renderItem = ({item, index}: {item: RenderItem; index: number}) => { if (item.type === 'message' || item.type === 'pending-message') { return ( @@ -561,138 +604,180 @@ export function MessagesList({ return ( - - - {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} - - - - - {convoState.hasAllHistory ? ( - convoState.convo?.kind === 'group' ? ( - - ) : ( - - ) - ) : null} - - } - // native only (prop is not supported on web) - renderScrollComponent={renderScrollComponent} - contentContainerStyle={{ - paddingBottom: platform({ - // ios is slightly larger as the input has no top padding - ios: tokens.space.lg, - android: tokens.space.md, - web: 0, // web uses ListFooterComponent instead for scroll reasons - }), - }} - ListFooterComponent={ - - } - style={[ - web({ - scrollbarWidth: 'thin', - scrollbarColor: `${t.palette.contrast_100} transparent`, - scrollbarGutter: 'stable', - }), - ]} - pointerEvents={!hasScrolled ? 'none' : 'auto'} - contentInset={{top: transparentHeaderHeight}} - scrollIndicatorInsets={{top: transparentHeaderHeight}} - /> - - - - {footer ?? ( - - - {({loading}) => - ax.features.enabled( - ax.features.DmsNewMessageComposerEnable, - ) ? ( - - void onSendMessage(message) - } - hasEmbed={!!messageEmbed} - setEmbed={setEmbed} - loading={loading}> - - - ) : ( - + + + {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} + + + + + {convoState.hasAllHistory ? ( + convoState.convo?.kind === 'group' ? ( + + ) : ( + + ) + ) : null} + + } + // native only (prop is not supported on web) + renderScrollComponent={renderScrollComponent} + contentContainerStyle={{ + paddingBottom: platform({ + // ios is slightly larger as the input has no top padding + ios: tokens.space.lg, + android: tokens.space.md, + web: 0, // web uses ListFooterComponent instead for scroll reasons + }), + }} + ListFooterComponent={ + + } + style={[ + web({ + scrollbarWidth: 'thin', + scrollbarColor: `${t.palette.contrast_100} transparent`, + scrollbarGutter: 'stable', + }), + ]} + pointerEvents={!hasScrolled ? 'none' : 'auto'} + contentInset={{top: transparentHeaderHeight}} + scrollIndicatorInsets={{top: transparentHeaderHeight}} + /> + + + + {footer ?? ( + + + {({loading}) => ( + - - - ) - } - - - )} - - + loading={loading} + useNewComposer={ax.features.enabled( + ax.features.DmsNewMessageComposerEnable, + )} + /> + )} + + + )} + + - {newMessagesPill.show && ( - - )} - + {newMessagesPill.show && ( + + )} + + ) } +/** + * Picks the new vs legacy composer and mounts the reply preview alongside the + * existing embed preview in the composer's children slot. The staged reply + * itself is read and cleared inside the composer via `useMessageReplies`. + */ +function Composer({ + textInputId, + onSendMessage, + messageEmbed, + setEmbed, + loading, + useNewComposer, +}: { + textInputId: string + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => Promise + messageEmbed: MessageEmbedState | undefined + setEmbed: (embedUrl: string | undefined) => void + loading?: boolean + useNewComposer: boolean +}) { + const handleSendMessage = useNonReactiveCallback( + (message: string, replyTo?: $Typed) => { + void onSendMessage(message, replyTo) + }, + ) + + const previews = ( + <> + + + + ) + + return useNewComposer ? ( + + {previews} + + ) : ( + + {previews} + + ) +} + /** Note: native only */ function ChatScrollComponent({ ref, diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 3603026d78..b1dc348e79 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -86,6 +86,22 @@ function toSystemMessageView( return ev.message } +/** + * Derive a deleted-message tombstone from a (now-deleted) message, preserving + * the fields the deleted view carries so a reply can render it as deleted. + */ +function toDeletedMessageView( + m: ChatBskyConvoDefs.MessageView, +): $Typed { + return { + $type: 'chat.bsky.convo.defs#deletedMessageView', + id: m.id, + rev: m.rev, + sender: m.sender, + sentAt: m.sentAt, + } +} + export class Convo { private id: string @@ -119,6 +135,7 @@ export class Convo { optimisticEmbedView?: | $Typed | $Typed + optimisticReplyTo?: $Typed } > = new Map() private deletedMessages: Set = new Set() @@ -802,6 +819,12 @@ export class Convo { }) const {cursor, messages, relatedProfiles} = response.data + // Trust the cursor for pagination. We can't infer "no more pages" from a + // short page: the server pages by raw rows but strips deleted messages + // from the response, so a full page containing a deleted message (e.g. + // from a deleted account) comes back short *with* a valid cursor. Using a + // count heuristic here would stop history fetching early and hide + // messages. The tradeoff is one extra empty fetch at the true top. this.oldestRev = cursor ?? null if (relatedProfiles) { @@ -811,14 +834,6 @@ export class Convo { this.applyProfileShadows() } - /* - * If the response contained fewer messages than the limit, we know - * there are no more pages, regardless of whether a cursor was returned. - */ - if (messages.length < (IS_NATIVE ? 30 : 60)) { - this.oldestRev = null - } - for (const message of messages) { if ( ChatBskyConvoDefs.isMessageView(message) || @@ -965,17 +980,17 @@ export class Convo { ChatBskyConvoDefs.isDeletedMessageView(ev.message) ) { /* - * Update if we have this in state. If we don't, don't worry about it. + * Remove the message itself, and keep its id in `deletedMessages` + * so any message that quotes it keeps rendering a deleted-message + * tombstone (see `tombstoneDeletedReplyTo`) rather than reverting + * to the original hydrated text. We add here rather than relying on + * the optimistic entry so deletes from elsewhere (e.g. another + * device) are covered too. */ - if ( - this.pastMessages.has(ev.message.id) || - this.newMessages.has(ev.message.id) - ) { - this.pastMessages.delete(ev.message.id) - this.newMessages.delete(ev.message.id) - this.deletedMessages.delete(ev.message.id) - needsCommit = true - } + this.pastMessages.delete(ev.message.id) + this.newMessages.delete(ev.message.id) + this.deletedMessages.add(ev.message.id) + needsCommit = true } else if ( (ChatBskyConvoDefs.isLogAddReaction(ev) || ChatBskyConvoDefs.isLogRemoveReaction(ev)) && @@ -1020,6 +1035,7 @@ export class Convo { optimisticEmbedView?: | $Typed | $Typed, + optimisticReplyTo?: $Typed, ) { // Ignore empty messages for now since they have no other purpose atm if (!message.text.trim() && !message.embed) return @@ -1033,6 +1049,7 @@ export class Convo { id: tempId, message, optimisticEmbedView, + optimisticReplyTo, }) if (this.convo?.view.status === 'request') { this.updateConvo({ @@ -1316,6 +1333,26 @@ export class Convo { } } + /** + * When a message is deleted locally, it's removed from the list, but other + * messages that reply to it still carry a hydrated `replyTo` with the + * original text until the server re-sends them. Swap that `replyTo` for a + * deleted-message tombstone so the quote reflects the deletion immediately, + * matching what the server returns on refresh. + */ + private tombstoneDeletedReplyTo( + m: ChatBskyConvoDefs.MessageView, + ): ChatBskyConvoDefs.MessageView { + const {replyTo} = m + if ( + !ChatBskyConvoDefs.isMessageView(replyTo) || + !this.deletedMessages.has(replyTo.id) + ) { + return m + } + return {...m, replyTo: toDeletedMessageView(replyTo)} + } + /* * Items in reverse order, since FlatList inverts */ @@ -1327,7 +1364,7 @@ export class Convo { items.unshift({ type: 'message', key: m.id, - message: m, + message: this.tombstoneDeletedReplyTo(m), }) } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { items.unshift({ @@ -1360,7 +1397,7 @@ export class Convo { items.push({ type: 'message', key: m.id, - message: m, + message: this.tombstoneDeletedReplyTo(m), }) } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { items.push({ @@ -1378,12 +1415,17 @@ export class Convo { }) this.pendingMessages.forEach(m => { + const optimisticReplyTo = + m.optimisticReplyTo && this.deletedMessages.has(m.optimisticReplyTo.id) + ? toDeletedMessageView(m.optimisticReplyTo) + : m.optimisticReplyTo items.push({ type: 'pending-message', key: m.id, message: { ...m.message, embed: m.optimisticEmbedView, + replyTo: optimisticReplyTo, $type: 'chat.bsky.convo.defs#messageView', id: nanoid(), rev: '__fake__', diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index c83fbfb813..e330d6eea3 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -113,6 +113,7 @@ type SendMessage = ( | $Typed | $Typed | undefined, + optimisticReplyTo?: $Typed, ) => void type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 787ca9e278..441297f120 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -30,6 +30,15 @@ export type ListMethods = { scrollToTop: () => void scrollToOffset: (options: {animated: boolean; offset: number}) => void scrollToEnd: (options?: {animated?: boolean}) => void + // Signature kept compatible with FlatList's scrollToIndex (the native + // ListMethods type) so callers stay platform-agnostic. viewOffset is + // accepted for parity but not currently used by the web implementation. + scrollToIndex: (params: { + animated?: boolean | null + index: number + viewOffset?: number + viewPosition?: number + }) => void } export type ListProps = Omit< FlatListProps, @@ -216,6 +225,22 @@ function ListImpl( }, [disableFullWindowScroll]) const nativeRef = useRef(null) + + // Registry of item index -> row DOM node. The list renders header/footer and + // visibility-detector siblings too, so we can't index into the container's + // children directly; each Row registers its own node here keyed by index. + const rowNodesRef = useRef>(new Map()) + const registerRowNode = useCallback( + (index: number, node: HTMLElement | null) => { + if (node) { + rowNodesRef.current.set(index, node) + } else { + rowNodesRef.current.delete(index) + } + }, + [], + ) + useImperativeHandle( ref, () => ({ @@ -239,6 +264,17 @@ function ListImpl( behavior: animated ? 'smooth' : 'instant', }) }, + + scrollToIndex({animated = true, index}) { + const node = rowNodesRef.current.get(index) + // scrollIntoView with block: 'center' roughly matches the caller's + // viewPosition of 0.3 - not exact, but close enough and it respects + // whichever element is the scroll container (window or nativeRef). + node?.scrollIntoView({ + block: 'center', + behavior: animated ? 'smooth' : 'instant', + }) + }, }), [getScrollableNode], ) @@ -392,6 +428,7 @@ function ListImpl( renderItem={renderItem} extraData={extraData} onItemSeen={onItemSeen} + registerRowNode={registerRowNode} /> ) })} @@ -470,6 +507,7 @@ let Row = function RowImpl({ renderItem, extraData: _unused, onItemSeen, + registerRowNode, }: { item: ItemT index: number @@ -479,6 +517,7 @@ let Row = function RowImpl({ | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown onItemSeen: ((item: ItemT) => void) | undefined + registerRowNode: (index: number, node: HTMLElement | null) => void }): React.ReactNode { const rowRef = useRef(null) const intersectionTimeout = useRef | undefined>( @@ -529,6 +568,15 @@ let Row = function RowImpl({ } }, [handleIntersection, onItemSeen]) + // Register this row's DOM node so the list can scroll to it by index. + useEffect(() => { + const node: HTMLElement | null = rowRef.current + registerRowNode(index, node) + return () => { + registerRowNode(index, null) + } + }, [index, registerRowNode]) + if (!renderItem) { return null } @@ -552,6 +600,7 @@ Row = memo(Row) as (props: { | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown onItemSeen: ((item: ItemT) => void) | undefined + registerRowNode: (index: number, node: HTMLElement | null) => void }) => React.ReactNode let Visibility = ({