Message replies in chat (#10903)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<Comp
|
||||
size="lg"
|
||||
size="md"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: destructive
|
||||
? t.palette.negative_500
|
||||
: t.atoms.text_contrast_medium.color
|
||||
: t.atoms.text.color
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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]}>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownReplyBtn"
|
||||
label={l`Reply`}
|
||||
onPress={() => setReply(message)}>
|
||||
<ContextMenu.ItemIcon icon={ReplyIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
@@ -172,7 +182,6 @@ export let MessageContextMenu = ({
|
||||
</>
|
||||
)}
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={l`Delete message for me`}
|
||||
onPress={() => openDeleteMessage(message)}>
|
||||
@@ -181,7 +190,6 @@ export let MessageContextMenu = ({
|
||||
</ContextMenu.Item>
|
||||
{!isFromSelf && (
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownReportBtn"
|
||||
label={l`Report message`}
|
||||
onPress={() => openReportMessage(message, senderProfile)}>
|
||||
|
||||
@@ -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<number>({
|
||||
android: -a.mx_sm.marginLeft,
|
||||
ios: -a.mx_md.marginLeft,
|
||||
web: -a.mx_lg.marginLeft,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasLargeGapFromPrev && <DateDivider date={message.sentAt} />}
|
||||
@@ -381,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: t.palette.primary_100,
|
||||
},
|
||||
highlightStyle,
|
||||
]}
|
||||
/>
|
||||
<View style={[a.relative]}>
|
||||
{showAvatar ? (
|
||||
<View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View>
|
||||
@@ -391,7 +467,16 @@ let MessageItem = ({
|
||||
a.flex_grow,
|
||||
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
|
||||
]}>
|
||||
{displayName && showDisplayName ? (
|
||||
{replyTo ? (
|
||||
<ReplyCaption
|
||||
replyTo={replyTo}
|
||||
isFromSelf={isFromSelf}
|
||||
isGroupChat={isGroupChat}
|
||||
replierDisplayName={displayName}
|
||||
relatedProfiles={relatedProfiles}
|
||||
onPress={() => scrollToMessage(replyTo.id)}
|
||||
/>
|
||||
) : displayName && showDisplayName ? (
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
@@ -458,6 +543,14 @@ let MessageItem = ({
|
||||
borderRadiusStyle,
|
||||
],
|
||||
]}>
|
||||
{replyTo && !isOnlyEmoji(message.text) ? (
|
||||
<ReplyQuote
|
||||
replyTo={replyTo}
|
||||
isFromSelf={isFromSelf}
|
||||
relatedProfiles={relatedProfiles}
|
||||
onPress={() => scrollToMessage(replyTo.id)}
|
||||
/>
|
||||
) : null}
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[
|
||||
@@ -648,3 +741,181 @@ function BlockedPlaceholder({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||
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 (
|
||||
<Button
|
||||
label={l`Scroll to the message this is replying to`}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_2xs,
|
||||
a.pb_2xs,
|
||||
a.pt_xs,
|
||||
isFromSelf
|
||||
? [a.justify_end, a.pr_md]
|
||||
: [
|
||||
a.justify_start,
|
||||
isGroupChat ? {paddingLeft: DISPLAY_NAME_INSET} : a.pl_md,
|
||||
],
|
||||
]}>
|
||||
<ArrowCornerDownRightIcon
|
||||
size="xs"
|
||||
style={t.atoms.text_contrast_medium}
|
||||
/>
|
||||
<Text
|
||||
style={[a.text_xs, a.flex_shrink, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}
|
||||
emoji>
|
||||
{isFromSelf ? (
|
||||
originalSenderIsSelf ? (
|
||||
<Trans>You replied to yourself</Trans>
|
||||
) : originalName ? (
|
||||
<Trans>You replied to {originalName}</Trans>
|
||||
) : (
|
||||
<Trans>You replied</Trans>
|
||||
)
|
||||
) : originalSenderIsSelf ? (
|
||||
<Trans>{replierDisplayName} replied to you</Trans>
|
||||
) : originalName ? (
|
||||
<Trans>
|
||||
{replierDisplayName} replied to {originalName}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>{replierDisplayName} replied</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||
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 (
|
||||
<Button
|
||||
label={
|
||||
senderName
|
||||
? l`Replied-to message from ${senderName}, tap to scroll to it`
|
||||
: l`Replied-to message, tap to scroll to it`
|
||||
}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
a.mb_xs,
|
||||
a.rounded_md,
|
||||
a.p_sm,
|
||||
a.flex_col,
|
||||
a.align_start,
|
||||
a.border,
|
||||
{borderColor, marginHorizontal: -4},
|
||||
]}>
|
||||
{senderName ? (
|
||||
<Text style={[a.text_xs, {color: subtleColor}]} emoji numberOfLines={1}>
|
||||
{senderName}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
{color: subtle ? subtleColor : tintColor},
|
||||
subtle && a.italic,
|
||||
]}
|
||||
emoji
|
||||
numberOfLines={2}>
|
||||
{text}
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user