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