diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index eec5c3c207..c5b9ff28be 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -586,6 +586,14 @@ export type Events = { | 'SendViaChatDialog' | 'ConvoSettings' } + // Message replies + 'chat:message:reply:send': { + convoId: string + isGroup: boolean + } + 'chat:message:reply:tap': { + convoId: string + } // Group chat adoption 'groupchat:create': { diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index ddfa5118c0..5b60bd1cb7 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -23,6 +23,7 @@ import {useMessageDialogs} from '#/components/dms/MessageOverlays' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language' +import {Reply as ReplyIcon} from '#/components/icons/Reply' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' @@ -46,7 +47,7 @@ export let MessageContextMenu = ({ const ax = useAnalytics() const {currentAccount} = useSession() const convo = useConvoActive() - const {openDeleteMessage, openReportMessage} = useMessageDialogs() + const {openDeleteMessage, openReportMessage, openReply} = useMessageDialogs() const langPrefs = useLanguagePrefs() const translate = useGoogleTranslate() @@ -151,6 +152,13 @@ export let MessageContextMenu = ({ timeStyle: 'short', })}`} style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> + openReply(message)}> + + {l`Reply`} + {message.text.length > 0 && ( <> ) : null} + {replyTo ? ( + + ) : null} {profile && isBlockedOrBlocking(profile) && isGroupChat ? ( ) : ( @@ -458,6 +476,14 @@ let MessageItem = ({ borderRadiusStyle, ], ]}> + {replyTo && !isOnlyEmoji(message.text) ? ( + scrollToMessage(replyTo.id)} + /> + ) : null} ) } + +/** + * 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 +}) { + 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 ( + + + + {isFromSelf ? ( + originalSenderIsSelf ? ( + You replied to yourself + ) : originalName ? ( + You replied to {originalName} + ) : ( + You replied + ) + ) : originalSenderIsSelf ? ( + {replierDisplayName} replied to you + ) : originalName ? ( + + {replierDisplayName} replied to {originalName} + + ) : ( + {replierDisplayName} replied + )} + + + ) +} + +/** + * The nested quote of the original message, rendered at the top of a reply + * bubble. Tapping it scrolls to the original (if loaded). + */ +function ReplyQuote({ + replyTo, + isFromSelf, + relatedProfiles, + onPress, +}: { + replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + isFromSelf: boolean + relatedProfiles: Map + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + const 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 ( + + ) +} diff --git a/src/components/dms/MessageOverlays.tsx b/src/components/dms/MessageOverlays.tsx index 2384aad88d..43f8983d24 100644 --- a/src/components/dms/MessageOverlays.tsx +++ b/src/components/dms/MessageOverlays.tsx @@ -29,6 +29,16 @@ type MessageDialogsContextType = { senderProfile: bsky.profile.AnyProfileView | undefined, ) => 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(null) @@ -41,7 +51,13 @@ export function useMessageDialogs() { 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 queryClient = useQueryClient() const convo = useConvoActive() @@ -61,6 +77,9 @@ export function MessageOverlays({children}: {children: React.ReactNode}) { useState(null) const [reactionsTarget, setReactionsTarget] = useState(null) + const [replyTo, setReplyTo] = useState( + null, + ) const openDeleteMessage = useCallback( (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 // tick that we set their targets - the control refs aren't attached yet. Open // in an effect after the dialog has mounted. @@ -121,8 +148,24 @@ export function MessageOverlays({children}: {children: React.ReactNode}) { }, [queryClient, reportTarget]) const ctx = useMemo( - () => ({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 diff --git a/src/screens/Messages/components/MessageInputReply.tsx b/src/screens/Messages/components/MessageInputReply.tsx new file mode 100644 index 0000000000..0567695ffe --- /dev/null +++ b/src/screens/Messages/components/MessageInputReply.tsx @@ -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 ( + + + + {isFromSelf ? ( + Replying to yourself + ) : displayName ? ( + Replying to {displayName} + ) : ( + Replying to message + )} + + + {replyTo.text} + + + + + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index f0b1389b86..f274f5521d 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -35,6 +35,7 @@ import { } from '@atproto/api' import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {mergeRefs} from '#/lib/merge-refs' import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' @@ -61,7 +62,10 @@ import {MessageListError} from '#/screens/Messages/components/MessageListError' import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {DateDivider} from '#/components/dms/DateDivider' import {MessageItem} from '#/components/dms/MessageItem' -import {MessageOverlays} from '#/components/dms/MessageOverlays' +import { + MessageOverlays, + useMessageDialogs, +} from '#/components/dms/MessageOverlays' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup' import {SystemMessageItem} from '#/components/dms/SystemMessageItem' @@ -72,7 +76,12 @@ import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' import {ChatStatusInfo} from './ChatStatusInfo' import {groupSystemMessages, type RenderItem} from './groupSystemMessages' import {InviteLinkDialogProvider} from './InviteLinkDialogProvider' -import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' +import { + type MessageEmbedState, + MessageInputEmbed, + useMessageEmbed, +} from './MessageInputEmbed' +import {MessageInputReply} from './MessageInputReply' import {MessagesListGroupInfoPanel} from './MessagesListGroupInfoPanel' import {MessagesListInfoPanel} from './MessagesListInfoPanel' import {KeyboardStickyView} from './vendor/KeyboardStickyView' @@ -371,7 +380,7 @@ export function MessagesList({ // -- Message sending const onSendMessage = useCallback( - async (text: string) => { + async (text: string, replyTo?: ChatBskyConvoDefs.MessageView) => { let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true}) // detect facets without resolution first - this is used to see if there's @@ -469,10 +478,20 @@ export function MessagesList({ text: rt.text, facets: rt.facets, embed, + replyTo: replyTo ? {messageId: replyTo.id} : undefined, }, 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') { ax.metric('groupchat:message:send', { convoId: convoState.convo.view.id, @@ -510,6 +529,27 @@ export function MessagesList({ }) }, [flatListRef]) + // Scroll to a message by id, if it's currently loaded in the list. Per the + // feature scope, we don't fetch history to find unloaded messages - tapping a + // reply to an out-of-window message is a no-op. + 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}) => { if (item.type === 'message' || item.type === 'pending-message') { return ( @@ -561,7 +601,7 @@ export function MessagesList({ return ( - + - {({loading}) => - ax.features.enabled( - ax.features.DmsNewMessageComposerEnable, - ) ? ( - - void onSendMessage(message) - } - hasEmbed={!!messageEmbed} - setEmbed={setEmbed} - loading={loading}> - - - ) : ( - - - - ) - } + {({loading}) => ( + + )} )} @@ -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 + 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 = ( + <> + + + + ) + + return useNewComposer ? ( + + {previews} + + ) : ( + + {previews} + + ) +} + /** Note: native only */ function ChatScrollComponent({ ref, diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index f294216b1d..ff25af1f43 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -119,6 +119,7 @@ export class Convo { optimisticEmbedView?: | $Typed | $Typed + optimisticReplyTo?: $Typed } > = new Map() private deletedMessages: Set = new Set() @@ -999,6 +1000,7 @@ export class Convo { optimisticEmbedView?: | $Typed | $Typed, + optimisticReplyTo?: $Typed, ) { // Ignore empty messages for now since they have no other purpose atm if (!message.text.trim() && !message.embed) return @@ -1012,6 +1014,7 @@ export class Convo { id: tempId, message, optimisticEmbedView, + optimisticReplyTo, }) if (this.convo?.view.status === 'request') { this.updateConvo({ @@ -1363,6 +1366,7 @@ export class Convo { message: { ...m.message, embed: m.optimisticEmbedView, + replyTo: m.optimisticReplyTo, $type: 'chat.bsky.convo.defs#messageView', id: nanoid(), rev: '__fake__', diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index c83fbfb813..e330d6eea3 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -113,6 +113,7 @@ type SendMessage = ( | $Typed | $Typed | undefined, + optimisticReplyTo?: $Typed, ) => void type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void