From c4785ef96e13d02b217dce4e777269c0e895507d Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 21 Mar 2025 18:29:14 +0200 Subject: New `ContextMenu` menu type for DM messages (#8014) * get context menu somewhat working ish * take screenshot rather than double rendering * get animations somewhat working * get transform animation working * rm log * upwards safe area * get working on android * get android working once and for all * fix positioning on both platforms * use dark blur on ios always, fix dark mode * allow closing with hardware back press * try and fix type error * add note about ts-ignore * round post * add image capture error handling * extract magic numbers * set explicit embed width, rm top margin * Message embed width tweaks * Format * fix position of embeds * same as above for web --------- Co-authored-by: Eric Bailey --- src/components/ContextMenu/index.tsx | 591 +++++++++++++++++++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 src/components/ContextMenu/index.tsx (limited to 'src/components/ContextMenu/index.tsx') diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx new file mode 100644 index 000000000..d172935d6 --- /dev/null +++ b/src/components/ContextMenu/index.tsx @@ -0,0 +1,591 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import { + BackHandler, + Keyboard, + LayoutChangeEvent, + Pressable, + StyleProp, + View, + ViewStyle, +} from 'react-native' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import Animated, { + clamp, + interpolate, + runOnJS, + SharedValue, + useAnimatedStyle, + useSharedValue, + withSpring, + 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 {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useIsFocused} from '@react-navigation/native' +import flattenReactChildren from 'react-keyed-flatten-children' + +import {HITSLOP_10} from '#/lib/constants' +import {useHaptics} from '#/lib/haptics' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {logger} from '#/logger' +import {isAndroid, isIOS} from '#/platform/detection' +import {atoms as a, platform, useTheme} from '#/alf' +import { + Context, + ItemContext, + useContextMenuContext, + useContextMenuItemContext, +} from '#/components/ContextMenu/context' +import { + ContextType, + ItemIconProps, + ItemProps, + ItemTextProps, + Measurement, + TriggerProps, +} from '#/components/ContextMenu/types' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {createPortalGroup} from '#/components/Portal' +import {Text} from '#/components/Typography' +import {Backdrop} from './Backdrop' + +export { + type DialogControlProps as ContextMenuControlProps, + useDialogControl as useContextMenuControl, +} from '#/components/Dialog' + +const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() + +const SPRING: WithSpringConfig = { + mass: isIOS ? 1.25 : 0.75, + damping: 150, + stiffness: 1000, + restDisplacementThreshold: 0.01, +} + +/** + * Needs placing near the top of the provider stack, but BELOW the theme provider. + */ +export function Provider({children}: {children: React.ReactNode}) { + return ( + + {children} + + + ) +} + +export function Root({children}: {children: React.ReactNode}) { + const [measurement, setMeasurement] = useState(null) + const animationSV = useSharedValue(0) + const translationSV = useSharedValue(0) + const isFocused = useIsFocused() + + const clearMeasurement = useCallback(() => setMeasurement(null), []) + + const context = useMemo( + () => ({ + isOpen: !!measurement && isFocused, + measurement, + animationSV, + translationSV, + open: (evt: Measurement) => { + setMeasurement(evt) + animationSV.set(withSpring(1, SPRING)) + }, + close: () => { + animationSV.set( + withSpring(0, SPRING, finished => { + if (finished) { + translationSV.set(0) + runOnJS(clearMeasurement)() + } + }), + ) + }, + }), + [ + measurement, + setMeasurement, + isFocused, + animationSV, + translationSV, + clearMeasurement, + ], + ) + + useEffect(() => { + if (isAndroid && context.isOpen) { + const listener = BackHandler.addEventListener('hardwareBackPress', () => { + context.close() + return true + }) + + return () => listener.remove() + } + }, [context]) + + return {children} +} + +export function Trigger({children, label, contentLabel, style}: TriggerProps) { + const context = useContextMenuContext() + const playHaptic = useHaptics() + const {top: topInset} = useSafeAreaInsets() + const ref = useRef(null) + const isFocused = useIsFocused() + const [image, setImage] = useState(null) + const [pendingMeasurement, setPendingMeasurement] = + useState(null) + + const open = useNonReactiveCallback(async () => { + playHaptic() + Keyboard.dismiss() + const [measurement, capture] = await Promise.all([ + new Promise(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 '' + }), + ]) + setImage(capture) + setPendingMeasurement(measurement) + }) + + const doubleTapGesture = useMemo(() => { + return Gesture.Tap() + .numberOfTaps(2) + .hitSlop(HITSLOP_10) + .onEnd(open) + .runOnJS(true) + }, [open]) + + const pressAndHoldGesture = useMemo(() => { + return Gesture.LongPress() + .onStart(() => { + runOnJS(open)() + }) + .cancelsTouchesInView(false) + }, [open]) + + const composedGestures = Gesture.Exclusive( + doubleTapGesture, + pressAndHoldGesture, + ) + + const {translationSV, animationSV} = context + + const measurement = context.measurement || pendingMeasurement + + return ( + <> + + + {children({ + isNative: true, + control: {isOpen: context.isOpen, open}, + state: { + pressed: false, + hovered: false, + focused: false, + }, + props: { + ref: null, + onPress: null, + onFocus: null, + onBlur: null, + onPressIn: null, + onPressOut: null, + accessibilityHint: null, + accessibilityLabel: label, + accessibilityRole: null, + }, + })} + + + {isFocused && image && measurement && ( + + { + if (pendingMeasurement) { + context.open(pendingMeasurement) + setPendingMeasurement(null) + } + }} + /> + + )} + + ) +} + +/** + * an image of the underlying trigger with a grow animation + */ +function TriggerClone({ + translation, + animation, + image, + measurement, + onDisplay, + label, +}: { + translation: SharedValue + animation: SharedValue + image: string + measurement: Measurement + onDisplay: () => void + label: string +}) { + const {_} = useLingui() + + const animatedStyles = useAnimatedStyle(() => ({ + transform: [{translateY: translation.get() * animation.get()}], + })) + + const handleError = useCallback( + (evt: ImageErrorEventData) => { + logger.error('Context menu image load error', {message: evt.error}) + onDisplay() + }, + [onDisplay], + ) + + return ( + + + + ) +} + +const MENU_WIDTH = 230 + +export function Outer({ + children, + style, + align = 'left', +}: { + children: React.ReactNode + style?: StyleProp + align?: 'left' | 'right' +}) { + const t = useTheme() + const context = useContextMenuContext() + const insets = useSafeAreaInsets() + const frame = useSafeAreaFrame() + + const {animationSV, translationSV} = context + + const animatedContainerStyle = useAnimatedStyle(() => ({ + transform: [{translateY: translationSV.get() * animationSV.get()}], + })) + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: clamp(animationSV.get(), 0, 1), + transform: [{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}], + })) + + const onLayout = useCallback( + (evt: LayoutChangeEvent) => { + if (!context.measurement) return // should not happen + let translation = 0 + + // pure vibes based + const TOP_INSET = insets.top + 80 + const BOTTOM_INSET_IOS = insets.bottom + 20 + const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn + + const {height} = evt.nativeEvent.layout + const topPosition = context.measurement.y + context.measurement.height + 4 + const bottomPosition = topPosition + height + const safeAreaBottomLimit = + frame.height - + platform({ + ios: BOTTOM_INSET_IOS, + android: BOTTOM_INSET_ANDROID, + default: 0, + }) + const diff = bottomPosition - safeAreaBottomLimit + if (diff > 0) { + translation = -diff + } else { + const distanceMessageFromTop = context.measurement.y - TOP_INSET + if (distanceMessageFromTop < 0) { + translation = -Math.max(distanceMessageFromTop, diff) + } + } + + if (translation !== 0) { + translationSV.set(translation) + } + }, + [context.measurement, frame.height, insets, translationSV], + ) + + if (!context.isOpen || !context.measurement) return null + + return ( + + + + {/* containing element - stays the same size, so we measure it + to determine if a translation is necessary. also has the positioning */} + + {/* scaling element - has the scale/fade animation on it */} + + {/* 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 */} + + {flattenReactChildren(children).map((child, i) => { + return React.isValidElement(child) && + (child.type === Item || child.type === Divider) ? ( + + {i > 0 ? ( + + ) : null} + {React.cloneElement(child, { + // @ts-expect-error not typed + style: { + borderRadius: 0, + borderWidth: 0, + }, + })} + + ) : null + })} + + + + + + ) +} + +export function Item({children, label, style, onPress, ...rest}: ItemProps) { + const t = useTheme() + const context = useContextMenuContext() + const playHaptic = useHaptics() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + + return ( + { + context.close() + onPress?.(e) + }} + onPressIn={e => { + onPressIn() + rest.onPressIn?.(e) + playHaptic('Light') + }} + onPressOut={e => { + onPressOut() + 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}, + style, + (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50], + ]}> + + {children} + + + ) +} + +export function ItemText({children, style}: ItemTextProps) { + const t = useTheme() + const {disabled} = useContextMenuItemContext() + return ( + + {children} + + ) +} + +export function ItemIcon({icon: Comp}: ItemIconProps) { + const t = useTheme() + const {disabled} = useContextMenuItemContext() + return ( + + ) +} + +export function ItemRadio({selected}: {selected: boolean}) { + const t = useTheme() + return ( + + {selected ? ( + + ) : null} + + ) +} + +export function LabelText({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function Divider() { + const t = useTheme() + return ( + + ) +} -- cgit 1.4.1