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 {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()
+47 -1
View File
@@ -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<number>({
android: -a.mx_sm.marginLeft,
ios: -a.mx_md.marginLeft,
web: -a.mx_lg.marginLeft,
})
return (
<>
{hasLargeGapFromPrev && <DateDivider date={message.sentAt} />}
@@ -411,6 +443,20 @@ let MessageItem = ({
isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP},
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]}>
{showAvatar ? (
<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,
) => 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)
@@ -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<ChatBskyConvoDefs.MessageView | null>(null)
const [reactionsTarget, setReactionsTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const [replyTo, setReplyTo] = useState<ChatBskyConvoDefs.MessageView | null>(
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<MessageDialogsContextType>(
() => ({
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
+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'
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)
@@ -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
+119 -113
View File
@@ -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 (
<InviteLinkDialogProvider convo={convoState.convo}>
<MessageOverlays scrollToMessage={scrollToMessage}>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
// slightly too buggy unfortunately, enable when possible
// textInputNativeID={textInputId}
style={[a.flex_1]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<Animated.View style={[a.flex_1, animatedListStyle]}>
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={renderItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
disableVirtualization={true}
// The extra two items account for the header and the footer components
initialNumToRender={IS_NATIVE ? 32 : 62}
maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{minIndexForVisible: 0}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
showsVerticalScrollIndicator={!IS_ANDROID}
scrollEventThrottle={100}
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.hasAllHistory ? (
convoState.convo?.kind === 'group' ? (
<MessagesListGroupInfoPanel convo={convoState.convo} />
) : (
<MessagesListInfoPanel convo={convoState.convo} />
)
) : 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={
<View
style={web({height: tokens.space.md + inputHeightJS})}
/>
}
style={[
web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable',
}),
]}
pointerEvents={!hasScrolled ? 'none' : 'auto'}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
</Animated.View>
<KeyboardStickyView
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
onLayout={onInputLayout}
minimumOffset={bottomInset}
offset={{
closed: platform({
ios: tokens.space.lg, // hide bottom padding when closed
default: 0,
}),
opened: 0,
}}>
{footer ?? (
<Animated.View entering={FadeIn.duration(200)}>
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
{({loading}) => (
<Composer
textInputId={textInputId}
onSendMessage={onSendMessage}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}
useNewComposer={ax.features.enabled(
ax.features.DmsNewMessageComposerEnable,
)}
<MessageRepliesProvider scrollToMessage={scrollToMessage}>
<MessageOverlays>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
// slightly too buggy unfortunately, enable when possible
// textInputNativeID={textInputId}
style={[a.flex_1]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<Animated.View style={[a.flex_1, animatedListStyle]}>
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={renderItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
disableVirtualization={true}
// The extra two items account for the header and the footer components
initialNumToRender={IS_NATIVE ? 32 : 62}
maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{minIndexForVisible: 0}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
showsVerticalScrollIndicator={!IS_ANDROID}
scrollEventThrottle={100}
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.hasAllHistory ? (
convoState.convo?.kind === 'group' ? (
<MessagesListGroupInfoPanel
convo={convoState.convo}
/>
) : (
<MessagesListInfoPanel convo={convoState.convo} />
)
) : 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={
<View
style={web({height: tokens.space.md + inputHeightJS})}
/>
)}
</ConversationFooter>
</Animated.View>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
}
style={[
web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable',
}),
]}
pointerEvents={!hasScrolled ? 'none' : 'auto'}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
</Animated.View>
<KeyboardStickyView
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
onLayout={onInputLayout}
minimumOffset={bottomInset}
offset={{
closed: platform({
ios: tokens.space.lg, // hide bottom padding when closed
default: 0,
}),
opened: 0,
}}>
{footer ?? (
<Animated.View entering={FadeIn.duration(200)}>
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
{({loading}) => (
<Composer
textInputId={textInputId}
onSendMessage={onSendMessage}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}
useNewComposer={ax.features.enabled(
ax.features.DmsNewMessageComposerEnable,
)}
/>
)}
</ConversationFooter>
</Animated.View>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
{newMessagesPill.show && (
<NewMessagesPill onPress={scrollToEndOnPress} />
)}
</MessageOverlays>
{newMessagesPill.show && (
<NewMessagesPill onPress={scrollToEndOnPress} />
)}
</MessageOverlays>
</MessageRepliesProvider>
</InviteLinkDialogProvider>
)
}
/**
* 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,