From 1b4c72f5166aec88df7ec8513a02c6e934d86327 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:28:11 -0700 Subject: [PATCH] Mount message dialogs once at the list level (#10435) Co-authored-by: Samuel Newman --- src/components/Prompt.tsx | 4 +- src/components/dms/AfterReportDialog.tsx | 7 +- src/components/dms/MessageContextMenu.tsx | 176 +++++-------- src/components/dms/MessageItem.tsx | 47 +--- src/components/dms/MessageOverlays.tsx | 175 +++++++++++++ src/components/dms/ReactionsDialog.tsx | 38 ++- .../moderation/ReportDialog/index.tsx | 4 +- .../moderation/ReportDialog/types.ts | 4 + .../Messages/components/MessagesList.tsx | 243 +++++++++--------- 9 files changed, 420 insertions(+), 278 deletions(-) create mode 100644 src/components/dms/MessageOverlays.tsx diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 916a7ec2d9..5fdf18e2a1 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -224,6 +224,7 @@ export function Basic({ cancelButtonCta, confirmButtonCta, onConfirm, + onClose, confirmButtonColor, showCancel = true, }: React.PropsWithChildren<{ @@ -240,11 +241,12 @@ export function Basic({ * should NOT close the dialog as a side effect of this method. */ onConfirm: (e: GestureResponderEvent) => void + onClose?: () => void confirmButtonColor?: ButtonColor showCancel?: boolean }>) { return ( - + {title} {description && {description}} diff --git a/src/components/dms/AfterReportDialog.tsx b/src/components/dms/AfterReportDialog.tsx index 3df194fc8b..060ea1fcfb 100644 --- a/src/components/dms/AfterReportDialog.tsx +++ b/src/components/dms/AfterReportDialog.tsx @@ -33,14 +33,19 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({ control, params, currentScreen, + onClose, }: { control: Dialog.DialogControlProps params: ReportDialogParams currentScreen: 'list' | 'conversation' + onClose?: () => void }): React.ReactNode { const {t: l} = useLingui() return ( - + { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - convo - .deleteMessage(message.id) - .then(() => Toast.show(l({message: 'Message deleted', context: 'toast'}))) - .catch(() => Toast.show(l`Failed to delete message`)) - }, [l, convo, message.id]) - const onEmojiSelect = useCallback( (emoji: string) => { if ( @@ -128,104 +112,72 @@ export let MessageContextMenu = ({ const sender = senderProfile return ( - <> - - {IS_NATIVE && reactionsAvailable && ( - - - - )} - - - {children} - - - + {IS_NATIVE && reactionsAvailable && ( + - {message.text.length > 0 && ( - <> - - - {l`Translate`} - - - - - {l`Copy message text`} - - - - )} + + + )} + + + {children} + + + + {message.text.length > 0 && ( + <> + + + {l`Translate`} + + + + + {l`Copy message text`} + + + + )} + openDeleteMessage(message)}> + + {l`Delete for me`} + + {!isFromSelf && ( deleteControl.open()}> - - {l`Delete for me`} + testID="messageDropdownReportBtn" + label={l`Report message`} + onPress={() => openReportMessage(message, senderProfile)}> + + {l`Report`} - {!isFromSelf && ( - reportControl.open()}> - - {l`Report`} - - )} - - - { - if (sender) { - unstableCacheProfileView(queryClient, sender) - } - blockOrDeleteControl.open() - }} - /> - - - + )} + + ) } MessageContextMenu = memo(MessageContextMenu) diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 6f1819ada2..ac6acb5891 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -40,8 +40,8 @@ import {useSession} from '#/state/session' import {atoms as a, native, platform, useTheme} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' import {Button} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' +import {useMessageDialogs} from '#/components/dms/MessageOverlays' import {InlineLinkText, Link} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import * as Prompt from '#/components/Prompt' @@ -49,7 +49,7 @@ import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' -import {ReactionsDialog} from './ReactionsDialog' +import {groupReactions} from './ReactionsDialog' import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util' const AVATAR_SIZE = 28 @@ -118,7 +118,7 @@ let MessageItem = ({ const {message} = item const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did)) - const reactionsControl = useDialogControl() + const {openReactions} = useMessageDialogs() const isPending = item.type === 'pending-message' @@ -243,34 +243,10 @@ let MessageItem = ({ ) - const groupedReactions = useMemo(() => { - const reactions = message.reactions ?? [] - const grouped = new Map< - string, - { - key: string - value: string - senders: ChatBskyConvoDefs.ReactionViewSender[] - count: number - } - >() - for (const reaction of reactions) { - if (!reaction) continue - const existing = grouped.get(reaction.value) - if (existing) { - existing.senders.push(reaction.sender) - existing.count++ - } else { - grouped.set(reaction.value, { - key: reaction.value, - value: reaction.value, - senders: [reaction.sender], - count: 1, - }) - } - } - return Array.from(grouped.values()) - }, [message.reactions]) + const groupedReactions = useMemo( + () => groupReactions(message.reactions), + [message.reactions], + ) const reactions = useMemo(() => message.reactions ?? [], [message.reactions]) @@ -336,7 +312,7 @@ let MessageItem = ({ transform: [{translateY: -8}], }, ]} - onPress={isGroupChat ? reactionsControl.open : undefined}> + onPress={isGroupChat ? () => openReactions(message) : undefined}> {groupedReactions.map(group => ( ) : null} - ) diff --git a/src/components/dms/MessageOverlays.tsx b/src/components/dms/MessageOverlays.tsx new file mode 100644 index 0000000000..41228f2899 --- /dev/null +++ b/src/components/dms/MessageOverlays.tsx @@ -0,0 +1,175 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import {LayoutAnimation} from 'react-native' +import {type ChatBskyConvoDefs} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' +import {useQueryClient} from '@tanstack/react-query' + +import {useConvoActive} from '#/state/messages/convo' +import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' +import {useDialogControl} from '#/components/Dialog' +import {AfterReportDialog} from '#/components/dms/AfterReportDialog' +import {ReactionsDialog} from '#/components/dms/ReactionsDialog' +import {ReportDialog} from '#/components/moderation/ReportDialog' +import * as Prompt from '#/components/Prompt' +import {usePromptControl} from '#/components/Prompt' +import * as Toast from '#/components/Toast' +import type * as bsky from '#/types/bsky' + +type MessageDialogsContextType = { + openDeleteMessage: (message: ChatBskyConvoDefs.MessageView) => void + openReportMessage: ( + message: ChatBskyConvoDefs.MessageView, + senderProfile: bsky.profile.AnyProfileView | undefined, + ) => void + openReactions: (message: ChatBskyConvoDefs.MessageView) => void +} + +const Context = createContext(null) + +export function useMessageDialogs() { + const ctx = useContext(Context) + if (!ctx) { + throw new Error('useMessageDialogs must be used within a MessageOverlays') + } + return ctx +} + +export function MessageOverlays({children}: {children: React.ReactNode}) { + const {t: l} = useLingui() + const queryClient = useQueryClient() + const convo = useConvoActive() + + const deleteControl = usePromptControl() + const reportControl = usePromptControl() + const afterReportControl = usePromptControl() + const reactionsControl = useDialogControl() + + const [deleteTarget, setDeleteTarget] = + useState(null) + const [reportTarget, setReportTarget] = useState<{ + message: ChatBskyConvoDefs.MessageView + senderProfile: bsky.profile.AnyProfileView | undefined + } | null>(null) + const [afterReportTarget, setAfterReportTarget] = + useState(null) + const [reactionsTarget, setReactionsTarget] = + useState(null) + + const openDeleteMessage = useCallback( + (message: ChatBskyConvoDefs.MessageView) => { + setDeleteTarget(message) + deleteControl.open() + }, + [deleteControl], + ) + + const openReportMessage = useCallback( + ( + message: ChatBskyConvoDefs.MessageView, + senderProfile: bsky.profile.AnyProfileView | undefined, + ) => { + setReportTarget({message, senderProfile}) + reportControl.open() + }, + [reportControl], + ) + + const openReactions = useCallback( + (message: ChatBskyConvoDefs.MessageView) => { + setReactionsTarget(message) + }, + [], + ) + + // 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. + useEffect(() => { + if (reactionsTarget) { + reactionsControl.open() + } + }, [reactionsTarget, reactionsControl]) + + useEffect(() => { + if (afterReportTarget) { + afterReportControl.open() + } + }, [afterReportTarget, afterReportControl]) + + const onConfirmDelete = useCallback(() => { + if (!deleteTarget) return + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + convo + .deleteMessage(deleteTarget.id) + .then(() => Toast.show(l({message: 'Message deleted', context: 'toast'}))) + .catch(() => Toast.show(l`Failed to delete message`)) + }, [l, convo, deleteTarget]) + + const onAfterReportSubmit = useCallback(() => { + if (!reportTarget) return + if (reportTarget.senderProfile) { + unstableCacheProfileView(queryClient, reportTarget.senderProfile) + } + setAfterReportTarget(reportTarget.message) + }, [queryClient, reportTarget]) + + const ctx = useMemo( + () => ({openDeleteMessage, openReportMessage, openReactions}), + [openDeleteMessage, openReportMessage, openReactions], + ) + + const reportSubject = reportTarget + ? ({ + view: 'message', + convoId: convo.convo.view.id, + message: reportTarget.message, + } as const) + : undefined + + return ( + + {children} + setReportTarget(null)} + /> + {afterReportTarget && ( + setAfterReportTarget(null)} + /> + )} + {reactionsTarget && ( + setReactionsTarget(null)} + /> + )} + setDeleteTarget(null)} + /> + + ) +} diff --git a/src/components/dms/ReactionsDialog.tsx b/src/components/dms/ReactionsDialog.tsx index 576ef4cead..8119cc1b7c 100644 --- a/src/components/dms/ReactionsDialog.tsx +++ b/src/components/dms/ReactionsDialog.tsx @@ -1,4 +1,4 @@ -import {useRef, useState} from 'react' +import {useMemo, useRef, useState} from 'react' import { LayoutAnimation, Pressable, @@ -37,14 +37,12 @@ export function ReactionsDialog({ control, relatedProfiles, message, - reactions, - groupedReactions, + onClose, }: { control: Dialog.DialogControlProps relatedProfiles: Map message: ChatBskyConvoDefs.MessageView - reactions?: ChatBskyConvoDefs.ReactionView[] - groupedReactions?: Reaction[] + onClose?: () => void }) { const {t: l} = useLingui() @@ -54,6 +52,9 @@ export function ReactionsDialog({ const [selected, setSelected] = useState('all') + const reactions = message.reactions + const groupedReactions = useMemo(() => groupReactions(reactions), [reactions]) + const filteredReactions = reactions?.filter( r => selected === 'all' || r.value === selected, ) @@ -78,7 +79,10 @@ export function ReactionsDialog({ return ( setSelected('all')} + onClose={() => { + setSelected('all') + onClose?.() + }} nativeOptions={{ preventExpansion: true, minHeight: screenHeight / 2, @@ -388,3 +392,25 @@ function ReactionTab({ ) } + +export function groupReactions( + reactions: ChatBskyConvoDefs.ReactionView[] | undefined, +): Reaction[] { + const grouped = new Map() + for (const reaction of reactions ?? []) { + if (!reaction) continue + const existing = grouped.get(reaction.value) + if (existing) { + existing.senders.push(reaction.sender) + existing.count++ + } else { + grouped.set(reaction.value, { + key: reaction.value, + value: reaction.value, + senders: [reaction.sender], + count: 1, + }) + } + } + return Array.from(grouped.values()) +} diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index c24c6feae0..d1d3aa5584 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -75,9 +75,11 @@ export function ReportDialog( () => (props.subject ? parseReportSubject(props.subject) : undefined), [props.subject], ) + const propsOnClose = props.onClose const onClose = useCallback(() => { ax.metric('reportDialog:close', {}) - }, [ax]) + propsOnClose?.() + }, [ax, propsOnClose]) return ( diff --git a/src/components/moderation/ReportDialog/types.ts b/src/components/moderation/ReportDialog/types.ts index 89089d138b..57ed3cd279 100644 --- a/src/components/moderation/ReportDialog/types.ts +++ b/src/components/moderation/ReportDialog/types.ts @@ -88,4 +88,8 @@ export type ReportDialogProps = { * Called if the report was successfully submitted. */ onAfterSubmit?: () => void + /** + * Called after the dialog finishes closing. + */ + onClose?: () => void } diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 99b725267e..fbebdc0745 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -56,6 +56,7 @@ 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 {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup' import {SystemMessageItem} from '#/components/dms/SystemMessageItem' @@ -498,127 +499,133 @@ export function MessagesList({ return ( - - {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} - - - - - {convoState.hasAllHistory ? ( - convoState.convo?.kind === 'group' ? ( - - ) : ( - - ) - ) : null} - - } - // native only (prop is not supported on web) - renderScrollComponent={renderScrollComponent} - contentContainerStyle={{ - paddingBottom: platform({ - // ios is slightly larger as the input has no top padding - ios: tokens.space.lg, - android: tokens.space.md, - web: 0, // web uses ListFooterComponent instead for scroll reasons - }), - }} - ListFooterComponent={ - - } - style={[ - web({ - scrollbarWidth: 'thin', - scrollbarColor: `${t.palette.contrast_100} transparent`, - scrollbarGutter: 'stable', - }), - ]} - pointerEvents={!hasScrolled ? 'none' : 'auto'} - contentInset={{top: transparentHeaderHeight}} - scrollIndicatorInsets={{top: transparentHeaderHeight}} - /> - - - - {footer ?? ( - - {({loading}) => - ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( - - void onSendMessage(message) - } - hasEmbed={!!embedUri} - setEmbed={setEmbed} - loading={loading}> - + + {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} + + + + + {convoState.hasAllHistory ? ( + convoState.convo?.kind === 'group' ? ( + + ) : ( + + ) + ) : null} + + } + // native only (prop is not supported on web) + renderScrollComponent={renderScrollComponent} + contentContainerStyle={{ + paddingBottom: platform({ + // ios is slightly larger as the input has no top padding + ios: tokens.space.lg, + android: tokens.space.md, + web: 0, // web uses ListFooterComponent instead for scroll reasons + }), + }} + ListFooterComponent={ + + } + style={[ + web({ + scrollbarWidth: 'thin', + scrollbarColor: `${t.palette.contrast_100} transparent`, + scrollbarGutter: 'stable', + }), + ]} + pointerEvents={!hasScrolled ? 'none' : 'auto'} + contentInset={{top: transparentHeaderHeight}} + scrollIndicatorInsets={{top: transparentHeaderHeight}} + /> + + + + {footer ?? ( + + {({loading}) => + ax.features.enabled( + ax.features.DmsNewMessageComposerEnable, + ) ? ( + + void onSendMessage(message) + } + hasEmbed={!!embedUri} setEmbed={setEmbed} - /> - - ) : ( - - + + + ) : ( + - - ) - } - - )} - - + loading={loading}> + + + ) + } + + )} + + - {newMessagesPill.show && } + {newMessagesPill.show && ( + + )} + ) }