From 4e9c61cc6cdb5e645c9c376d1c03d53427769a65 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 16 Jun 2026 17:02:40 +0300 Subject: [PATCH] flash on scroll --- src/components/dms/MessageContextMenu.tsx | 4 +- src/components/dms/MessageItem.tsx | 48 +++- src/components/dms/MessageOverlays.tsx | 49 +--- src/components/dms/MessageReplies.tsx | 124 ++++++++++ .../Messages/components/MessageComposer.tsx | 4 +- .../Messages/components/MessageInputReply.tsx | 4 +- .../Messages/components/MessagesList.tsx | 232 +++++++++--------- 7 files changed, 300 insertions(+), 165 deletions(-) create mode 100644 src/components/dms/MessageReplies.tsx diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index 258a9e86fe..f82c56ed1d 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -20,6 +20,7 @@ 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' @@ -47,7 +48,8 @@ export let MessageContextMenu = ({ const ax = useAnalytics() const {currentAccount} = useSession() const convo = useConvoActive() - const {openDeleteMessage, openReportMessage, openReply} = useMessageDialogs() + const {openDeleteMessage, openReportMessage} = useMessageDialogs() + const {openReply} = useMessageReplies() const langPrefs = useLanguagePrefs() const translate = useGoogleTranslate() diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 2f4cc050e4..a0ab1cb560 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, @@ -46,6 +48,7 @@ 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' @@ -148,7 +151,8 @@ let MessageItem = ({ const {message} = item const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did)) - const {openReactions, scrollToMessage} = useMessageDialogs() + 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 @@ -256,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 ? { @@ -402,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 && } @@ -411,6 +443,20 @@ let MessageItem = ({ isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP}, hasReactions && {paddingBottom: 26}, ]}> + {showAvatar ? ( {avatar} diff --git a/src/components/dms/MessageOverlays.tsx b/src/components/dms/MessageOverlays.tsx index 43f8983d24..2384aad88d 100644 --- a/src/components/dms/MessageOverlays.tsx +++ b/src/components/dms/MessageOverlays.tsx @@ -29,16 +29,6 @@ type MessageDialogsContextType = { senderProfile: bsky.profile.AnyProfileView | undefined, ) => void openReactions: (message: ChatBskyConvoDefs.MessageView) => void - /** - * The message currently staged for reply in the composer, or null. - */ - replyTo: ChatBskyConvoDefs.MessageView | null - openReply: (message: ChatBskyConvoDefs.MessageView) => void - clearReply: () => void - /** - * Scroll the list to a message, if it's currently loaded. No-op otherwise. - */ - scrollToMessage: (messageId: string) => void } const Context = createContext(null) @@ -51,13 +41,7 @@ export function useMessageDialogs() { return ctx } -export function MessageOverlays({ - children, - scrollToMessage, -}: { - children: React.ReactNode - scrollToMessage: (messageId: string) => void -}) { +export function MessageOverlays({children}: {children: React.ReactNode}) { const {t: l} = useLingui() const queryClient = useQueryClient() const convo = useConvoActive() @@ -77,9 +61,6 @@ export function MessageOverlays({ useState(null) const [reactionsTarget, setReactionsTarget] = useState(null) - const [replyTo, setReplyTo] = useState( - null, - ) const openDeleteMessage = useCallback( (message: ChatBskyConvoDefs.MessageView) => { @@ -107,14 +88,6 @@ export function MessageOverlays({ [], ) - const openReply = useCallback((message: ChatBskyConvoDefs.MessageView) => { - setReplyTo(message) - }, []) - - const clearReply = useCallback(() => { - setReplyTo(null) - }, []) - // These dialogs are conditionally mounted, so we can't open them in the same // tick that we set their targets - the control refs aren't attached yet. Open // in an effect after the dialog has mounted. @@ -148,24 +121,8 @@ export function MessageOverlays({ }, [queryClient, reportTarget]) const ctx = useMemo( - () => ({ - openDeleteMessage, - openReportMessage, - openReactions, - replyTo, - openReply, - clearReply, - scrollToMessage, - }), - [ - openDeleteMessage, - openReportMessage, - openReactions, - replyTo, - openReply, - clearReply, - scrollToMessage, - ], + () => ({openDeleteMessage, openReportMessage, openReactions}), + [openDeleteMessage, openReportMessage, openReactions], ) // `reactionsTarget` is a snapshot from when the dialog was opened. Read the 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/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index e9170d6a80..768e007455 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -29,7 +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 {useMessageDialogs} from '#/components/dms/MessageOverlays' +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' @@ -65,7 +65,7 @@ export function MessageComposer({ const editable = !needsEmailVerification && !loading const {getDraft, clearDraft} = useMessageDraft() const composerInternalApiRef = useComposerInternalApiRef() - const {replyTo, clearReply} = useMessageDialogs() + const {replyTo, clearReply} = useMessageReplies() const [text, setText] = useState(getDraft) useSaveMessageDraft(text) diff --git a/src/screens/Messages/components/MessageInputReply.tsx b/src/screens/Messages/components/MessageInputReply.tsx index 5d3357b7d6..5853e46d81 100644 --- a/src/screens/Messages/components/MessageInputReply.tsx +++ b/src/screens/Messages/components/MessageInputReply.tsx @@ -7,7 +7,7 @@ import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-disp import {useConvoActive} from '#/state/messages/convo' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' -import {useMessageDialogs} from '#/components/dms/MessageOverlays' +import {useMessageReplies} from '#/components/dms/MessageReplies' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Text} from '#/components/Typography' @@ -19,7 +19,7 @@ export function MessageInputReply() { const t = useTheme() const {t: l} = useLingui() const convo = useConvoActive() - const {replyTo, clearReply} = useMessageDialogs() + const {replyTo, clearReply} = useMessageReplies() if (!replyTo) { return null diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index ace4e409d8..be8ebcb8a9 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -63,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' @@ -531,7 +532,8 @@ export function MessagesList({ // 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. + // 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 => @@ -540,7 +542,7 @@ export function MessagesList({ item.type === 'deleted-message') && item.message.id === messageId, ) - if (index === -1) return + if (index === -1) return false ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id}) flatListRef.current?.scrollToIndex({ @@ -548,6 +550,7 @@ export function MessagesList({ viewPosition: 0.3, animated: true, }) + return true }) const renderItem = ({item, index}: {item: RenderItem; index: number}) => { @@ -601,124 +604,127 @@ 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}) => ( - + + + {/* 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}) => ( + + )} + + + )} + + - {newMessagesPill.show && ( - - )} - + {newMessagesPill.show && ( + + )} + + ) } /** - * Bridges the composer to reply state. It's rendered inside `MessageOverlays` - * so it can read the staged reply target via `useMessageDialogs`, inject it - * into the send call, then clear it. The reply preview is mounted alongside the - * existing embed preview in the composer's children slot. + * 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,