From 9a2cabf64ccba6ff1e7c7715e6cdc4a38107bee3 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 7 May 2026 13:24:55 -0700 Subject: [PATCH] Update context menu presentation (#10426) --- .../language_stroke2_corner2_rounded.svg | 1 + src/components/ContextMenu/index.tsx | 44 +++++++++------- src/components/ContextMenu/index.web.tsx | 52 ++++++++++++++++++- src/components/dms/ActionsWrapper.tsx | 7 +-- src/components/dms/ActionsWrapper.web.tsx | 9 +--- src/components/dms/DateDividerToggle.tsx | 44 ---------------- src/components/dms/EmojiReactionPicker.tsx | 20 ++----- .../dms/EmojiReactionPicker.web.tsx | 7 +-- src/components/dms/MessageContextMenu.tsx | 23 ++++---- src/components/dms/MessageItem.tsx | 51 ++++-------------- src/components/icons/Language.tsx | 5 ++ .../Messages/components/MessagesList.tsx | 5 +- 12 files changed, 115 insertions(+), 153 deletions(-) create mode 100644 assets/icons/language_stroke2_corner2_rounded.svg delete mode 100644 src/components/dms/DateDividerToggle.tsx create mode 100644 src/components/icons/Language.tsx diff --git a/assets/icons/language_stroke2_corner2_rounded.svg b/assets/icons/language_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..9eac8d1e0d --- /dev/null +++ b/assets/icons/language_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 8450c47bbe..6aa7c6e1f6 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -378,7 +378,10 @@ export function Trigger({ {children({ IS_NATIVE: true, - control: {isOpen: context.isOpen, open}, + control: { + isOpen: context.isOpen, + open: mode => void open(mode), + }, state: { pressed: false, hovered: false, @@ -594,10 +597,12 @@ const MENU_WIDTH = 240 export function Outer({ children, + label, style, align = 'left', }: { children: React.ReactNode + label?: string style?: StyleProp align?: 'left' | 'right' }) { @@ -709,23 +714,25 @@ export function Outer({ ]}> {/* innermost element - needs an overflow: hidden for children, but we also need a shadow, so put the shadow on the scaling element and the overflow on the innermost element */} - + + {label ? ( + + {label} + + ) : null} {flattenReactChildren(children).map((child, i) => { return isValidElement(child) && (child.type === Item || child.type === Divider) ? ( - {i > 0 ? ( - - ) : null} {cloneElement(child, { // @ts-expect-error not typed style: { @@ -736,6 +743,7 @@ export function Outer({ ) : null })} + {label ? : null} @@ -840,13 +848,10 @@ export function Item({ !unstyled && [ a.flex_row, a.align_center, - a.gap_sm, - a.px_md, + a.px_2xl, a.rounded_md, - a.border, t.atoms.bg_contrast_25, - t.atoms.border_contrast_low, - {minHeight: 44, paddingVertical: 10}, + {gap: 6, minHeight: 44, paddingVertical: 10}, (focused || pressed || context.hoveredMenuItem === id) && !rest.disabled && t.atoms.bg_contrast_50, @@ -877,7 +882,6 @@ export function ItemText({children, style}: ItemTextProps) { a.text_md, a.font_semi_bold, t.atoms.text_contrast_high, - {paddingTop: 3}, style, disabled && t.atoms.text_contrast_low, ]}> diff --git a/src/components/ContextMenu/index.web.tsx b/src/components/ContextMenu/index.web.tsx index 41fa62474b..9554e7e1ae 100644 --- a/src/components/ContextMenu/index.web.tsx +++ b/src/components/ContextMenu/index.web.tsx @@ -1,6 +1,25 @@ +import {type StyleProp, type ViewStyle} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import * as Menu from '#/components/Menu' +import {Text} from '#/components/Typography' import {type AuxiliaryViewProps} from './types' -export * from '#/components/Menu' +export { + ContainerItem, + type MenuControlProps as ContextMenuControlProps, + Divider, + Group, + Item, + ItemIcon, + ItemRadio, + ItemText, + LabelText, + Root, + Trigger, + useMenuContext as useContextMenuContext, + useMenuControl as useContextMenuControl, +} from '#/components/Menu' export function Provider({children}: {children: React.ReactNode}) { return children @@ -10,3 +29,34 @@ export function Provider({children}: {children: React.ReactNode}) { export function AuxiliaryView({}: AuxiliaryViewProps) { return null } + +export function Outer({ + children, + label, + style, +}: { + children: React.ReactNode + label?: string + style?: StyleProp +}) { + const t = useTheme() + return ( + + {label ? ( + + {label} + + ) : null} + {children} + + ) +} diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index bc0505cda5..89c4a06520 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -11,22 +11,17 @@ export function ActionsWrapper({ isFromSelf, senderProfile, children, - onTap, }: { message: ChatBskyConvoDefs.MessageView hasReactions?: boolean isFromSelf: boolean senderProfile?: bsky.profile.AnyProfileView children: React.ReactNode - onTap?: () => void }) { const {t: l} = useLingui() return ( - + {trigger => // will always be true, since this file is platform split trigger.IS_NATIVE && ( diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index 12ccea03ab..0d3df37637 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -20,14 +20,12 @@ export function ActionsWrapper({ isFromSelf, senderProfile, children, - onTap, }: { message: ChatBskyConvoDefs.MessageView hasReactions?: boolean isFromSelf: boolean senderProfile?: bsky.profile.AnyProfileView children: React.ReactNode - onTap?: () => void }) { const viewRef = useRef(null) const t = useTheme() @@ -140,13 +138,10 @@ export function ActionsWrapper({ }} - {children} - + ) } diff --git a/src/components/dms/DateDividerToggle.tsx b/src/components/dms/DateDividerToggle.tsx deleted file mode 100644 index 97489f8401..0000000000 --- a/src/components/dms/DateDividerToggle.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import {createContext, useCallback, useContext, useState} from 'react' - -type DateDividerToggleContextType = { - isDividerToggled: (id: string) => boolean - toggleDivider: (id: string) => void -} - -const DateDividerToggleContext = createContext({ - isDividerToggled: () => false, - toggleDivider: () => {}, -}) - -export function DateDividerToggleProvider({ - children, -}: { - children: React.ReactNode -}) { - const [toggledIds, setToggledIds] = useState(new Set()) - - const toggleDivider = useCallback((id: string) => { - setToggledIds(prev => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - }, []) - - const isDividerToggled = useCallback( - (id: string) => toggledIds.has(id), - [toggledIds], - ) - - return ( - - {children} - - ) -} - -export function useDateDividerToggle() { - return useContext(DateDividerToggleContext) -} diff --git a/src/components/dms/EmojiReactionPicker.tsx b/src/components/dms/EmojiReactionPicker.tsx index 8049297661..de2b818b96 100644 --- a/src/components/dms/EmojiReactionPicker.tsx +++ b/src/components/dms/EmojiReactionPicker.tsx @@ -11,10 +11,7 @@ import { useContextMenuContext, useContextMenuMenuContext, } from '#/components/ContextMenu/context' -import { - EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyesIcon, - EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon, -} from '#/components/icons/Emoji' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {type TriggerProps} from '#/components/Menu/types' import {Text} from '#/components/Typography' import {EmojiPopup} from './EmojiPopup' @@ -37,11 +34,6 @@ export function EmojiReactionPicker({ const [layout, setLayout] = useState({width: 0, height: 0}) const {width: screenWidth} = useWindowDimensions() - // 1 in 100 chance of showing heart eyes icon - const EmojiIcon = useMemo(() => { - return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon - }, []) - const position = useMemo(() => { return { x: @@ -76,7 +68,7 @@ export function EmojiReactionPicker({ t.atoms.border_contrast_low, a.shadow_md, ]}> - {['👍', '😆', '❤️', '👀', '😢'].map(emoji => { + {['❤️', '👍', '😆', '👀', '😢'].map(emoji => { const alreadyReacted = hasAlreadyReacted( message, currentAccount?.did, @@ -123,16 +115,12 @@ export function EmojiReactionPicker({ - + diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx index 1a78a55458..f5cbe78e0e 100644 --- a/src/components/dms/EmojiReactionPicker.web.tsx +++ b/src/components/dms/EmojiReactionPicker.web.tsx @@ -7,7 +7,7 @@ import {DropdownMenu} from 'radix-ui' import {useSession} from '#/state/session' import {atoms as a, flatten, useTheme} from '#/alf' import * as EmojiPicker from '#/components/EmojiPicker' -import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import * as Menu from '#/components/Menu' import {Text} from '#/components/Typography' import {hasAlreadyReacted, hasReachedReactionLimit} from './util' @@ -70,7 +70,7 @@ function MenuInner({ ) : ( - {['👍', '😆', '❤️', '👀', '😢'].map(emoji => { + {['❤️', '👍', '😆', '👀', '😢'].map(emoji => { const alreadyReacted = hasAlreadyReacted( message, currentAccount?.did, @@ -118,10 +118,11 @@ function MenuInner({ style={flatten([ a.rounded_full, {height: 34, width: 34}, + t.atoms.bg_contrast_50, a.justify_center, a.align_center, ])}> - + diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index bc18eae21a..897ff277a6 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -15,10 +15,10 @@ import {atoms as a} from '#/alf' import * as ContextMenu from '#/components/ContextMenu' import {type TriggerProps} from '#/components/ContextMenu/types' import {AfterReportDialog} from '#/components/dms/AfterReportDialog' -import {BubbleQuestion_Stroke2_Corner0_Rounded as TranslateIcon} from '#/components/icons/Bubble' 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 {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' -import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {ReportDialog} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt' @@ -33,14 +33,12 @@ export let MessageContextMenu = ({ message, senderProfile, children, - onTap, }: { message: ChatBskyConvoDefs.MessageView senderProfile?: bsky.profile.AnyProfileView children: TriggerProps['children'] - onTap?: () => void }): React.ReactNode => { - const {t: l} = useLingui() + const {t: l, i18n} = useLingui() const ax = useAnalytics() const {currentAccount} = useSession() const queryClient = useQueryClient() @@ -133,13 +131,15 @@ export let MessageContextMenu = ({ label={l`Message options`} contentLabel={l`Message from @${ sender?.handle ?? 'unknown' // should always be defined - }: ${message.text}`} - onTap={onTap}> + }: ${message.text}`}> {children} {message.text.length > 0 && ( <> @@ -147,35 +147,34 @@ export let MessageContextMenu = ({ testID="messageDropdownTranslateBtn" label={l`Translate`} onPress={onPressTranslateMessage}> + {l`Translate`} - + {l`Copy message text`} - - )} deleteControl.open()}> + {l`Delete for me`} - {!isFromSelf && ( reportControl.open()}> + {l`Report`} - )} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 2db2d63a5b..6cbdf6a781 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -1,7 +1,6 @@ -import {memo, useEffect, useMemo, useRef} from 'react' +import {memo, useEffect, useMemo} from 'react' import { type GestureResponderEvent, - LayoutAnimation, Pressable, type StyleProp, type TextStyle, @@ -43,7 +42,6 @@ import * as ProfileCard from '#/components/ProfileCard' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {DateDivider} from './DateDivider' -import {useDateDividerToggle} from './DateDividerToggle' import {MessageItemEmbed} from './MessageItemEmbed' import {ReactionsDialog} from './ReactionsDialog' @@ -56,8 +54,6 @@ const DISPLAY_NAME_INSET = 22 const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 -const TAP_AND_DRAG_DELAY_MS = 100 - function isWithinClusterBoundary({ isPending, adjacentMessage, @@ -104,7 +100,6 @@ let MessageItem = ({ const profile = item.relatedProfiles.get(item.message.sender.did) const reactionsControl = useDialogControl() - const reactionTapRef = useRef(false) const {message, nextMessage, prevMessage} = item const isPending = item.type === 'pending-message' @@ -148,16 +143,9 @@ let MessageItem = ({ new Date(prevMessage.sentAt).getTime() > MESSAGE_GAP_THRESHOLD_MS - const {isDividerToggled, toggleDivider} = useDateDividerToggle() - const isDateDividerToggled = isDividerToggled(message.id) - const isNextDateDividerToggled = - nextMessage != null && isDividerToggled(nextMessage.id) - - const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled - const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled - const isInCluster = !(effectiveFirstInCluster && effectiveLastInCluster) + const isInCluster = !(isFirstInCluster && isLastInCluster) const isInMiddleOfCluster = - isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster + isInCluster && !isFirstInCluster && !isLastInCluster const hasReactions = message.reactions && message.reactions.length > 0 const prevHasReactions = @@ -168,12 +156,12 @@ let MessageItem = ({ !hasReactions && !isNextEmojiOnly && isInCluster && - (isInMiddleOfCluster || effectiveFirstInCluster) + (isInMiddleOfCluster || isFirstInCluster) const squaredTopCorner = !prevHasReactions && !isPrevEmojiOnly && isInCluster && - (isInMiddleOfCluster || effectiveLastInCluster) + (isInMiddleOfCluster || isLastInCluster) const pendingColor = t.palette.primary_300 @@ -336,16 +324,6 @@ let MessageItem = ({ transform: [{translateY: -8}], }, ]} - onPressIn={() => { - // Don't toggle the date divider when tapping a reaction. - reactionTapRef.current = true - }} - onPressOut={() => { - // Include a delay here to account for tap-and-drag before release. - setTimeout(() => { - reactionTapRef.current = false - }, TAP_AND_DRAG_DELAY_MS) - }} onPress={isGroupChat ? reactionsControl.open : undefined}> {groupedReactions.map(group => ( - {(hasLargeGapFromPrev || isDateDividerToggled) && ( + {hasLargeGapFromPrev && ( )} - + {showAvatar ? ( { - if (reactionTapRef.current) return - if (!hasLargeGapFromPrev) { - LayoutAnimation.configureNext( - LayoutAnimation.Presets.easeInEaseOut, - ) - toggleDivider(message.id) - } - }}> + senderProfile={profile}> {AppBskyEmbedRecord.isView(message.embed) && ( - {effectiveLastInCluster && ( + {isLastInCluster && ( + <> {newMessagesPill.show && } - + ) }