diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-03-28 08:43:40 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-03-28 08:43:40 +0200 |
commit | 55a40c2436b68dea850e54a65c5dd197132c08e4 (patch) | |
tree | e6d4d2d45ce5a3475aa4f73556910ff7d818986f /src/components/ContextMenu | |
parent | ac2c2a9a1d2d09442a497dc0dcfd8bc0bf715372 (diff) | |
download | voidsky-55a40c2436b68dea850e54a65c5dd197132c08e4.tar.zst |
[DMs] Emoji reaction picker (#8023)
Diffstat (limited to 'src/components/ContextMenu')
-rw-r--r-- | src/components/ContextMenu/Backdrop.ios.tsx | 54 | ||||
-rw-r--r-- | src/components/ContextMenu/Backdrop.tsx | 8 | ||||
-rw-r--r-- | src/components/ContextMenu/index.tsx | 414 | ||||
-rw-r--r-- | src/components/ContextMenu/index.web.tsx | 7 | ||||
-rw-r--r-- | src/components/ContextMenu/types.ts | 40 |
5 files changed, 355 insertions, 168 deletions
diff --git a/src/components/ContextMenu/Backdrop.ios.tsx b/src/components/ContextMenu/Backdrop.ios.tsx index 27a4ed1d8..60a8fda44 100644 --- a/src/components/ContextMenu/Backdrop.ios.tsx +++ b/src/components/ContextMenu/Backdrop.ios.tsx @@ -2,26 +2,36 @@ import {Pressable} from 'react-native' import Animated, { Extrapolation, interpolate, - SharedValue, + type SharedValue, useAnimatedProps, + useAnimatedStyle, } from 'react-native-reanimated' import {BlurView} from 'expo-blur' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' +import {useContextMenuContext} from './context' const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) -export function Backdrop({ - animation, - intensity = 50, - onPress, -}: { +type Props = { animation: SharedValue<number> intensity?: number onPress?: () => void -}) { +} + +export function Backdrop(props: Props) { + const {mode} = useContextMenuContext() + switch (mode) { + case 'full': + return <BlurredBackdrop {...props} /> + case 'auxiliary-only': + return <OpacityBackdrop {...props} /> + } +} + +function BlurredBackdrop({animation, intensity = 50, onPress}: Props) { const {_} = useLingui() const animatedProps = useAnimatedProps(() => ({ @@ -37,7 +47,7 @@ export function Backdrop({ <AnimatedBlurView animatedProps={animatedProps} style={[a.absolute, a.inset_0]} - tint="systemThinMaterialDark"> + tint="systemMaterialDark"> <Pressable style={a.flex_1} accessibilityLabel={_(msg`Close menu`)} @@ -47,3 +57,29 @@ export function Backdrop({ </AnimatedBlurView> ) } + +function OpacityBackdrop({animation, onPress}: Props) { + const t = useTheme() + const {_} = useLingui() + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: interpolate( + animation.get(), + [0, 1], + [0, 0.05], + Extrapolation.CLAMP, + ), + })) + + return ( + <Animated.View + style={[a.absolute, a.inset_0, t.atoms.bg_contrast_975, animatedStyle]}> + <Pressable + style={a.flex_1} + accessibilityLabel={_(msg`Close menu`)} + accessibilityHint={_(msg`Tap to close context menu`)} + onPress={onPress} + /> + </Animated.View> + ) +} diff --git a/src/components/ContextMenu/Backdrop.tsx b/src/components/ContextMenu/Backdrop.tsx index 857be7c44..027bf9849 100644 --- a/src/components/ContextMenu/Backdrop.tsx +++ b/src/components/ContextMenu/Backdrop.tsx @@ -9,6 +9,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {atoms as a, useTheme} from '#/alf' +import {useContextMenuContext} from './context' export function Backdrop({ animation, @@ -21,12 +22,17 @@ export function Backdrop({ }) { const t = useTheme() const {_} = useLingui() + const {mode} = useContextMenuContext() + + const reduced = mode === 'auxiliary-only' + + const target = reduced ? 0.05 : intensity / 100 const animatedStyle = useAnimatedStyle(() => ({ opacity: interpolate( animation.get(), [0, 1], - [0, intensity / 100], + [0, target], Extrapolation.CLAMP, ), })) diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 840e2caea..90c448782 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -9,37 +9,37 @@ import React, { import { BackHandler, Keyboard, - LayoutChangeEvent, + type LayoutChangeEvent, Pressable, - StyleProp, + type StyleProp, useWindowDimensions, View, - ViewStyle, + type ViewStyle, } from 'react-native' import { Gesture, GestureDetector, - GestureStateChangeEvent, - GestureUpdateEvent, - PanGestureHandlerEventPayload, + type GestureStateChangeEvent, + type GestureUpdateEvent, + type PanGestureHandlerEventPayload, } from 'react-native-gesture-handler' import Animated, { clamp, interpolate, runOnJS, - SharedValue, + type SharedValue, useAnimatedReaction, useAnimatedStyle, useSharedValue, withSpring, - WithSpringConfig, + type WithSpringConfig, } from 'react-native-reanimated' import { useSafeAreaFrame, useSafeAreaInsets, } from 'react-native-safe-area-context' import {captureRef} from 'react-native-view-shot' -import {Image, ImageErrorEventData} from 'expo-image' +import {Image, type ImageErrorEventData} from 'expo-image' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useIsFocused} from '@react-navigation/native' @@ -60,12 +60,13 @@ import { useContextMenuMenuContext, } from '#/components/ContextMenu/context' import { - ContextType, - ItemIconProps, - ItemProps, - ItemTextProps, - Measurement, - TriggerProps, + type AuxiliaryViewProps, + type ContextType, + type ItemIconProps, + type ItemProps, + type ItemTextProps, + type Measurement, + type TriggerProps, } from '#/components/ContextMenu/types' import {useInteractionState} from '#/components/hooks/useInteractionState' import {createPortalGroup} from '#/components/Portal' @@ -79,7 +80,14 @@ export { const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() -const SPRING: WithSpringConfig = { +const SPRING_IN: WithSpringConfig = { + mass: isIOS ? 1.25 : 0.75, + damping: 50, + stiffness: 1100, + restDisplacementThreshold: 0.01, +} + +const SPRING_OUT: WithSpringConfig = { mass: isIOS ? 1.25 : 0.75, damping: 150, stiffness: 1000, @@ -100,6 +108,7 @@ export function Provider({children}: {children: React.ReactNode}) { export function Root({children}: {children: React.ReactNode}) { const playHaptic = useHaptics() + const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full') const [measurement, setMeasurement] = useState<Measurement | null>(null) const animationSV = useSharedValue(0) const translationSV = useSharedValue(0) @@ -134,13 +143,15 @@ export function Root({children}: {children: React.ReactNode}) { measurement, animationSV, translationSV, - open: (evt: Measurement) => { + mode, + open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => { setMeasurement(evt) - animationSV.set(withSpring(1, SPRING)) + setMode(mode) + animationSV.set(withSpring(1, SPRING_IN)) }, close: () => { animationSV.set( - withSpring(0, SPRING, finished => { + withSpring(0, SPRING_OUT, finished => { if (finished) { hoverablesSV.set({}) translationSV.set(0) @@ -192,6 +203,7 @@ export function Root({children}: {children: React.ReactNode}) { hoveredMenuItem, setHoveredMenuItem, playHaptic, + mode, ], ) @@ -216,45 +228,49 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { const ref = useRef<View>(null) const isFocused = useIsFocused() const [image, setImage] = useState<string | null>(null) - const [pendingMeasurement, setPendingMeasurement] = - useState<Measurement | null>(null) - - const open = useNonReactiveCallback(async () => { - playHaptic() - Keyboard.dismiss() - const [measurement, capture] = await Promise.all([ - new Promise<Measurement>(resolve => { - ref.current?.measureInWindow((x, y, width, height) => - resolve({ - x, - y: - y + - platform({ - default: 0, - android: topInset, // not included in measurement - }), - width, - height, - }), - ) - }), - captureRef(ref, {result: 'data-uri'}).catch(err => { - logger.error(err instanceof Error ? err : String(err), { - message: 'Failed to capture image of context menu trigger', - }) - // will cause the image to fail to load, but it will get handled gracefully - return '<failed capture>' - }), - ]) - setImage(capture) - setPendingMeasurement(measurement) - }) + const [pendingMeasurement, setPendingMeasurement] = useState<{ + measurement: Measurement + mode: 'full' | 'auxiliary-only' + } | null>(null) + + const open = useNonReactiveCallback( + async (mode: 'full' | 'auxiliary-only') => { + playHaptic() + Keyboard.dismiss() + const [measurement, capture] = await Promise.all([ + new Promise<Measurement>(resolve => { + ref.current?.measureInWindow((x, y, width, height) => + resolve({ + x, + y: + y + + platform({ + default: 0, + android: topInset, // not included in measurement + }), + width, + height, + }), + ) + }), + captureRef(ref, {result: 'data-uri'}).catch(err => { + logger.error(err instanceof Error ? err : String(err), { + message: 'Failed to capture image of context menu trigger', + }) + // will cause the image to fail to load, but it will get handled gracefully + return '<failed capture>' + }), + ]) + setImage(capture) + setPendingMeasurement({measurement, mode}) + }, + ) const doubleTapGesture = useMemo(() => { return Gesture.Tap() .numberOfTaps(2) .hitSlop(HITSLOP_10) - .onEnd(open) + .onEnd(() => open('auxiliary-only')) .runOnJS(true) }, [open]) @@ -283,17 +299,19 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { .averageTouches(true) .onStart(() => { 'worklet' - runOnJS(open)() + runOnJS(open)('full') }) .onUpdate(evt => { 'worklet' const item = getHoveredHoverable(evt, hoverablesSV, translationSV) hoveredItemSV.set(item) }) - .onEnd(evt => { + .onEnd(() => { 'worklet' - const item = getHoveredHoverable(evt, hoverablesSV, translationSV) - hoveredItemSV.set(null) + // don't recalculate hovered item - if they haven't moved their finger from + // the initial press, it's jarring to then select the item underneath + // as the menu may have slid into place beneath their finger + const item = hoveredItemSV.get() if (item) { runOnJS(onTouchUpMenuItem)(item) } @@ -305,7 +323,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { pressAndHoldGesture, ) - const measurement = context.measurement || pendingMeasurement + const measurement = context.measurement || pendingMeasurement?.measurement return ( <> @@ -343,7 +361,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { measurement={measurement} onDisplay={() => { if (pendingMeasurement) { - context.open(pendingMeasurement) + context.open( + pendingMeasurement.measurement, + pendingMeasurement.mode, + ) setPendingMeasurement(null) } }} @@ -416,7 +437,90 @@ function TriggerClone({ ) } -const MENU_WIDTH = 230 +export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) { + const context = useContextMenuContext() + const {width: screenWidth} = useWindowDimensions() + const {top: topInset} = useSafeAreaInsets() + const ensureOnScreenTranslationSV = useSharedValue(0) + + const {isOpen, mode, measurement, translationSV, animationSV} = context + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: clamp(animationSV.get(), 0, 1), + transform: [ + { + translateY: + (ensureOnScreenTranslationSV.get() || translationSV.get()) * + animationSV.get(), + }, + {scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}, + ], + } + }) + + const menuContext = useMemo(() => ({align}), [align]) + + const onLayout = useCallback(() => { + if (!measurement) return + + let translation = 0 + + // vibes based, just assuming it'll fit within this space. revisit if we use + // AuxiliaryView for something tall + const TOP_INSET = topInset + 80 + + const distanceMessageFromTop = measurement.y - TOP_INSET + if (distanceMessageFromTop < 0) { + translation = -distanceMessageFromTop + } + + // normally, the context menu is responsible for measuring itself and moving everything into the right place + // however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here + if (mode === 'auxiliary-only') { + translationSV.set(translation) + ensureOnScreenTranslationSV.set(0) + } + // however, we also need to make sure that for super tall triggers, we don't go off the screen + // so we have an additional cap on the standard transform every other element has + // note: this breaks the press-and-hold gesture for the reaction items. unfortunately I think + // we'll just have to live with it for now, fixing it would be possible but be a large complexity + // increase for an edge case + else { + ensureOnScreenTranslationSV.set(translation) + } + }, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV]) + + if (!isOpen || !measurement) return null + + return ( + <Portal> + <Context.Provider value={context}> + <MenuContext.Provider value={menuContext}> + <Animated.View + onLayout={onLayout} + style={[ + a.absolute, + { + top: measurement.y, + transformOrigin: + align === 'left' ? 'bottom left' : 'bottom right', + }, + align === 'left' + ? {left: measurement.x} + : {right: screenWidth - measurement.x - measurement.width}, + animatedStyle, + a.z_20, + ]}> + {children} + </Animated.View> + </MenuContext.Provider> + </Context.Provider> + </Portal> + ) +} + +const MENU_WIDTH = 240 export function Outer({ children, @@ -491,85 +595,95 @@ export function Outer({ <Context.Provider value={context}> <MenuContext.Provider value={menuContext}> <Backdrop animation={animationSV} onPress={context.close} /> - {/* containing element - stays the same size, so we measure it - to determine if a translation is necessary. also has the positioning */} - <Animated.View - onLayout={onLayout} - style={[ - a.absolute, - a.z_10, - a.mt_xs, - { - width: MENU_WIDTH, - top: context.measurement.y + context.measurement.height, - }, - align === 'left' - ? {left: context.measurement.x} - : { - right: - screenWidth - - context.measurement.x - - context.measurement.width, - }, - animatedContainerStyle, - ]}> - {/* scaling element - has the scale/fade animation on it */} + {context.mode === 'full' && ( + /* containing element - stays the same size, so we measure it + to determine if a translation is necessary. also has the positioning */ <Animated.View + onLayout={onLayout} style={[ - a.rounded_md, - a.shadow_md, - t.atoms.bg_contrast_25, - a.w_full, - // @ts-ignore react-native-web expects string, and this file is platform-split -sfn - // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error - // in the typecheck CI - presumably because of RNW overriding the types + a.absolute, + a.z_10, + a.mt_xs, { - transformOrigin: - // "top right" doesn't seem to work on android, so set explicity in pixels - align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0], + width: MENU_WIDTH, + top: context.measurement.y + context.measurement.height, }, - animatedStyle, - style, + align === 'left' + ? {left: context.measurement.x} + : { + right: + screenWidth - + context.measurement.x - + context.measurement.width, + }, + animatedContainerStyle, ]}> - {/* 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 + {/* scaling element - has the scale/fade animation on it */} + <Animated.View style={[ - a.flex_1, a.rounded_md, - a.overflow_hidden, - a.border, - t.atoms.border_contrast_low, + a.shadow_md, + t.atoms.bg_contrast_25, + a.w_full, + // @ts-ignore react-native-web expects string, and this file is platform-split -sfn + // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error + // in the typecheck CI - presumably because of RNW overriding the types + { + transformOrigin: + // "top right" doesn't seem to work on android, so set explicitly in pixels + align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0], + }, + animatedStyle, + style, ]}> - {flattenReactChildren(children).map((child, i) => { - return React.isValidElement(child) && - (child.type === Item || child.type === Divider) ? ( - <React.Fragment key={i}> - {i > 0 ? ( - <View - style={[a.border_b, t.atoms.border_contrast_low]} - /> - ) : null} - {React.cloneElement(child, { - // @ts-expect-error not typed - style: { - borderRadius: 0, - borderWidth: 0, - }, - })} - </React.Fragment> - ) : null - })} - </View> + {/* 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, + ]}> + {flattenReactChildren(children).map((child, i) => { + return React.isValidElement(child) && + (child.type === Item || child.type === Divider) ? ( + <React.Fragment key={i}> + {i > 0 ? ( + <View + style={[a.border_b, t.atoms.border_contrast_low]} + /> + ) : null} + {React.cloneElement(child, { + // @ts-expect-error not typed + style: { + borderRadius: 0, + borderWidth: 0, + }, + })} + </React.Fragment> + ) : null + })} + </View> + </Animated.View> </Animated.View> - </Animated.View> + )} </MenuContext.Provider> </Context.Provider> </Portal> ) } -export function Item({children, label, style, onPress, ...rest}: ItemProps) { +export function Item({ + children, + label, + unstyled, + style, + onPress, + position, + ...rest +}: ItemProps) { const t = useTheme() const context = useContextMenuContext() const playHaptic = useHaptics() @@ -590,16 +704,22 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) { const layout = evt.nativeEvent.layout + const yOffset = position + ? position.y + : measurement.y + measurement.height + tokens.space.xs + const xOffset = position + ? position.x + : align === 'left' + ? measurement.x + : measurement.x + measurement.width - layout.width + registerHoverable( id, { width: layout.width, height: layout.height, - y: measurement.y + measurement.height + tokens.space.xs + layout.y, - x: - align === 'left' - ? measurement.x - : measurement.x + measurement.width - layout.width, + y: yOffset + layout.y, + x: xOffset + layout.x, }, () => { close() @@ -607,7 +727,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) { }, ) }, - [id, measurement, registerHoverable, close, onPress, align], + [id, measurement, registerHoverable, close, onPress, align, position], ) const itemContext = useMemo( @@ -637,22 +757,26 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) { rest.onPressOut?.(e) }} style={[ - a.flex_row, - a.align_center, - a.gap_sm, - a.py_sm, - a.px_md, - a.rounded_md, - a.border, - t.atoms.bg_contrast_25, - t.atoms.border_contrast_low, - {minHeight: 40}, + !unstyled && [ + a.flex_row, + a.align_center, + a.gap_sm, + a.px_md, + a.rounded_md, + a.border, + t.atoms.bg_contrast_25, + t.atoms.border_contrast_low, + {minHeight: 44, paddingVertical: 10}, + (focused || pressed || context.hoveredMenuItem === id) && + !rest.disabled && + t.atoms.bg_contrast_50, + ], style, - (focused || pressed || context.hoveredMenuItem === id) && - !rest.disabled && [t.atoms.bg_contrast_50], ]}> <ItemContext.Provider value={itemContext}> - {children} + {typeof children === 'function' + ? children(focused || pressed || context.hoveredMenuItem === id) + : children} </ItemContext.Provider> </Pressable> ) @@ -667,7 +791,7 @@ export function ItemText({children, style}: ItemTextProps) { ellipsizeMode="middle" style={[ a.flex_1, - a.text_sm, + a.text_md, a.font_bold, t.atoms.text_contrast_high, {paddingTop: 3}, @@ -684,7 +808,7 @@ export function ItemIcon({icon: Comp}: ItemIconProps) { const {disabled} = useContextMenuItemContext() return ( <Comp - size="md" + size="lg" fill={ disabled ? t.atoms.text_contrast_low.color diff --git a/src/components/ContextMenu/index.web.tsx b/src/components/ContextMenu/index.web.tsx index f7e3b0c34..41fa62474 100644 --- a/src/components/ContextMenu/index.web.tsx +++ b/src/components/ContextMenu/index.web.tsx @@ -1,5 +1,12 @@ +import {type AuxiliaryViewProps} from './types' + export * from '#/components/Menu' export function Provider({children}: {children: React.ReactNode}) { return children } + +// native only +export function AuxiliaryView({}: AuxiliaryViewProps) { + return null +} diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts index 2084ecf19..265a746ca 100644 --- a/src/components/ContextMenu/types.ts +++ b/src/components/ContextMenu/types.ts @@ -1,16 +1,16 @@ -import React from 'react' import { - AccessibilityRole, - GestureResponderEvent, - StyleProp, - ViewStyle, + type AccessibilityRole, + type GestureResponderEvent, + type StyleProp, + type ViewStyle, } from 'react-native' -import {SharedValue} from 'react-native-reanimated' +import {type SharedValue} from 'react-native-reanimated' +import type React from 'react' -import * as Dialog from '#/components/Dialog' +import type * as Dialog from '#/components/Dialog' import { - ItemProps as MenuItemProps, - RadixPassThroughTriggerProps, + type ItemProps as MenuItemProps, + type RadixPassThroughTriggerProps, } from '#/components/Menu/types' export type { @@ -19,9 +19,19 @@ export type { ItemTextProps, } from '#/components/Menu/types' -// Same as Menu.ItemProps, but onPress is not guaranteed to get an event -export type ItemProps = Omit<MenuItemProps, 'onPress'> & { +export type AuxiliaryViewProps = { + children?: React.ReactNode + align?: 'left' | 'right' +} + +export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & { + // remove default styles (i.e. for emoji reactions) + unstyled?: boolean onPress: (evt?: GestureResponderEvent) => void + children?: React.ReactNode | ((hovered: boolean) => React.ReactNode) + // absolute position of the parent element. if undefined, assumed to + // be in the context menu. use this if using AuxiliaryView + position?: Measurement } export type Measurement = { @@ -38,7 +48,8 @@ export type ContextType = { animationSV: SharedValue<number> /* Translation in Y axis to ensure everything's onscreen */ translationSV: SharedValue<number> - open: (evt: Measurement) => void + mode: 'full' | 'auxiliary-only' + open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void close: () => void registerHoverable: ( id: string, @@ -76,7 +87,10 @@ export type TriggerProps = { export type TriggerChildProps = | { isNative: true - control: {isOpen: boolean; open: () => void} + control: { + isOpen: boolean + open: (mode: 'full' | 'auxiliary-only') => void + } state: { hovered: false focused: false |