Message replies in chat (#10903)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
|
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
@@ -93,7 +93,7 @@
|
|||||||
"prettier": "prettier --check ."
|
"prettier": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.20.12",
|
"@atproto/api": "0.20.15",
|
||||||
"@atproto/syntax": "0.6.1",
|
"@atproto/syntax": "0.6.1",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
|
|||||||
Generated
+5
-5
@@ -242,8 +242,8 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/api':
|
'@atproto/api':
|
||||||
specifier: 0.20.12
|
specifier: 0.20.15
|
||||||
version: 0.20.12
|
version: 0.20.15
|
||||||
'@atproto/syntax':
|
'@atproto/syntax':
|
||||||
specifier: 0.6.1
|
specifier: 0.6.1
|
||||||
version: 0.6.1
|
version: 0.6.1
|
||||||
@@ -877,8 +877,8 @@ packages:
|
|||||||
graphql:
|
graphql:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@atproto/api@0.20.12':
|
'@atproto/api@0.20.15':
|
||||||
resolution: {integrity: sha512-pNCrl/BSmkjlrVu0W5A9zkOVRIWdWAdajuHX/EyhDZFxu8HExsiQi6j46H5kv85GrNRXS/QtULE+ocSuMtEJfw==}
|
resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
'@atproto/common-web@0.5.0':
|
'@atproto/common-web@0.5.0':
|
||||||
@@ -9494,7 +9494,7 @@ snapshots:
|
|||||||
|
|
||||||
'@0no-co/graphql.web@1.2.0': {}
|
'@0no-co/graphql.web@1.2.0': {}
|
||||||
|
|
||||||
'@atproto/api@0.20.12':
|
'@atproto/api@0.20.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@atproto/common-web': 0.5.0
|
'@atproto/common-web': 0.5.0
|
||||||
'@atproto/lexicon': 0.7.1
|
'@atproto/lexicon': 0.7.1
|
||||||
|
|||||||
@@ -586,6 +586,14 @@ export type Events = {
|
|||||||
| 'SendViaChatDialog'
|
| 'SendViaChatDialog'
|
||||||
| 'ConvoSettings'
|
| 'ConvoSettings'
|
||||||
}
|
}
|
||||||
|
// Message replies
|
||||||
|
'chat:message:reply:send': {
|
||||||
|
convoId: string
|
||||||
|
isGroup: boolean
|
||||||
|
}
|
||||||
|
'chat:message:reply:tap': {
|
||||||
|
convoId: string
|
||||||
|
}
|
||||||
|
|
||||||
// Group chat adoption
|
// Group chat adoption
|
||||||
'groupchat:create': {
|
'groupchat:create': {
|
||||||
|
|||||||
@@ -850,10 +850,11 @@ export function Item({
|
|||||||
!unstyled && [
|
!unstyled && [
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.px_2xl,
|
a.px_lg,
|
||||||
|
a.gap_sm,
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
{gap: 6, minHeight: 44, paddingVertical: 10},
|
{minHeight: 44, paddingVertical: 10},
|
||||||
(focused || pressed || context.hoveredMenuItem === id) &&
|
(focused || pressed || context.hoveredMenuItem === id) &&
|
||||||
!rest.disabled &&
|
!rest.disabled &&
|
||||||
t.atoms.bg_contrast_50,
|
t.atoms.bg_contrast_50,
|
||||||
@@ -882,8 +883,7 @@ export function ItemText({children, style}: ItemTextProps) {
|
|||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.text_md,
|
a.text_md,
|
||||||
a.font_semi_bold,
|
a.font_medium,
|
||||||
t.atoms.text_contrast_high,
|
|
||||||
style,
|
style,
|
||||||
destructive && {color: t.palette.negative_500},
|
destructive && {color: t.palette.negative_500},
|
||||||
disabled && t.atoms.text_contrast_low,
|
disabled && t.atoms.text_contrast_low,
|
||||||
@@ -898,13 +898,13 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
|
|||||||
const {disabled, destructive} = useContextMenuItemContext()
|
const {disabled, destructive} = useContextMenuItemContext()
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
size="lg"
|
size="md"
|
||||||
fill={
|
fill={
|
||||||
disabled
|
disabled
|
||||||
? t.atoms.text_contrast_low.color
|
? t.atoms.text_contrast_low.color
|
||||||
: destructive
|
: destructive
|
||||||
? t.palette.negative_500
|
? 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 * as ContextMenu from '#/components/ContextMenu'
|
||||||
import {type TriggerProps} from '#/components/ContextMenu/types'
|
import {type TriggerProps} from '#/components/ContextMenu/types'
|
||||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||||
|
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||||
|
import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
|
||||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||||
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||||
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
|
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
|
||||||
@@ -47,6 +49,7 @@ export let MessageContextMenu = ({
|
|||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const convo = useConvoActive()
|
const convo = useConvoActive()
|
||||||
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
|
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
|
||||||
|
const {setReply} = useMessageReplies()
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const translate = useGoogleTranslate()
|
const translate = useGoogleTranslate()
|
||||||
|
|
||||||
@@ -151,6 +154,13 @@ export let MessageContextMenu = ({
|
|||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
})}`}
|
})}`}
|
||||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
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 && (
|
{message.text.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
@@ -172,7 +182,6 @@ export let MessageContextMenu = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
destructive
|
|
||||||
testID="messageDropdownDeleteBtn"
|
testID="messageDropdownDeleteBtn"
|
||||||
label={l`Delete message for me`}
|
label={l`Delete message for me`}
|
||||||
onPress={() => openDeleteMessage(message)}>
|
onPress={() => openDeleteMessage(message)}>
|
||||||
@@ -181,7 +190,6 @@ export let MessageContextMenu = ({
|
|||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{!isFromSelf && (
|
{!isFromSelf && (
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
destructive
|
|
||||||
testID="messageDropdownReportBtn"
|
testID="messageDropdownReportBtn"
|
||||||
label={l`Report message`}
|
label={l`Report message`}
|
||||||
onPress={() => openReportMessage(message, senderProfile)}>
|
onPress={() => openReportMessage(message, senderProfile)}>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import Animated, {
|
|||||||
LinearTransition,
|
LinearTransition,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
|
withDelay,
|
||||||
|
withSequence,
|
||||||
withTiming,
|
withTiming,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
@@ -41,11 +43,13 @@ import {useProfileBlockMutationQueue} from '#/state/queries/profile'
|
|||||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
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 {isOnlyEmoji} from '#/alf/typography'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||||
|
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||||
|
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
@@ -63,28 +67,48 @@ import {
|
|||||||
|
|
||||||
const AVATAR_SIZE = 28
|
const AVATAR_SIZE = 28
|
||||||
const CLUSTERED_MESSAGE_GAP = 2
|
const CLUSTERED_MESSAGE_GAP = 2
|
||||||
const BORDER_RADIUS = 18
|
const BORDER_RADIUS = 20
|
||||||
const SQUARED_BORDER_RADIUS = 4
|
const SQUARED_BORDER_RADIUS = 4
|
||||||
const DISPLAY_NAME_INSET = 20
|
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({
|
function isWithinClusterBoundary({
|
||||||
isPending,
|
isPending,
|
||||||
|
message,
|
||||||
adjacentMessage,
|
adjacentMessage,
|
||||||
isFromSameSender,
|
isFromSameSender,
|
||||||
currentSentAt,
|
|
||||||
direction,
|
direction,
|
||||||
}: {
|
}: {
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
|
message: ChatBskyConvoDefs.MessageView
|
||||||
adjacentMessage:
|
adjacentMessage:
|
||||||
| ChatBskyConvoDefs.MessageView
|
| ChatBskyConvoDefs.MessageView
|
||||||
| ChatBskyConvoDefs.DeletedMessageView
|
| ChatBskyConvoDefs.DeletedMessageView
|
||||||
| null
|
| null
|
||||||
isFromSameSender: boolean
|
isFromSameSender: boolean
|
||||||
currentSentAt: string
|
|
||||||
direction: 'prev' | 'next'
|
direction: 'prev' | 'next'
|
||||||
}): boolean {
|
}): 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 (!isFromSameSender) return true
|
||||||
if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
|
if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
|
||||||
|
const currentSentAt = message.sentAt
|
||||||
const thisDate = new Date(currentSentAt)
|
const thisDate = new Date(currentSentAt)
|
||||||
const adjDate = new Date(adjacentMessage.sentAt)
|
const adjDate = new Date(adjacentMessage.sentAt)
|
||||||
const diff =
|
const diff =
|
||||||
@@ -128,6 +152,16 @@ let MessageItem = ({
|
|||||||
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
||||||
|
|
||||||
const {openReactions} = useMessageDialogs()
|
const {openReactions} = useMessageDialogs()
|
||||||
|
const {scrollToMessage, highlightedMessage} = useMessageReplies()
|
||||||
|
|
||||||
|
// `replyTo` comes back hydrated as the referenced message (or a deleted-
|
||||||
|
// message tombstone). Narrow away the open-union fallback so we only render
|
||||||
|
// shapes we understand.
|
||||||
|
const replyTo =
|
||||||
|
ChatBskyConvoDefs.isMessageView(message.replyTo) ||
|
||||||
|
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo)
|
||||||
|
? message.replyTo
|
||||||
|
: undefined
|
||||||
|
|
||||||
const isPending = item.type === 'pending-message'
|
const isPending = item.type === 'pending-message'
|
||||||
|
|
||||||
@@ -150,17 +184,17 @@ let MessageItem = ({
|
|||||||
|
|
||||||
const isFirstInCluster = isWithinClusterBoundary({
|
const isFirstInCluster = isWithinClusterBoundary({
|
||||||
isPending,
|
isPending,
|
||||||
|
message,
|
||||||
adjacentMessage: prevMessage,
|
adjacentMessage: prevMessage,
|
||||||
isFromSameSender: isPrevFromSameSender,
|
isFromSameSender: isPrevFromSameSender,
|
||||||
currentSentAt: message.sentAt,
|
|
||||||
direction: 'prev',
|
direction: 'prev',
|
||||||
})
|
})
|
||||||
|
|
||||||
const isLastInCluster = isWithinClusterBoundary({
|
const isLastInCluster = isWithinClusterBoundary({
|
||||||
isPending,
|
isPending,
|
||||||
|
message,
|
||||||
adjacentMessage: nextMessage,
|
adjacentMessage: nextMessage,
|
||||||
isFromSameSender: isNextFromSameSender,
|
isFromSameSender: isNextFromSameSender,
|
||||||
currentSentAt: message.sentAt,
|
|
||||||
direction: 'next',
|
direction: 'next',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,6 +260,26 @@ let MessageItem = ({
|
|||||||
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
|
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
|
||||||
}, [targetTopRadius, topRadiusSV])
|
}, [targetTopRadius, topRadiusSV])
|
||||||
|
|
||||||
|
// Flash the message background when it's been scrolled to (e.g. by tapping a
|
||||||
|
// reply that quotes it), so it's easy to spot. Keyed on the highlight `key`
|
||||||
|
// so re-tapping the same message re-triggers the flash.
|
||||||
|
const highlightSV = useSharedValue(0)
|
||||||
|
const isHighlighted = highlightedMessage?.id === message.id
|
||||||
|
const highlightKey = isHighlighted ? highlightedMessage.key : null
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlightKey === null) return
|
||||||
|
highlightSV.set(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1, {duration: 150}),
|
||||||
|
withDelay(400, withTiming(0, {duration: 450})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, [highlightKey, highlightSV])
|
||||||
|
|
||||||
|
const highlightStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: highlightSV.get(),
|
||||||
|
}))
|
||||||
|
|
||||||
const borderRadiusStyle = useAnimatedStyle(() =>
|
const borderRadiusStyle = useAnimatedStyle(() =>
|
||||||
isFromSelf
|
isFromSelf
|
||||||
? {
|
? {
|
||||||
@@ -372,6 +426,14 @@ let MessageItem = ({
|
|||||||
web: a.mx_lg,
|
web: a.mx_lg,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Negative of `messageInset` so the flash bleeds past the row's horizontal
|
||||||
|
// margin to the screen edges.
|
||||||
|
const flashBleed = platform<number>({
|
||||||
|
android: -a.mx_sm.marginLeft,
|
||||||
|
ios: -a.mx_md.marginLeft,
|
||||||
|
web: -a.mx_lg.marginLeft,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasLargeGapFromPrev && <DateDivider date={message.sentAt} />}
|
{hasLargeGapFromPrev && <DateDivider date={message.sentAt} />}
|
||||||
@@ -381,6 +443,20 @@ let MessageItem = ({
|
|||||||
isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP},
|
isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP},
|
||||||
hasReactions && {paddingBottom: 26},
|
hasReactions && {paddingBottom: 26},
|
||||||
]}>
|
]}>
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
top: -CLUSTERED_MESSAGE_GAP,
|
||||||
|
bottom: -CLUSTERED_MESSAGE_GAP,
|
||||||
|
left: flashBleed,
|
||||||
|
right: flashBleed,
|
||||||
|
backgroundColor: t.palette.primary_100,
|
||||||
|
},
|
||||||
|
highlightStyle,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<View style={[a.relative]}>
|
<View style={[a.relative]}>
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
<View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View>
|
<View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View>
|
||||||
@@ -391,7 +467,16 @@ let MessageItem = ({
|
|||||||
a.flex_grow,
|
a.flex_grow,
|
||||||
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
|
!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
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_xs,
|
a.text_xs,
|
||||||
@@ -458,6 +543,14 @@ let MessageItem = ({
|
|||||||
borderRadiusStyle,
|
borderRadiusStyle,
|
||||||
],
|
],
|
||||||
]}>
|
]}>
|
||||||
|
{replyTo && !isOnlyEmoji(message.text) ? (
|
||||||
|
<ReplyQuote
|
||||||
|
replyTo={replyTo}
|
||||||
|
isFromSelf={isFromSelf}
|
||||||
|
relatedProfiles={relatedProfiles}
|
||||||
|
onPress={() => scrollToMessage(replyTo.id)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<RichText
|
<RichText
|
||||||
value={rt}
|
value={rt}
|
||||||
style={[
|
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',
|
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',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -262,7 +262,6 @@ function InnerReady({
|
|||||||
{IS_LIQUID_GLASS ? (
|
{IS_LIQUID_GLASS ? (
|
||||||
<ScrollEdgeEffect
|
<ScrollEdgeEffect
|
||||||
edge="top"
|
edge="top"
|
||||||
effect="soft"
|
|
||||||
style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]}
|
style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]}
|
||||||
onLayout={onHeaderLayout}>
|
onLayout={onHeaderLayout}>
|
||||||
{header}
|
{header}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useRef, useState} from 'react'
|
import {useEffect, useRef, useState} from 'react'
|
||||||
import {Pressable, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
useKeyboardHandler,
|
useKeyboardHandler,
|
||||||
@@ -13,6 +13,7 @@ import Animated, {
|
|||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {GlassContainer} from 'expo-glass-effect'
|
import {GlassContainer} from 'expo-glass-effect'
|
||||||
import {LinearGradient} from 'expo-linear-gradient'
|
import {LinearGradient} from 'expo-linear-gradient'
|
||||||
|
import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api'
|
||||||
import {ScrollEdgeEffect} from '@bsky.app/expo-scroll-edge-effect'
|
import {ScrollEdgeEffect} from '@bsky.app/expo-scroll-edge-effect'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
} from '#/state/messages/message-drafts'
|
} from '#/state/messages/message-drafts'
|
||||||
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
|
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
|
||||||
import {Composer, useComposerInternalApiRef} from '#/components/Composer'
|
import {Composer, useComposerInternalApiRef} from '#/components/Composer'
|
||||||
|
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||||
import * as EmojiPicker from '#/components/EmojiPicker'
|
import * as EmojiPicker from '#/components/EmojiPicker'
|
||||||
import {GlassView} from '#/components/GlassView'
|
import {GlassView} from '#/components/GlassView'
|
||||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||||
@@ -47,7 +49,10 @@ export function MessageComposer({
|
|||||||
loading = false,
|
loading = false,
|
||||||
}: {
|
}: {
|
||||||
textInputId?: string
|
textInputId?: string
|
||||||
onSendMessage: (message: string) => void
|
onSendMessage: (
|
||||||
|
message: string,
|
||||||
|
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||||
|
) => void
|
||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
@@ -60,10 +65,16 @@ export function MessageComposer({
|
|||||||
const editable = !needsEmailVerification && !loading
|
const editable = !needsEmailVerification && !loading
|
||||||
const {getDraft, clearDraft} = useMessageDraft()
|
const {getDraft, clearDraft} = useMessageDraft()
|
||||||
const composerInternalApiRef = useComposerInternalApiRef()
|
const composerInternalApiRef = useComposerInternalApiRef()
|
||||||
|
const {replyTo, clearReply} = useMessageReplies()
|
||||||
|
|
||||||
const [text, setText] = useState(getDraft)
|
const [text, setText] = useState(getDraft)
|
||||||
useSaveMessageDraft(text)
|
useSaveMessageDraft(text)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!replyTo) return
|
||||||
|
composerInternalApiRef.current?.input?.focus()
|
||||||
|
}, [replyTo, composerInternalApiRef])
|
||||||
|
|
||||||
// Android interactive dismiss sometimes doesn't blur the input
|
// Android interactive dismiss sometimes doesn't blur the input
|
||||||
const blur = useNonReactiveCallback(() => {
|
const blur = useNonReactiveCallback(() => {
|
||||||
composerInternalApiRef.current?.input?.blur()
|
composerInternalApiRef.current?.input?.blur()
|
||||||
@@ -80,7 +91,10 @@ export function MessageComposer({
|
|||||||
|
|
||||||
const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0)
|
const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0)
|
||||||
|
|
||||||
const onSubmit = (message: string) => {
|
const onSubmit = (
|
||||||
|
message: string,
|
||||||
|
replyTo: ChatBskyConvoDefs.MessageView | null,
|
||||||
|
) => {
|
||||||
if (!editable) return
|
if (!editable) return
|
||||||
if (!hasEmbed && message.trim() === '') return
|
if (!hasEmbed && message.trim() === '') return
|
||||||
const graphemeCount = countGraphemes(message)
|
const graphemeCount = countGraphemes(message)
|
||||||
@@ -95,6 +109,7 @@ export function MessageComposer({
|
|||||||
clearDraft()
|
clearDraft()
|
||||||
playHaptic()
|
playHaptic()
|
||||||
setEmbed(undefined)
|
setEmbed(undefined)
|
||||||
|
clearReply()
|
||||||
composerInternalApiRef.current?.clear()
|
composerInternalApiRef.current?.clear()
|
||||||
|
|
||||||
if (IS_WEB) {
|
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
|
// defer send by a frame so that the textinput resizes before we send the message
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
onSendMessage(message)
|
onSendMessage(
|
||||||
|
message,
|
||||||
|
replyTo
|
||||||
|
? {
|
||||||
|
...replyTo,
|
||||||
|
$type: 'chat.bsky.convo.defs#messageView',
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,18 +152,18 @@ export function MessageComposer({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isFlushingAutocorrectSuggestion.current) {
|
if (isFlushingAutocorrectSuggestion.current) {
|
||||||
isFlushingAutocorrectSuggestion.current = false
|
isFlushingAutocorrectSuggestion.current = false
|
||||||
onSubmit(text)
|
onSubmit(text, replyTo)
|
||||||
}
|
}
|
||||||
}, 20)
|
}, 20)
|
||||||
} else {
|
} else {
|
||||||
onSubmit(text)
|
onSubmit(text, replyTo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = (nextText: string) => {
|
const handleChange = (nextText: string) => {
|
||||||
if (IS_IOS && isFlushingAutocorrectSuggestion.current) {
|
if (IS_IOS && isFlushingAutocorrectSuggestion.current) {
|
||||||
isFlushingAutocorrectSuggestion.current = false
|
isFlushingAutocorrectSuggestion.current = false
|
||||||
onSubmit(nextText)
|
onSubmit(nextText, replyTo)
|
||||||
} else {
|
} else {
|
||||||
setText(nextText)
|
setText(nextText)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Animated, {
|
|||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {GlassContainer} from 'expo-glass-effect'
|
import {GlassContainer} from 'expo-glass-effect'
|
||||||
|
import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
useSaveMessageDraft,
|
useSaveMessageDraft,
|
||||||
} from '#/state/messages/message-drafts'
|
} from '#/state/messages/message-drafts'
|
||||||
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
||||||
|
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||||
import {GlassView} from '#/components/GlassView'
|
import {GlassView} from '#/components/GlassView'
|
||||||
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
@@ -47,7 +49,10 @@ export function MessageInput({
|
|||||||
loading = false,
|
loading = false,
|
||||||
}: {
|
}: {
|
||||||
textInputId?: string
|
textInputId?: string
|
||||||
onSendMessage: (message: string) => Promise<void> | void
|
onSendMessage: (
|
||||||
|
message: string,
|
||||||
|
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||||
|
) => Promise<void> | void
|
||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
@@ -57,6 +62,7 @@ export function MessageInput({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
const {getDraft, clearDraft} = useMessageDraft()
|
const {getDraft, clearDraft} = useMessageDraft()
|
||||||
|
const {replyTo, clearReply} = useMessageReplies()
|
||||||
|
|
||||||
// Input layout
|
// Input layout
|
||||||
const {top: topInset} = useSafeAreaInsets()
|
const {top: topInset} = useSafeAreaInsets()
|
||||||
@@ -92,6 +98,9 @@ export function MessageInput({
|
|||||||
playHaptic()
|
playHaptic()
|
||||||
setEmbed(undefined)
|
setEmbed(undefined)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
// Capture the reply before clearing - the deferred send below reads it.
|
||||||
|
const reply = replyTo
|
||||||
|
clearReply()
|
||||||
if (IS_IOS) {
|
if (IS_IOS) {
|
||||||
setShouldEnforceClear(true)
|
setShouldEnforceClear(true)
|
||||||
}
|
}
|
||||||
@@ -104,7 +113,12 @@ export function MessageInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
void onSendMessage(message)
|
void onSendMessage(
|
||||||
|
message,
|
||||||
|
reply
|
||||||
|
? {...reply, $type: 'chat.bsky.convo.defs#messageView'}
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
editable,
|
editable,
|
||||||
@@ -116,6 +130,8 @@ export function MessageInput({
|
|||||||
setEmbed,
|
setEmbed,
|
||||||
inputRef,
|
inputRef,
|
||||||
l,
|
l,
|
||||||
|
replyTo,
|
||||||
|
clearReply,
|
||||||
])
|
])
|
||||||
|
|
||||||
useFocusedInputHandler(
|
useFocusedInputHandler(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {useCallback, useRef, useState} from 'react'
|
import {useCallback, useRef, useState} from 'react'
|
||||||
import {Pressable, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
|
import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {flushSync} from 'react-dom'
|
import {flushSync} from 'react-dom'
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
import TextareaAutosize from 'react-textarea-autosize'
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from '#/state/messages/message-drafts'
|
} from '#/state/messages/message-drafts'
|
||||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
|
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||||
import * as EmojiPicker from '#/components/EmojiPicker'
|
import * as EmojiPicker from '#/components/EmojiPicker'
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||||
@@ -28,7 +30,10 @@ export function MessageInput({
|
|||||||
children,
|
children,
|
||||||
loading = false,
|
loading = false,
|
||||||
}: {
|
}: {
|
||||||
onSendMessage: (message: string) => void
|
onSendMessage: (
|
||||||
|
message: string,
|
||||||
|
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||||
|
) => void
|
||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
@@ -38,6 +43,7 @@ export function MessageInput({
|
|||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {getDraft, clearDraft} = useMessageDraft()
|
const {getDraft, clearDraft} = useMessageDraft()
|
||||||
|
const {replyTo, clearReply} = useMessageReplies()
|
||||||
const [message, setMessage] = useState(getDraft)
|
const [message, setMessage] = useState(getDraft)
|
||||||
|
|
||||||
const inputStyles = useSharedInputStyles()
|
const inputStyles = useSharedInputStyles()
|
||||||
@@ -58,10 +64,25 @@ export function MessageInput({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
clearDraft()
|
clearDraft()
|
||||||
onSendMessage(message)
|
onSendMessage(
|
||||||
|
message,
|
||||||
|
replyTo
|
||||||
|
? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'}
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
clearReply()
|
||||||
setMessage('')
|
setMessage('')
|
||||||
setEmbed(undefined)
|
setEmbed(undefined)
|
||||||
}, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed])
|
}, [
|
||||||
|
message,
|
||||||
|
onSendMessage,
|
||||||
|
l,
|
||||||
|
clearDraft,
|
||||||
|
hasEmbed,
|
||||||
|
setEmbed,
|
||||||
|
replyTo,
|
||||||
|
clearReply,
|
||||||
|
])
|
||||||
|
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect'
|
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect'
|
||||||
|
|
||||||
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {mergeRefs} from '#/lib/merge-refs'
|
import {mergeRefs} from '#/lib/merge-refs'
|
||||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
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 {DateDivider} from '#/components/dms/DateDivider'
|
||||||
import {MessageItem} from '#/components/dms/MessageItem'
|
import {MessageItem} from '#/components/dms/MessageItem'
|
||||||
import {MessageOverlays} from '#/components/dms/MessageOverlays'
|
import {MessageOverlays} from '#/components/dms/MessageOverlays'
|
||||||
|
import {MessageRepliesProvider} from '#/components/dms/MessageReplies'
|
||||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||||
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
|
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
|
||||||
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
|
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
|
||||||
@@ -72,7 +74,12 @@ import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env'
|
|||||||
import {ChatStatusInfo} from './ChatStatusInfo'
|
import {ChatStatusInfo} from './ChatStatusInfo'
|
||||||
import {groupSystemMessages, type RenderItem} from './groupSystemMessages'
|
import {groupSystemMessages, type RenderItem} from './groupSystemMessages'
|
||||||
import {InviteLinkDialogProvider} from './InviteLinkDialogProvider'
|
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 {MessagesListGroupInfoPanel} from './MessagesListGroupInfoPanel'
|
||||||
import {MessagesListInfoPanel} from './MessagesListInfoPanel'
|
import {MessagesListInfoPanel} from './MessagesListInfoPanel'
|
||||||
import {KeyboardStickyView} from './vendor/KeyboardStickyView'
|
import {KeyboardStickyView} from './vendor/KeyboardStickyView'
|
||||||
@@ -371,7 +378,7 @@ export function MessagesList({
|
|||||||
|
|
||||||
// -- Message sending
|
// -- Message sending
|
||||||
const onSendMessage = useCallback(
|
const onSendMessage = useCallback(
|
||||||
async (text: string) => {
|
async (text: string, reply?: $Typed<ChatBskyConvoDefs.MessageView>) => {
|
||||||
let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
|
let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
|
||||||
|
|
||||||
// detect facets without resolution first - this is used to see if there's
|
// detect facets without resolution first - this is used to see if there's
|
||||||
@@ -387,6 +394,7 @@ export function MessagesList({
|
|||||||
| $Typed<AppBskyEmbedRecord.View>
|
| $Typed<AppBskyEmbedRecord.View>
|
||||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||||
| undefined
|
| undefined
|
||||||
|
let replyTo: ChatBskyConvoDefs.ReplyRef | undefined
|
||||||
|
|
||||||
// Find the embedded link facet and, if it's at the start or end of the
|
// 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).
|
// message, remove it from the text (the embed card replaces it).
|
||||||
@@ -455,6 +463,10 @@ export function MessagesList({
|
|||||||
stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code)
|
stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reply) {
|
||||||
|
replyTo = {messageId: reply.id}
|
||||||
|
}
|
||||||
|
|
||||||
await rt.detectFacets(agent)
|
await rt.detectFacets(agent)
|
||||||
|
|
||||||
rt = shortenLinks(rt)
|
rt = shortenLinks(rt)
|
||||||
@@ -469,10 +481,18 @@ export function MessagesList({
|
|||||||
text: rt.text,
|
text: rt.text,
|
||||||
facets: rt.facets,
|
facets: rt.facets,
|
||||||
embed,
|
embed,
|
||||||
|
replyTo,
|
||||||
},
|
},
|
||||||
embedView,
|
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') {
|
if (convoState.convo.kind === 'group') {
|
||||||
ax.metric('groupchat:message:send', {
|
ax.metric('groupchat:message:send', {
|
||||||
convoId: convoState.convo.view.id,
|
convoId: convoState.convo.view.id,
|
||||||
@@ -510,6 +530,29 @@ export function MessagesList({
|
|||||||
})
|
})
|
||||||
}, [flatListRef])
|
}, [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}) => {
|
const renderItem = ({item, index}: {item: RenderItem; index: number}) => {
|
||||||
if (item.type === 'message' || item.type === 'pending-message') {
|
if (item.type === 'message' || item.type === 'pending-message') {
|
||||||
return (
|
return (
|
||||||
@@ -561,6 +604,7 @@ export function MessagesList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InviteLinkDialogProvider convo={convoState.convo}>
|
<InviteLinkDialogProvider convo={convoState.convo}>
|
||||||
|
<MessageRepliesProvider scrollToMessage={scrollToMessage}>
|
||||||
<MessageOverlays>
|
<MessageOverlays>
|
||||||
<KeyboardGestureArea
|
<KeyboardGestureArea
|
||||||
interpolator="ios"
|
interpolator="ios"
|
||||||
@@ -597,7 +641,9 @@ export function MessagesList({
|
|||||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||||
{convoState.hasAllHistory ? (
|
{convoState.hasAllHistory ? (
|
||||||
convoState.convo?.kind === 'group' ? (
|
convoState.convo?.kind === 'group' ? (
|
||||||
<MessagesListGroupInfoPanel convo={convoState.convo} />
|
<MessagesListGroupInfoPanel
|
||||||
|
convo={convoState.convo}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MessagesListInfoPanel convo={convoState.convo} />
|
<MessagesListInfoPanel convo={convoState.convo} />
|
||||||
)
|
)
|
||||||
@@ -648,37 +694,18 @@ export function MessagesList({
|
|||||||
<ConversationFooter
|
<ConversationFooter
|
||||||
convoState={convoState}
|
convoState={convoState}
|
||||||
hasAcceptOverride={hasAcceptOverride}>
|
hasAcceptOverride={hasAcceptOverride}>
|
||||||
{({loading}) =>
|
{({loading}) => (
|
||||||
ax.features.enabled(
|
<Composer
|
||||||
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
|
|
||||||
textInputId={textInputId}
|
textInputId={textInputId}
|
||||||
onSendMessage={onSendMessage}
|
onSendMessage={onSendMessage}
|
||||||
hasEmbed={!!messageEmbed}
|
messageEmbed={messageEmbed}
|
||||||
setEmbed={setEmbed}
|
|
||||||
loading={loading}>
|
|
||||||
<MessageInputEmbed
|
|
||||||
embed={messageEmbed}
|
|
||||||
setEmbed={setEmbed}
|
setEmbed={setEmbed}
|
||||||
|
loading={loading}
|
||||||
|
useNewComposer={ax.features.enabled(
|
||||||
|
ax.features.DmsNewMessageComposerEnable,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</MessageInput>
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
</ConversationFooter>
|
</ConversationFooter>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
@@ -689,10 +716,68 @@ export function MessagesList({
|
|||||||
<NewMessagesPill onPress={scrollToEndOnPress} />
|
<NewMessagesPill onPress={scrollToEndOnPress} />
|
||||||
)}
|
)}
|
||||||
</MessageOverlays>
|
</MessageOverlays>
|
||||||
|
</MessageRepliesProvider>
|
||||||
</InviteLinkDialogProvider>
|
</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 */
|
/** Note: native only */
|
||||||
function ChatScrollComponent({
|
function ChatScrollComponent({
|
||||||
ref,
|
ref,
|
||||||
|
|||||||
@@ -86,6 +86,22 @@ function toSystemMessageView(
|
|||||||
return ev.message
|
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 {
|
export class Convo {
|
||||||
private id: string
|
private id: string
|
||||||
|
|
||||||
@@ -119,6 +135,7 @@ export class Convo {
|
|||||||
optimisticEmbedView?:
|
optimisticEmbedView?:
|
||||||
| $Typed<AppBskyEmbedRecord.View>
|
| $Typed<AppBskyEmbedRecord.View>
|
||||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||||
|
optimisticReplyTo?: $Typed<ChatBskyConvoDefs.MessageView>
|
||||||
}
|
}
|
||||||
> = new Map()
|
> = new Map()
|
||||||
private deletedMessages: Set<string> = new Set()
|
private deletedMessages: Set<string> = new Set()
|
||||||
@@ -802,6 +819,12 @@ export class Convo {
|
|||||||
})
|
})
|
||||||
const {cursor, messages, relatedProfiles} = response.data
|
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
|
this.oldestRev = cursor ?? null
|
||||||
|
|
||||||
if (relatedProfiles) {
|
if (relatedProfiles) {
|
||||||
@@ -811,14 +834,6 @@ export class Convo {
|
|||||||
this.applyProfileShadows()
|
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) {
|
for (const message of messages) {
|
||||||
if (
|
if (
|
||||||
ChatBskyConvoDefs.isMessageView(message) ||
|
ChatBskyConvoDefs.isMessageView(message) ||
|
||||||
@@ -965,17 +980,17 @@ export class Convo {
|
|||||||
ChatBskyConvoDefs.isDeletedMessageView(ev.message)
|
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.pastMessages.delete(ev.message.id)
|
||||||
this.newMessages.delete(ev.message.id)
|
this.newMessages.delete(ev.message.id)
|
||||||
this.deletedMessages.delete(ev.message.id)
|
this.deletedMessages.add(ev.message.id)
|
||||||
needsCommit = true
|
needsCommit = true
|
||||||
}
|
|
||||||
} else if (
|
} else if (
|
||||||
(ChatBskyConvoDefs.isLogAddReaction(ev) ||
|
(ChatBskyConvoDefs.isLogAddReaction(ev) ||
|
||||||
ChatBskyConvoDefs.isLogRemoveReaction(ev)) &&
|
ChatBskyConvoDefs.isLogRemoveReaction(ev)) &&
|
||||||
@@ -1020,6 +1035,7 @@ export class Convo {
|
|||||||
optimisticEmbedView?:
|
optimisticEmbedView?:
|
||||||
| $Typed<AppBskyEmbedRecord.View>
|
| $Typed<AppBskyEmbedRecord.View>
|
||||||
| $Typed<ChatBskyEmbedJoinLink.View>,
|
| $Typed<ChatBskyEmbedJoinLink.View>,
|
||||||
|
optimisticReplyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||||
) {
|
) {
|
||||||
// Ignore empty messages for now since they have no other purpose atm
|
// Ignore empty messages for now since they have no other purpose atm
|
||||||
if (!message.text.trim() && !message.embed) return
|
if (!message.text.trim() && !message.embed) return
|
||||||
@@ -1033,6 +1049,7 @@ export class Convo {
|
|||||||
id: tempId,
|
id: tempId,
|
||||||
message,
|
message,
|
||||||
optimisticEmbedView,
|
optimisticEmbedView,
|
||||||
|
optimisticReplyTo,
|
||||||
})
|
})
|
||||||
if (this.convo?.view.status === 'request') {
|
if (this.convo?.view.status === 'request') {
|
||||||
this.updateConvo({
|
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
|
* Items in reverse order, since FlatList inverts
|
||||||
*/
|
*/
|
||||||
@@ -1327,7 +1364,7 @@ export class Convo {
|
|||||||
items.unshift({
|
items.unshift({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: this.tombstoneDeletedReplyTo(m),
|
||||||
})
|
})
|
||||||
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
|
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
|
||||||
items.unshift({
|
items.unshift({
|
||||||
@@ -1360,7 +1397,7 @@ export class Convo {
|
|||||||
items.push({
|
items.push({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: m,
|
message: this.tombstoneDeletedReplyTo(m),
|
||||||
})
|
})
|
||||||
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
|
} else if (ChatBskyConvoDefs.isDeletedMessageView(m)) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -1378,12 +1415,17 @@ export class Convo {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.pendingMessages.forEach(m => {
|
this.pendingMessages.forEach(m => {
|
||||||
|
const optimisticReplyTo =
|
||||||
|
m.optimisticReplyTo && this.deletedMessages.has(m.optimisticReplyTo.id)
|
||||||
|
? toDeletedMessageView(m.optimisticReplyTo)
|
||||||
|
: m.optimisticReplyTo
|
||||||
items.push({
|
items.push({
|
||||||
type: 'pending-message',
|
type: 'pending-message',
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: {
|
message: {
|
||||||
...m.message,
|
...m.message,
|
||||||
embed: m.optimisticEmbedView,
|
embed: m.optimisticEmbedView,
|
||||||
|
replyTo: optimisticReplyTo,
|
||||||
$type: 'chat.bsky.convo.defs#messageView',
|
$type: 'chat.bsky.convo.defs#messageView',
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
rev: '__fake__',
|
rev: '__fake__',
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ type SendMessage = (
|
|||||||
| $Typed<AppBskyEmbedRecord.View>
|
| $Typed<AppBskyEmbedRecord.View>
|
||||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||||
| undefined,
|
| undefined,
|
||||||
|
optimisticReplyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||||
) => void
|
) => void
|
||||||
type FetchMessageHistory = () => Promise<void>
|
type FetchMessageHistory = () => Promise<void>
|
||||||
type MarkConvoAccepted = () => void
|
type MarkConvoAccepted = () => void
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ export type ListMethods = {
|
|||||||
scrollToTop: () => void
|
scrollToTop: () => void
|
||||||
scrollToOffset: (options: {animated: boolean; offset: number}) => void
|
scrollToOffset: (options: {animated: boolean; offset: number}) => void
|
||||||
scrollToEnd: (options?: {animated?: boolean}) => 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<
|
export type ListProps<ItemT> = Omit<
|
||||||
FlatListProps<ItemT>,
|
FlatListProps<ItemT>,
|
||||||
@@ -216,6 +225,22 @@ function ListImpl<ItemT>(
|
|||||||
}, [disableFullWindowScroll])
|
}, [disableFullWindowScroll])
|
||||||
|
|
||||||
const nativeRef = useRef<HTMLDivElement>(null)
|
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(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@@ -239,6 +264,17 @@ function ListImpl<ItemT>(
|
|||||||
behavior: animated ? 'smooth' : 'instant',
|
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],
|
[getScrollableNode],
|
||||||
)
|
)
|
||||||
@@ -392,6 +428,7 @@ function ListImpl<ItemT>(
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={extraData}
|
extraData={extraData}
|
||||||
onItemSeen={onItemSeen}
|
onItemSeen={onItemSeen}
|
||||||
|
registerRowNode={registerRowNode}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -470,6 +507,7 @@ let Row = function RowImpl<ItemT>({
|
|||||||
renderItem,
|
renderItem,
|
||||||
extraData: _unused,
|
extraData: _unused,
|
||||||
onItemSeen,
|
onItemSeen,
|
||||||
|
registerRowNode,
|
||||||
}: {
|
}: {
|
||||||
item: ItemT
|
item: ItemT
|
||||||
index: number
|
index: number
|
||||||
@@ -479,6 +517,7 @@ let Row = function RowImpl<ItemT>({
|
|||||||
| ((info: ListRenderItemInfo<ItemT>) => React.ReactNode)
|
| ((info: ListRenderItemInfo<ItemT>) => React.ReactNode)
|
||||||
extraData: unknown
|
extraData: unknown
|
||||||
onItemSeen: ((item: ItemT) => void) | undefined
|
onItemSeen: ((item: ItemT) => void) | undefined
|
||||||
|
registerRowNode: (index: number, node: HTMLElement | null) => void
|
||||||
}): React.ReactNode {
|
}): React.ReactNode {
|
||||||
const rowRef = useRef(null)
|
const rowRef = useRef(null)
|
||||||
const intersectionTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(
|
const intersectionTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||||
@@ -529,6 +568,15 @@ let Row = function RowImpl<ItemT>({
|
|||||||
}
|
}
|
||||||
}, [handleIntersection, onItemSeen])
|
}, [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) {
|
if (!renderItem) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -552,6 +600,7 @@ Row = memo(Row) as <ItemT>(props: {
|
|||||||
| ((info: ListRenderItemInfo<ItemT>) => React.ReactNode)
|
| ((info: ListRenderItemInfo<ItemT>) => React.ReactNode)
|
||||||
extraData: unknown
|
extraData: unknown
|
||||||
onItemSeen: ((item: ItemT) => void) | undefined
|
onItemSeen: ((item: ItemT) => void) | undefined
|
||||||
|
registerRowNode: (index: number, node: HTMLElement | null) => void
|
||||||
}) => React.ReactNode
|
}) => React.ReactNode
|
||||||
|
|
||||||
let Visibility = ({
|
let Visibility = ({
|
||||||
|
|||||||
Reference in New Issue
Block a user