import React, {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import {
BackHandler,
Keyboard,
type LayoutChangeEvent,
Pressable,
type StyleProp,
useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
import {
Gesture,
GestureDetector,
type GestureStateChangeEvent,
type GestureUpdateEvent,
type PanGestureHandlerEventPayload,
} from 'react-native-gesture-handler'
import Animated, {
clamp,
interpolate,
runOnJS,
type SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
type WithSpringConfig,
} from 'react-native-reanimated'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {captureRef} from 'react-native-view-shot'
import {Image, type 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, tokens, useTheme} from '#/alf'
import {
Context,
ItemContext,
MenuContext,
useContextMenuContext,
useContextMenuItemContext,
useContextMenuMenuContext,
} from '#/components/ContextMenu/context'
import {
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'
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_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,
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 playHaptic = useHaptics()
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
const [measurement, setMeasurement] = useState(null)
const animationSV = useSharedValue(0)
const translationSV = useSharedValue(0)
const isFocused = useIsFocused()
const hoverables = useRef<
Map void}>
>(new Map())
const hoverablesSV = useSharedValue<
Record
>({})
const syncHoverablesThrottleRef =
useRef>(undefined)
const [hoveredMenuItem, setHoveredMenuItem] = useState(null)
const onHoverableTouchUp = useCallback((id: string) => {
const hoverable = hoverables.current.get(id)
if (!hoverable) {
logger.warn(`No such hoverable with id ${id}`)
return
}
hoverable.onTouchUp()
}, [])
const onCompletedClose = useCallback(() => {
hoverables.current.clear()
setMeasurement(null)
}, [])
const context = useMemo(
() =>
({
isOpen: !!measurement && isFocused,
measurement,
animationSV,
translationSV,
mode,
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => {
setMeasurement(evt)
setMode(mode)
animationSV.set(withSpring(1, SPRING_IN))
},
close: () => {
animationSV.set(
withSpring(0, SPRING_OUT, finished => {
if (finished) {
hoverablesSV.set({})
translationSV.set(0)
runOnJS(onCompletedClose)()
}
}),
)
},
registerHoverable: (
id: string,
rect: Measurement,
onTouchUp: () => void,
) => {
hoverables.current.set(id, {id, rect, onTouchUp})
// we need this data on the UI thread, but we want to limit cross-thread communication
// and this function will be called in quick succession, so we need to throttle it
if (syncHoverablesThrottleRef.current)
clearTimeout(syncHoverablesThrottleRef.current)
syncHoverablesThrottleRef.current = setTimeout(() => {
syncHoverablesThrottleRef.current = undefined
hoverablesSV.set(
Object.fromEntries(
// eslint-ignore
[...hoverables.current.entries()].map(([id, {rect}]) => [
id,
{id, rect},
]),
),
)
}, 1)
},
hoverablesSV,
onTouchUpMenuItem: onHoverableTouchUp,
hoveredMenuItem,
setHoveredMenuItem: item => {
if (item) playHaptic('Light')
setHoveredMenuItem(item)
},
}) satisfies ContextType,
[
measurement,
setMeasurement,
onCompletedClose,
isFocused,
animationSV,
translationSV,
hoverablesSV,
onHoverableTouchUp,
hoveredMenuItem,
setHoveredMenuItem,
playHaptic,
mode,
],
)
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<{
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(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, mode})
},
)
const doubleTapGesture = useMemo(() => {
return Gesture.Tap()
.numberOfTaps(2)
.hitSlop(HITSLOP_10)
.onEnd(() => open('auxiliary-only'))
.runOnJS(true)
}, [open])
const {
hoverablesSV,
setHoveredMenuItem,
onTouchUpMenuItem,
translationSV,
animationSV,
} = context
const hoveredItemSV = useSharedValue(null)
useAnimatedReaction(
() => hoveredItemSV.get(),
(hovered, prev) => {
if (hovered !== prev) {
runOnJS(setHoveredMenuItem)(hovered)
}
},
)
const pressAndHoldGesture = useMemo(() => {
return Gesture.Pan()
.activateAfterLongPress(500)
.cancelsTouchesInView(false)
.averageTouches(true)
.onStart(() => {
'worklet'
runOnJS(open)('full')
})
.onUpdate(evt => {
'worklet'
const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
hoveredItemSV.set(item)
})
.onEnd(() => {
'worklet'
// 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)
}
})
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
const composedGestures = Gesture.Exclusive(
doubleTapGesture,
pressAndHoldGesture,
)
const measurement = context.measurement || pendingMeasurement?.measurement
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.measurement,
pendingMeasurement.mode,
)
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 (
)
}
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 (
{children}
)
}
const MENU_WIDTH = 240
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 {width: screenWidth} = useWindowDimensions()
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 = insets.bottom + 12
const {height} = evt.nativeEvent.layout
const topPosition =
context.measurement.y + context.measurement.height + tokens.space.xs
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],
)
const menuContext = useMemo(() => ({align}), [align])
if (!context.isOpen || !context.measurement) return null
return (
{context.mode === 'full' && (
/* 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,
unstyled,
style,
onPress,
position,
...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()
const id = useId()
const {align} = useContextMenuMenuContext()
const {close, measurement, registerHoverable} = context
const handleLayout = useCallback(
(evt: LayoutChangeEvent) => {
if (!measurement) return // should be impossible
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: yOffset + layout.y,
x: xOffset + layout.x,
},
() => {
close()
onPress()
},
)
},
[id, measurement, registerHoverable, close, onPress, align, position],
)
const itemContext = useMemo(
() => ({disabled: Boolean(rest.disabled)}),
[rest.disabled],
)
return (
{
close()
onPress?.(e)
}}
onPressIn={e => {
onPressIn()
rest.onPressIn?.(e)
playHaptic('Light')
}}
onPressOut={e => {
onPressOut()
rest.onPressOut?.(e)
}}
style={[
!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,
]}>
{typeof children === 'function'
? children(
(focused || pressed || context.hoveredMenuItem === id) &&
!rest.disabled,
)
: 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 (
)
}
function getHoveredHoverable(
evt:
| GestureStateChangeEvent
| GestureUpdateEvent,
hoverables: SharedValue>,
translation: SharedValue,
) {
'worklet'
const x = evt.absoluteX
const y = evt.absoluteY
const yOffset = translation.get()
const rects = Object.values(hoverables.get())
for (const {id, rect} of rects) {
const isWithinLeftBound = x >= rect.x
const isWithinRightBound = x <= rect.x + rect.width
const isWithinTopBound = y >= rect.y + yOffset
const isWithinBottomBound = y <= rect.y + rect.height + yOffset
if (
isWithinLeftBound &&
isWithinRightBound &&
isWithinTopBound &&
isWithinBottomBound
) {
return id
}
}
return null
}