Update context menu presentation (#10426)

This commit is contained in:
DS Boyce
2026-05-07 13:24:55 -07:00
committed by GitHub
parent 10cb0dbf21
commit 9a2cabf64c
12 changed files with 115 additions and 153 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"><path fill="#000" fill-rule="evenodd" d="M14.5 8a1 1 0 0 1 .912.59l4.5 10a1 1 0 0 1-1.824.82L16.554 16h-4.108l-1.534 3.41a1 1 0 0 1-1.824-.82l4.5-10A1 1 0 0 1 14.5 8m-1.153 6h2.306L14.5 11.437zM5.053.106a1 1 0 0 1 1.342.447L7.118 2H11a1 1 0 0 1 0 2H9.92c-.28 1.765-1.27 3.24-2.393 4.398a17 17 0 0 0 2.605 1.562q.129.062.197.092l.005.002.049.022.01.005a1 1 0 0 1-.787 1.838h-.002l-.027-.013-.068-.03-.242-.113A19 19 0 0 1 6 9.75a19 19 0 0 1-3.509 2.125l-.068.03-.027.013h-.002a1 1 0 0 1-.788-1.838l.011-.005.049-.022.005-.002q.069-.03.197-.092a17 17 0 0 0 2.604-1.562C3.348 7.239 2.36 5.764 2.08 4H1a1 1 0 0 1 0-2h3.882l-.277-.553A1 1 0 0 1 5.053.106M4.118 4c.27 1.151.98 2.193 1.882 3.1.901-.907 1.612-1.95 1.882-3.1z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 819 B

+24 -20
View File
@@ -378,7 +378,10 @@ export function Trigger({
<View ref={ref} style={[{opacity: context.isOpen ? 0 : 1}, style]}>
{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<ViewStyle>
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 */}
<View
style={[
a.flex_1,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
{label ? (
<Text
numberOfLines={1}
style={[
a.pl_md,
a.pt_md,
a.pr_lg,
a.pb_md,
a.text_xs,
t.atoms.text_contrast_medium,
]}>
{label}
</Text>
) : null}
{flattenReactChildren(children).map((child, i) => {
return isValidElement(child) &&
(child.type === Item || child.type === Divider) ? (
<Fragment key={i}>
{i > 0 ? (
<View
style={[a.border_b, t.atoms.border_contrast_low]}
/>
) : null}
{cloneElement(child, {
// @ts-expect-error not typed
style: {
@@ -736,6 +743,7 @@ export function Outer({
</Fragment>
) : null
})}
{label ? <View style={[a.pb_md]} /> : null}
</View>
</Animated.View>
</Animated.View>
@@ -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,
]}>
+51 -1
View File
@@ -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<ViewStyle>
}) {
const t = useTheme()
return (
<Menu.Outer style={style}>
{label ? (
<Text
numberOfLines={1}
style={[
a.pl_sm,
a.pt_md,
a.pr_lg,
a.pb_md,
a.text_xs,
t.atoms.text_contrast_medium,
]}>
{label}
</Text>
) : null}
{children}
</Menu.Outer>
)
}
+1 -6
View File
@@ -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 (
<MessageContextMenu
message={message}
senderProfile={senderProfile}
onTap={onTap}>
<MessageContextMenu message={message} senderProfile={senderProfile}>
{trigger =>
// will always be true, since this file is platform split
trigger.IS_NATIVE && (
+2 -7
View File
@@ -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({
}}
</MessageContextMenu>
</View>
<Pressable
accessibilityRole="button"
accessibilityHint={l`Click to view the date and time`}
onPress={onTap}
<View
style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
{children}
</Pressable>
</View>
</View>
)
}
-44
View File
@@ -1,44 +0,0 @@
import {createContext, useCallback, useContext, useState} from 'react'
type DateDividerToggleContextType = {
isDividerToggled: (id: string) => boolean
toggleDivider: (id: string) => void
}
const DateDividerToggleContext = createContext<DateDividerToggleContextType>({
isDividerToggled: () => false,
toggleDivider: () => {},
})
export function DateDividerToggleProvider({
children,
}: {
children: React.ReactNode
}) {
const [toggledIds, setToggledIds] = useState(new Set<string>())
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 (
<DateDividerToggleContext.Provider
value={{isDividerToggled, toggleDivider}}>
{children}
</DateDividerToggleContext.Provider>
)
}
export function useDateDividerToggle() {
return useContext(DateDividerToggleContext)
}
+4 -16
View File
@@ -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({
<View
style={[
a.rounded_full,
t.scheme === 'light'
? t.atoms.bg_contrast_25
: t.atoms.bg_contrast_50,
t.atoms.bg_contrast_50,
{height: 40, width: 40},
a.justify_center,
a.align_center,
a.border,
t.atoms.border_contrast_low,
]}>
<EmojiIcon size="xl" fill={t.palette.contrast_400} />
<PlusIcon size="md" fill={t.palette.contrast_1000} />
</View>
</EmojiPopup>
</View>
@@ -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({
) : (
<Menu.Outer style={[a.rounded_full]}>
<View style={[a.flex_row, a.gap_xs]}>
{['👍', '😆', '❤️', '👀', '😢'].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,
])}>
<DotGridIcon size="lg" style={t.atoms.text_contrast_medium} />
<PlusIcon size="md" style={t.atoms.text_contrast_medium} />
</Pressable>
</DropdownMenu.Item>
</View>
+11 -12
View File
@@ -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}
</ContextMenu.Trigger>
<ContextMenu.Outer
align={isFromSelf ? 'right' : 'left'}
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && (
<>
@@ -147,35 +147,34 @@ export let MessageContextMenu = ({
testID="messageDropdownTranslateBtn"
label={l`Translate`}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={TranslateIcon} position="right" />
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={l`Copy message text`}
onPress={onCopyMessage}>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
<ContextMenu.ItemText>
{l`Copy message text`}
</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
</ContextMenu.Item>
<ContextMenu.Divider />
</>
)}
<ContextMenu.Item
testID="messageDropdownDeleteBtn"
label={l`Delete message for me`}
onPress={() => deleteControl.open()}>
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={TrashIcon} position="right" />
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
testID="messageDropdownReportBtn"
label={l`Report message`}
onPress={() => reportControl.open()}>
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
<ContextMenu.ItemIcon icon={WarningIcon} position="right" />
</ContextMenu.Item>
)}
</ContextMenu.Outer>
+10 -41
View File
@@ -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 => (
<Animated.View
@@ -407,13 +385,13 @@ let MessageItem = ({
return (
<>
<LayoutAnimationConfig skipExiting skipEntering>
{(hasLargeGapFromPrev || isDateDividerToggled) && (
{hasLargeGapFromPrev && (
<Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
<DateDivider date={message.sentAt} />
</Animated.View>
)}
</LayoutAnimationConfig>
<View style={[messageInset, effectiveFirstInCluster && a.mt_md]}>
<View style={[messageInset, isFirstInCluster && a.mt_md]}>
<View style={[a.relative]}>
{showAvatar ? (
<View
@@ -450,16 +428,7 @@ let MessageItem = ({
hasReactions={hasReactions}
isFromSelf={isFromSelf}
message={message}
senderProfile={profile}
onTap={() => {
if (reactionTapRef.current) return
if (!hasLargeGapFromPrev) {
LayoutAnimation.configureNext(
LayoutAnimation.Presets.easeInEaseOut,
)
toggleDivider(message.id)
}
}}>
senderProfile={profile}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed
embed={message.embed}
@@ -480,7 +449,7 @@ let MessageItem = ({
a.py_sm,
a.px_md,
{
marginTop: effectiveFirstInCluster
marginTop: isFirstInCluster
? 0
: CLUSTERED_MESSAGE_GAP,
backgroundColor: isFromSelf
@@ -523,7 +492,7 @@ let MessageItem = ({
</ActionsWrapper>
</View>
</View>
{effectiveLastInCluster && (
{isLastInCluster && (
<MessageItemMetadata
item={item}
style={[isFromSelf ? a.text_right : a.text_left]}
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Language_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M14.5 8a1 1 0 0 1 .912.59l4.5 10a1 1 0 0 1-1.824.82L16.554 16h-4.108l-1.534 3.41a1 1 0 0 1-1.824-.82l4.5-10A1 1 0 0 1 14.5 8m-1.153 6h2.306L14.5 11.437zM5.053.106a1 1 0 0 1 1.342.447L7.118 2H11a1 1 0 0 1 0 2H9.92C9.64 5.765 8.65 7.24 7.527 8.398a17 17 0 0 0 2.605 1.562q.129.062.197.092l.005.002.049.022.01.005a1 1 0 0 1-.787 1.838h-.002l-.027-.013-.068-.03q-.088-.04-.242-.113A18.878 18.878 0 0 1 6 9.75a19 19 0 0 1-3.509 2.125l-.068.03-.027.013h-.002a1 1 0 0 1-.788-1.838l.011-.005.049-.022.005-.002q.069-.03.197-.092a17.176 17.176 0 0 0 2.604-1.562C3.348 7.239 2.36 5.764 2.08 4H1a1 1 0 0 1 0-2h3.882l-.277-.553A1 1 0 0 1 5.053.106M4.118 4C4.388 5.151 5.098 6.193 6 7.1 6.901 6.193 7.612 5.15 7.882 4z',
})
@@ -55,7 +55,6 @@ import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {DateDividerToggleProvider} from '#/components/dms/DateDividerToggle'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
@@ -410,7 +409,7 @@ export function MessagesList({
)
return (
<DateDividerToggleProvider>
<>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
@@ -514,7 +513,7 @@ export function MessagesList({
</KeyboardGestureArea>
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</DateDividerToggleProvider>
</>
)
}