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 && }
-
+ >
)
}