flash on scroll

This commit is contained in:
Samuel Newman
2026-06-16 17:02:40 +03:00
parent a199ae691a
commit 4e9c61cc6c
7 changed files with 300 additions and 165 deletions
+3 -1
View File
@@ -20,6 +20,7 @@ import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu' import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types' import {type TriggerProps} from '#/components/ContextMenu/types'
import {useMessageDialogs} from '#/components/dms/MessageOverlays' import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies'
import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight' import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
@@ -47,7 +48,8 @@ export let MessageContextMenu = ({
const ax = useAnalytics() const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const convo = useConvoActive() const convo = useConvoActive()
const {openDeleteMessage, openReportMessage, openReply} = useMessageDialogs() const {openDeleteMessage, openReportMessage} = useMessageDialogs()
const {openReply} = useMessageReplies()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate() const translate = useGoogleTranslate()
+47 -1
View File
@@ -14,6 +14,8 @@ import Animated, {
LinearTransition, LinearTransition,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withDelay,
withSequence,
withTiming, withTiming,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
@@ -46,6 +48,7 @@ import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays' import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight' import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
@@ -148,7 +151,8 @@ let MessageItem = ({
const {message} = item const {message} = item
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did)) 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- // `replyTo` comes back hydrated as the referenced message (or a deleted-
// message tombstone). Narrow away the open-union fallback so we only render // message tombstone). Narrow away the open-union fallback so we only render
@@ -256,6 +260,26 @@ let MessageItem = ({
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300})) topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
}, [targetTopRadius, topRadiusSV]) }, [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(() => const borderRadiusStyle = useAnimatedStyle(() =>
isFromSelf isFromSelf
? { ? {
@@ -402,6 +426,14 @@ let MessageItem = ({
web: a.mx_lg, web: a.mx_lg,
}) })
// Negative of `messageInset` so the flash bleeds past the row's horizontal
// margin to the screen edges.
const flashBleed = platform<number>({
android: -a.mx_sm.marginLeft,
ios: -a.mx_md.marginLeft,
web: -a.mx_lg.marginLeft,
})
return ( return (
<> <>
{hasLargeGapFromPrev && <DateDivider date={message.sentAt} />} {hasLargeGapFromPrev && <DateDivider date={message.sentAt} />}
@@ -411,6 +443,20 @@ let MessageItem = ({
isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP}, isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP},
hasReactions && {paddingBottom: 26}, hasReactions && {paddingBottom: 26},
]}> ]}>
<Animated.View
pointerEvents="none"
style={[
a.absolute,
{
top: -CLUSTERED_MESSAGE_GAP,
bottom: -CLUSTERED_MESSAGE_GAP,
left: flashBleed,
right: flashBleed,
backgroundColor: utils.alpha(t.palette.primary_500, 0.1),
},
highlightStyle,
]}
/>
<View style={[a.relative]}> <View style={[a.relative]}>
{showAvatar ? ( {showAvatar ? (
<View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View> <View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View>
+3 -46
View File
@@ -29,16 +29,6 @@ type MessageDialogsContextType = {
senderProfile: bsky.profile.AnyProfileView | undefined, senderProfile: bsky.profile.AnyProfileView | undefined,
) => void ) => void
openReactions: (message: ChatBskyConvoDefs.MessageView) => 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<MessageDialogsContextType | null>(null) const Context = createContext<MessageDialogsContextType | null>(null)
@@ -51,13 +41,7 @@ export function useMessageDialogs() {
return ctx return ctx
} }
export function MessageOverlays({ export function MessageOverlays({children}: {children: React.ReactNode}) {
children,
scrollToMessage,
}: {
children: React.ReactNode
scrollToMessage: (messageId: string) => void
}) {
const {t: l} = useLingui() const {t: l} = useLingui()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const convo = useConvoActive() const convo = useConvoActive()
@@ -77,9 +61,6 @@ export function MessageOverlays({
useState<ChatBskyConvoDefs.MessageView | null>(null) useState<ChatBskyConvoDefs.MessageView | null>(null)
const [reactionsTarget, setReactionsTarget] = const [reactionsTarget, setReactionsTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null) useState<ChatBskyConvoDefs.MessageView | null>(null)
const [replyTo, setReplyTo] = useState<ChatBskyConvoDefs.MessageView | null>(
null,
)
const openDeleteMessage = useCallback( const openDeleteMessage = useCallback(
(message: ChatBskyConvoDefs.MessageView) => { (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 // 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 // tick that we set their targets - the control refs aren't attached yet. Open
// in an effect after the dialog has mounted. // in an effect after the dialog has mounted.
@@ -148,24 +121,8 @@ export function MessageOverlays({
}, [queryClient, reportTarget]) }, [queryClient, reportTarget])
const ctx = useMemo<MessageDialogsContextType>( const ctx = useMemo<MessageDialogsContextType>(
() => ({ () => ({openDeleteMessage, openReportMessage, openReactions}),
openDeleteMessage, [openDeleteMessage, openReportMessage, openReactions],
openReportMessage,
openReactions,
replyTo,
openReply,
clearReply,
scrollToMessage,
}),
[
openDeleteMessage,
openReportMessage,
openReactions,
replyTo,
openReply,
clearReply,
scrollToMessage,
],
) )
// `reactionsTarget` is a snapshot from when the dialog was opened. Read the // `reactionsTarget` is a snapshot from when the dialog was opened. Read the
+124
View File
@@ -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<MessageRepliesContextType | null>(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<ChatBskyConvoDefs.MessageView | null>(
null,
)
const [highlightedMessage, setHighlightedMessage] =
useState<HighlightedMessage | null>(null)
const highlightKey = useRef(0)
const clearHighlightTimeout = useRef<ReturnType<typeof setTimeout> | 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<MessageRepliesContextType>(
() => ({
replyTo,
setReply,
clearReply,
scrollToMessage,
highlightedMessage,
}),
[replyTo, setReply, clearReply, scrollToMessage, highlightedMessage],
)
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
@@ -29,7 +29,7 @@ import {
} from '#/state/messages/message-drafts' } from '#/state/messages/message-drafts'
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf' import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
import {Composer, useComposerInternalApiRef} from '#/components/Composer' 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 * as EmojiPicker from '#/components/EmojiPicker'
import {GlassView} from '#/components/GlassView' import {GlassView} from '#/components/GlassView'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
@@ -65,7 +65,7 @@ export function MessageComposer({
const editable = !needsEmailVerification && !loading const editable = !needsEmailVerification && !loading
const {getDraft, clearDraft} = useMessageDraft() const {getDraft, clearDraft} = useMessageDraft()
const composerInternalApiRef = useComposerInternalApiRef() const composerInternalApiRef = useComposerInternalApiRef()
const {replyTo, clearReply} = useMessageDialogs() const {replyTo, clearReply} = useMessageReplies()
const [text, setText] = useState(getDraft) const [text, setText] = useState(getDraft)
useSaveMessageDraft(text) useSaveMessageDraft(text)
@@ -7,7 +7,7 @@ import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-disp
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' 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 {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -19,7 +19,7 @@ export function MessageInputReply() {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const convo = useConvoActive() const convo = useConvoActive()
const {replyTo, clearReply} = useMessageDialogs() const {replyTo, clearReply} = useMessageReplies()
if (!replyTo) { if (!replyTo) {
return null return null
@@ -63,6 +63,7 @@ import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {DateDivider} from '#/components/dms/DateDivider' import {DateDivider} from '#/components/dms/DateDivider'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
import {MessageOverlays} from '#/components/dms/MessageOverlays' import {MessageOverlays} from '#/components/dms/MessageOverlays'
import {MessageRepliesProvider} from '#/components/dms/MessageReplies'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup' import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem' 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 // 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 // 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 scrollToMessage = useNonReactiveCallback((messageId: string) => {
const index = renderItems.findIndex( const index = renderItems.findIndex(
item => item =>
@@ -540,7 +542,7 @@ export function MessagesList({
item.type === 'deleted-message') && item.type === 'deleted-message') &&
item.message.id === messageId, item.message.id === messageId,
) )
if (index === -1) return if (index === -1) return false
ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id}) ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id})
flatListRef.current?.scrollToIndex({ flatListRef.current?.scrollToIndex({
@@ -548,6 +550,7 @@ export function MessagesList({
viewPosition: 0.3, viewPosition: 0.3,
animated: true, animated: true,
}) })
return true
}) })
const renderItem = ({item, index}: {item: RenderItem; index: number}) => { const renderItem = ({item, index}: {item: RenderItem; index: number}) => {
@@ -601,7 +604,8 @@ export function MessagesList({
return ( return (
<InviteLinkDialogProvider convo={convoState.convo}> <InviteLinkDialogProvider convo={convoState.convo}>
<MessageOverlays scrollToMessage={scrollToMessage}> <MessageRepliesProvider scrollToMessage={scrollToMessage}>
<MessageOverlays>
<KeyboardGestureArea <KeyboardGestureArea
interpolator="ios" interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419 // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
@@ -637,7 +641,9 @@ export function MessagesList({
<MaybeLoader isLoading={convoState.isFetchingHistory} /> <MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.hasAllHistory ? ( {convoState.hasAllHistory ? (
convoState.convo?.kind === 'group' ? ( convoState.convo?.kind === 'group' ? (
<MessagesListGroupInfoPanel convo={convoState.convo} /> <MessagesListGroupInfoPanel
convo={convoState.convo}
/>
) : ( ) : (
<MessagesListInfoPanel convo={convoState.convo} /> <MessagesListInfoPanel convo={convoState.convo} />
) )
@@ -710,15 +716,15 @@ export function MessagesList({
<NewMessagesPill onPress={scrollToEndOnPress} /> <NewMessagesPill onPress={scrollToEndOnPress} />
)} )}
</MessageOverlays> </MessageOverlays>
</MessageRepliesProvider>
</InviteLinkDialogProvider> </InviteLinkDialogProvider>
) )
} }
/** /**
* Bridges the composer to reply state. It's rendered inside `MessageOverlays` * Picks the new vs legacy composer and mounts the reply preview alongside the
* so it can read the staged reply target via `useMessageDialogs`, inject it * existing embed preview in the composer's children slot. The staged reply
* into the send call, then clear it. The reply preview is mounted alongside the * itself is read and cleared inside the composer via `useMessageReplies`.
* existing embed preview in the composer's children slot.
*/ */
function Composer({ function Composer({
textInputId, textInputId,