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