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