Message replies in chat (#10903)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-16 20:26:48 +03:00
committed by GitHub
parent f075187e57
commit b8fdce6478
19 changed files with 925 additions and 180 deletions

Before

Width:  |  Height:  |  Size: 334 B

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="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"/></svg>

After

Width:  |  Height:  |  Size: 273 B

+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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
+8
View File
@@ -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': {
+6 -6
View File
@@ -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
}
/>
)
+10 -2
View File
@@ -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)}>
+278 -7
View File
@@ -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>
)
}
+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>
}
@@ -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',
},
)
-1
View File
@@ -262,7 +262,6 @@ function InnerReady({
{IS_LIQUID_GLASS ? (
<ScrollEdgeEffect
edge="top"
effect="soft"
style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]}
onLayout={onHeaderLayout}>
{header}
@@ -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<ChatBskyConvoDefs.MessageView>,
) => 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)
}
@@ -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> | void
onSendMessage: (
message: string,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => Promise<void> | 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(
@@ -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<ChatBskyConvoDefs.MessageView>,
) => 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<HTMLTextAreaElement>) => {
@@ -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 (
<View
style={[
a.flex_1,
a.flex_row,
a.gap_sm,
a.align_start,
t.atoms.border_contrast_high,
a.rounded_md,
a.border,
a.p_sm,
a.mt_sm,
a.mx_sm,
a.gap_2xs,
]}>
<View style={[a.flex_1]}>
{displayName && (
<Text
style={[a.text_xs, t.atoms.text_contrast_high]}
emoji
numberOfLines={1}>
{displayName}
</Text>
)}
<Text
style={[a.text_sm, subtle && [a.italic, t.atoms.text_contrast_high]]}
emoji
numberOfLines={2}>
{text}
</Text>
</View>
<Button
label={l`Cancel reply`}
onPress={onRemove}
style={[a.px_2xs]}
hitSlop={HITSLOP_20}>
<XIcon size="xs" style={t.atoms.text_contrast_high} />
</Button>
</View>
)
}
+211 -126
View File
@@ -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<ChatBskyConvoDefs.MessageView>) => {
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<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
| 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 (
<InviteLinkDialogProvider convo={convoState.convo}>
<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})}
/>
}
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}) =>
ax.features.enabled(
ax.features.DmsNewMessageComposerEnable,
) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!messageEmbed}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embed={messageEmbed}
setEmbed={setEmbed}
/>
</MessageComposer>
) : (
<MessageInput
<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})}
/>
}
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}
hasEmbed={!!messageEmbed}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embed={messageEmbed}
setEmbed={setEmbed}
/>
</MessageInput>
)
}
</ConversationFooter>
</Animated.View>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
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>
)
}
/**
* 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<ChatBskyConvoDefs.MessageView>,
) => Promise<void>
messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void
loading?: boolean
useNewComposer: boolean
}) {
const handleSendMessage = useNonReactiveCallback(
(message: string, replyTo?: $Typed<ChatBskyConvoDefs.MessageView>) => {
void onSendMessage(message, replyTo)
},
)
const previews = (
<>
<MessageInputReply />
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
</>
)
return useNewComposer ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={handleSendMessage}
hasEmbed={!!messageEmbed}
setEmbed={setEmbed}
loading={loading}>
{previews}
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={handleSendMessage}
hasEmbed={!!messageEmbed}
setEmbed={setEmbed}
loading={loading}>
{previews}
</MessageInput>
)
}
/** Note: native only */
function ChatScrollComponent({
ref,
+62 -20
View File
@@ -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<ChatBskyConvoDefs.DeletedMessageView> {
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<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
optimisticReplyTo?: $Typed<ChatBskyConvoDefs.MessageView>
}
> = new Map()
private deletedMessages: Set<string> = 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<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>,
optimisticReplyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) {
// 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__',
+1
View File
@@ -113,6 +113,7 @@ type SendMessage = (
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
| undefined,
optimisticReplyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => void
type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void
+49
View File
@@ -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<ItemT> = Omit<
FlatListProps<ItemT>,
@@ -216,6 +225,22 @@ function ListImpl<ItemT>(
}, [disableFullWindowScroll])
const nativeRef = useRef<HTMLDivElement>(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<Map<number, HTMLElement>>(new Map())
const registerRowNode = useCallback(
(index: number, node: HTMLElement | null) => {
if (node) {
rowNodesRef.current.set(index, node)
} else {
rowNodesRef.current.delete(index)
}
},
[],
)
useImperativeHandle(
ref,
() => ({
@@ -239,6 +264,17 @@ function ListImpl<ItemT>(
behavior: animated ? 'smooth' : 'instant',
})
},
scrollToIndex({animated = true, index}) {
const node = rowNodesRef.current.get(index)
// scrollIntoView with block: 'center' roughly matches the caller's
// viewPosition of 0.3 - not exact, but close enough and it respects
// whichever element is the scroll container (window or nativeRef).
node?.scrollIntoView({
block: 'center',
behavior: animated ? 'smooth' : 'instant',
})
},
}),
[getScrollableNode],
)
@@ -392,6 +428,7 @@ function ListImpl<ItemT>(
renderItem={renderItem}
extraData={extraData}
onItemSeen={onItemSeen}
registerRowNode={registerRowNode}
/>
)
})}
@@ -470,6 +507,7 @@ let Row = function RowImpl<ItemT>({
renderItem,
extraData: _unused,
onItemSeen,
registerRowNode,
}: {
item: ItemT
index: number
@@ -479,6 +517,7 @@ let Row = function RowImpl<ItemT>({
| ((info: ListRenderItemInfo<ItemT>) => React.ReactNode)
extraData: unknown
onItemSeen: ((item: ItemT) => void) | undefined
registerRowNode: (index: number, node: HTMLElement | null) => void
}): React.ReactNode {
const rowRef = useRef(null)
const intersectionTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(
@@ -529,6 +568,15 @@ let Row = function RowImpl<ItemT>({
}
}, [handleIntersection, onItemSeen])
// Register this row's DOM node so the list can scroll to it by index.
useEffect(() => {
const node: HTMLElement | null = rowRef.current
registerRowNode(index, node)
return () => {
registerRowNode(index, null)
}
}, [index, registerRowNode])
if (!renderItem) {
return null
}
@@ -552,6 +600,7 @@ Row = memo(Row) as <ItemT>(props: {
| ((info: ListRenderItemInfo<ItemT>) => React.ReactNode)
extraData: unknown
onItemSeen: ((item: ItemT) => void) | undefined
registerRowNode: (index: number, node: HTMLElement | null) => void
}) => React.ReactNode
let Visibility = ({