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 ( ) }