add message replies in chat
Reply to a specific message in a conversation. Adds a Reply action to the message context menu, a quoted preview in the composer, an inline quote in the sent bubble, and tap-to-scroll back to the original (when loaded). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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': {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
|||||||
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'
|
||||||
|
import {Reply as ReplyIcon} from '#/components/icons/Reply'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
@@ -46,7 +47,7 @@ export let MessageContextMenu = ({
|
|||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const convo = useConvoActive()
|
const convo = useConvoActive()
|
||||||
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
|
const {openDeleteMessage, openReportMessage, openReply} = useMessageDialogs()
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const translate = useGoogleTranslate()
|
const translate = useGoogleTranslate()
|
||||||
|
|
||||||
@@ -151,6 +152,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={() => openReply(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
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ 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 {ArrowCornerDownRight_Stroke2_Corner2_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'
|
||||||
@@ -127,7 +128,16 @@ let MessageItem = ({
|
|||||||
const {message} = item
|
const {message} = item
|
||||||
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
||||||
|
|
||||||
const {openReactions} = useMessageDialogs()
|
const {openReactions, scrollToMessage} = useMessageDialogs()
|
||||||
|
|
||||||
|
// `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'
|
||||||
|
|
||||||
@@ -404,6 +414,14 @@ let MessageItem = ({
|
|||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
{replyTo ? (
|
||||||
|
<ReplyCaption
|
||||||
|
replyTo={replyTo}
|
||||||
|
isFromSelf={isFromSelf}
|
||||||
|
replierDisplayName={displayName}
|
||||||
|
relatedProfiles={relatedProfiles}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{profile && isBlockedOrBlocking(profile) && isGroupChat ? (
|
{profile && isBlockedOrBlocking(profile) && isGroupChat ? (
|
||||||
<BlockedPlaceholder profile={profile} style={borderRadiusStyle} />
|
<BlockedPlaceholder profile={profile} style={borderRadiusStyle} />
|
||||||
) : (
|
) : (
|
||||||
@@ -458,6 +476,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 +674,140 @@ function BlockedPlaceholder({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "↪ X replied to Y" caption rendered above a reply message. `X` is the
|
||||||
|
* person sending the reply (self -> "you"), `Y` is the original sender.
|
||||||
|
*/
|
||||||
|
function ReplyCaption({
|
||||||
|
replyTo,
|
||||||
|
isFromSelf,
|
||||||
|
replierDisplayName,
|
||||||
|
relatedProfiles,
|
||||||
|
}: {
|
||||||
|
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
|
||||||
|
isFromSelf: boolean
|
||||||
|
replierDisplayName: string | null
|
||||||
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
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 (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_2xs, a.pb_2xs, a.pt_xs]}>
|
||||||
|
<ArrowCornerDownRightIcon
|
||||||
|
size="xs"
|
||||||
|
style={t.atoms.text_contrast_medium}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, t.atoms.text_contrast_medium, a.flex_1]}
|
||||||
|
emoji
|
||||||
|
numberOfLines={1}>
|
||||||
|
{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>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 isDeleted = ChatBskyConvoDefs.isDeletedMessageView(replyTo)
|
||||||
|
const senderProfile = relatedProfiles.get(replyTo.sender.did)
|
||||||
|
const senderName = senderProfile
|
||||||
|
? createSanitizedDisplayName(senderProfile)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// On the blue self-bubble, derive the quote chrome from white; on the grey
|
||||||
|
// bubble, from the foreground text color. Keeps it legible against either.
|
||||||
|
const tintColor = isFromSelf ? t.palette.white : t.atoms.text.color
|
||||||
|
const subtleColor = isFromSelf
|
||||||
|
? utils.alpha(t.palette.white, 0.7)
|
||||||
|
: t.atoms.text_contrast_medium.color
|
||||||
|
|
||||||
|
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]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.gap_2xs,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.px_sm,
|
||||||
|
a.py_xs,
|
||||||
|
a.border_l,
|
||||||
|
{
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: tintColor,
|
||||||
|
backgroundColor: utils.alpha(tintColor, isFromSelf ? 0.15 : 0.06),
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{senderName ? (
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.font_bold, {color: subtleColor}]}
|
||||||
|
emoji
|
||||||
|
numberOfLines={1}>
|
||||||
|
{senderName}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={[a.text_sm, {color: isDeleted ? subtleColor : tintColor}]}
|
||||||
|
emoji
|
||||||
|
numberOfLines={2}>
|
||||||
|
{ChatBskyConvoDefs.isMessageView(replyTo) ? (
|
||||||
|
replyTo.text
|
||||||
|
) : (
|
||||||
|
<Text style={[a.text_sm, a.italic, {color: subtleColor}]}>
|
||||||
|
<Trans>Message deleted</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ type MessageDialogsContextType = {
|
|||||||
senderProfile: bsky.profile.AnyProfileView | undefined,
|
senderProfile: bsky.profile.AnyProfileView | undefined,
|
||||||
) => void
|
) => void
|
||||||
openReactions: (message: ChatBskyConvoDefs.MessageView) => void
|
openReactions: (message: ChatBskyConvoDefs.MessageView) => void
|
||||||
|
/**
|
||||||
|
* The message currently staged for reply in the composer, or null.
|
||||||
|
*/
|
||||||
|
replyTo: ChatBskyConvoDefs.MessageView | null
|
||||||
|
openReply: (message: ChatBskyConvoDefs.MessageView) => void
|
||||||
|
clearReply: () => void
|
||||||
|
/**
|
||||||
|
* Scroll the list to a message, if it's currently loaded. No-op otherwise.
|
||||||
|
*/
|
||||||
|
scrollToMessage: (messageId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Context = createContext<MessageDialogsContextType | null>(null)
|
const Context = createContext<MessageDialogsContextType | null>(null)
|
||||||
@@ -41,7 +51,13 @@ export function useMessageDialogs() {
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageOverlays({children}: {children: React.ReactNode}) {
|
export function MessageOverlays({
|
||||||
|
children,
|
||||||
|
scrollToMessage,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
scrollToMessage: (messageId: string) => void
|
||||||
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const convo = useConvoActive()
|
const convo = useConvoActive()
|
||||||
@@ -61,6 +77,9 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
|
|||||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||||
const [reactionsTarget, setReactionsTarget] =
|
const [reactionsTarget, setReactionsTarget] =
|
||||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||||
|
const [replyTo, setReplyTo] = useState<ChatBskyConvoDefs.MessageView | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
const openDeleteMessage = useCallback(
|
const openDeleteMessage = useCallback(
|
||||||
(message: ChatBskyConvoDefs.MessageView) => {
|
(message: ChatBskyConvoDefs.MessageView) => {
|
||||||
@@ -88,6 +107,14 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const openReply = useCallback((message: ChatBskyConvoDefs.MessageView) => {
|
||||||
|
setReplyTo(message)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearReply = useCallback(() => {
|
||||||
|
setReplyTo(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// These dialogs are conditionally mounted, so we can't open them in the same
|
// These dialogs are conditionally mounted, so we can't open them in the same
|
||||||
// tick that we set their targets - the control refs aren't attached yet. Open
|
// tick that we set their targets - the control refs aren't attached yet. Open
|
||||||
// in an effect after the dialog has mounted.
|
// in an effect after the dialog has mounted.
|
||||||
@@ -121,8 +148,24 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
|
|||||||
}, [queryClient, reportTarget])
|
}, [queryClient, reportTarget])
|
||||||
|
|
||||||
const ctx = useMemo<MessageDialogsContextType>(
|
const ctx = useMemo<MessageDialogsContextType>(
|
||||||
() => ({openDeleteMessage, openReportMessage, openReactions}),
|
() => ({
|
||||||
[openDeleteMessage, openReportMessage, openReactions],
|
openDeleteMessage,
|
||||||
|
openReportMessage,
|
||||||
|
openReactions,
|
||||||
|
replyTo,
|
||||||
|
openReply,
|
||||||
|
clearReply,
|
||||||
|
scrollToMessage,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
openDeleteMessage,
|
||||||
|
openReportMessage,
|
||||||
|
openReactions,
|
||||||
|
replyTo,
|
||||||
|
openReply,
|
||||||
|
clearReply,
|
||||||
|
scrollToMessage,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
// `reactionsTarget` is a snapshot from when the dialog was opened. Read the
|
// `reactionsTarget` is a snapshot from when the dialog was opened. Read the
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {LayoutAnimation, View} from 'react-native'
|
||||||
|
import {Trans, 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 {useSession} from '#/state/session'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||||
|
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 {currentAccount} = useSession()
|
||||||
|
const convo = useConvoActive()
|
||||||
|
const {replyTo, clearReply} = useMessageDialogs()
|
||||||
|
|
||||||
|
if (!replyTo) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRemove = () => {
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
|
clearReply()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFromSelf = replyTo.sender.did === currentAccount?.did
|
||||||
|
const senderProfile = convo.relatedProfiles.get(replyTo.sender.did)
|
||||||
|
const displayName = senderProfile
|
||||||
|
? createSanitizedDisplayName(senderProfile)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_sm,
|
||||||
|
a.align_center,
|
||||||
|
t.atoms.border_contrast_high,
|
||||||
|
a.rounded_md,
|
||||||
|
a.border,
|
||||||
|
a.p_sm,
|
||||||
|
a.mt_sm,
|
||||||
|
a.mx_sm,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}
|
||||||
|
emoji
|
||||||
|
numberOfLines={1}>
|
||||||
|
{isFromSelf ? (
|
||||||
|
<Trans>Replying to yourself</Trans>
|
||||||
|
) : displayName ? (
|
||||||
|
<Trans>Replying to {displayName}</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Replying to message</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.text_sm, t.atoms.text_contrast_high, a.mt_2xs]}
|
||||||
|
emoji
|
||||||
|
numberOfLines={1}>
|
||||||
|
{replyTo.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'
|
||||||
@@ -61,7 +62,10 @@ import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
|||||||
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
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,
|
||||||
|
useMessageDialogs,
|
||||||
|
} from '#/components/dms/MessageOverlays'
|
||||||
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 +76,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 +380,7 @@ export function MessagesList({
|
|||||||
|
|
||||||
// -- Message sending
|
// -- Message sending
|
||||||
const onSendMessage = useCallback(
|
const onSendMessage = useCallback(
|
||||||
async (text: string) => {
|
async (text: string, replyTo?: 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
|
||||||
@@ -469,10 +478,20 @@ export function MessagesList({
|
|||||||
text: rt.text,
|
text: rt.text,
|
||||||
facets: rt.facets,
|
facets: rt.facets,
|
||||||
embed,
|
embed,
|
||||||
|
replyTo: replyTo ? {messageId: replyTo.id} : undefined,
|
||||||
},
|
},
|
||||||
embedView,
|
embedView,
|
||||||
|
replyTo
|
||||||
|
? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'}
|
||||||
|
: undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 +529,27 @@ 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.
|
||||||
|
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
|
||||||
|
|
||||||
|
ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id})
|
||||||
|
flatListRef.current?.scrollToIndex({
|
||||||
|
index,
|
||||||
|
viewPosition: 0.3,
|
||||||
|
animated: 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,7 +601,7 @@ export function MessagesList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InviteLinkDialogProvider convo={convoState.convo}>
|
<InviteLinkDialogProvider convo={convoState.convo}>
|
||||||
<MessageOverlays>
|
<MessageOverlays scrollToMessage={scrollToMessage}>
|
||||||
<KeyboardGestureArea
|
<KeyboardGestureArea
|
||||||
interpolator="ios"
|
interpolator="ios"
|
||||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||||
@@ -648,37 +688,18 @@ export function MessagesList({
|
|||||||
<ConversationFooter
|
<ConversationFooter
|
||||||
convoState={convoState}
|
convoState={convoState}
|
||||||
hasAcceptOverride={hasAcceptOverride}>
|
hasAcceptOverride={hasAcceptOverride}>
|
||||||
{({loading}) =>
|
{({loading}) => (
|
||||||
ax.features.enabled(
|
<Composer
|
||||||
ax.features.DmsNewMessageComposerEnable,
|
textInputId={textInputId}
|
||||||
) ? (
|
onSendMessage={onSendMessage}
|
||||||
<MessageComposer
|
messageEmbed={messageEmbed}
|
||||||
textInputId={textInputId}
|
setEmbed={setEmbed}
|
||||||
onSendMessage={(message: string) =>
|
loading={loading}
|
||||||
void onSendMessage(message)
|
useNewComposer={ax.features.enabled(
|
||||||
}
|
ax.features.DmsNewMessageComposerEnable,
|
||||||
hasEmbed={!!messageEmbed}
|
)}
|
||||||
setEmbed={setEmbed}
|
/>
|
||||||
loading={loading}>
|
)}
|
||||||
<MessageInputEmbed
|
|
||||||
embed={messageEmbed}
|
|
||||||
setEmbed={setEmbed}
|
|
||||||
/>
|
|
||||||
</MessageComposer>
|
|
||||||
) : (
|
|
||||||
<MessageInput
|
|
||||||
textInputId={textInputId}
|
|
||||||
onSendMessage={onSendMessage}
|
|
||||||
hasEmbed={!!messageEmbed}
|
|
||||||
setEmbed={setEmbed}
|
|
||||||
loading={loading}>
|
|
||||||
<MessageInputEmbed
|
|
||||||
embed={messageEmbed}
|
|
||||||
setEmbed={setEmbed}
|
|
||||||
/>
|
|
||||||
</MessageInput>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</ConversationFooter>
|
</ConversationFooter>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
@@ -693,6 +714,65 @@ export function MessagesList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges the composer to reply state. It's rendered inside `MessageOverlays`
|
||||||
|
* so it can read the staged reply target via `useMessageDialogs`, inject it
|
||||||
|
* into the send call, then clear it. The reply preview is mounted alongside the
|
||||||
|
* existing embed preview in the composer's children slot.
|
||||||
|
*/
|
||||||
|
function Composer({
|
||||||
|
textInputId,
|
||||||
|
onSendMessage,
|
||||||
|
messageEmbed,
|
||||||
|
setEmbed,
|
||||||
|
loading,
|
||||||
|
useNewComposer,
|
||||||
|
}: {
|
||||||
|
textInputId: string
|
||||||
|
onSendMessage: (
|
||||||
|
message: string,
|
||||||
|
replyTo?: ChatBskyConvoDefs.MessageView,
|
||||||
|
) => Promise<void>
|
||||||
|
messageEmbed: MessageEmbedState | undefined
|
||||||
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
|
loading?: boolean
|
||||||
|
useNewComposer: boolean
|
||||||
|
}) {
|
||||||
|
const {replyTo, clearReply} = useMessageDialogs()
|
||||||
|
|
||||||
|
const handleSendMessage = useNonReactiveCallback((message: string) => {
|
||||||
|
void onSendMessage(message, replyTo ?? undefined)
|
||||||
|
clearReply()
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
@@ -119,6 +119,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()
|
||||||
@@ -999,6 +1000,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
|
||||||
@@ -1012,6 +1014,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({
|
||||||
@@ -1363,6 +1366,7 @@ export class Convo {
|
|||||||
message: {
|
message: {
|
||||||
...m.message,
|
...m.message,
|
||||||
embed: m.optimisticEmbedView,
|
embed: m.optimisticEmbedView,
|
||||||
|
replyTo: m.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
|
||||||
|
|||||||
Reference in New Issue
Block a user