forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React, {
2 useCallback,
3 useEffect,
4 useId,
5 useMemo,
6 useRef,
7 useState,
8} from 'react'
9import {
10 BackHandler,
11 Keyboard,
12 type LayoutChangeEvent,
13 Pressable,
14 type StyleProp,
15 useWindowDimensions,
16 View,
17 type ViewStyle,
18} from 'react-native'
19import {
20 Gesture,
21 GestureDetector,
22 type GestureStateChangeEvent,
23 type GestureUpdateEvent,
24 type PanGestureHandlerEventPayload,
25} from 'react-native-gesture-handler'
26import {KeyboardEvents} from 'react-native-keyboard-controller'
27import Animated, {
28 clamp,
29 interpolate,
30 runOnJS,
31 type SharedValue,
32 useAnimatedReaction,
33 useAnimatedStyle,
34 useSharedValue,
35 withSpring,
36 type WithSpringConfig,
37} from 'react-native-reanimated'
38import {
39 type EdgeInsets,
40 useSafeAreaFrame,
41 useSafeAreaInsets,
42} from 'react-native-safe-area-context'
43import {captureRef} from 'react-native-view-shot'
44import {Image, type ImageErrorEventData} from 'expo-image'
45import {msg} from '@lingui/core/macro'
46import {useLingui} from '@lingui/react'
47import {useIsFocused} from '@react-navigation/native'
48import flattenReactChildren from 'react-keyed-flatten-children'
49
50import {HITSLOP_10} from '#/lib/constants'
51import {useHaptics} from '#/lib/haptics'
52import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
53import {logger} from '#/logger'
54import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
55import {atoms as a, platform, tokens, useTheme} from '#/alf'
56import {
57 Context,
58 ItemContext,
59 MenuContext,
60 useContextMenuContext,
61 useContextMenuItemContext,
62 useContextMenuMenuContext,
63} from '#/components/ContextMenu/context'
64import {
65 type AuxiliaryViewProps,
66 type ContextType,
67 type ItemIconProps,
68 type ItemProps,
69 type ItemTextProps,
70 type Measurement,
71 type TriggerProps,
72} from '#/components/ContextMenu/types'
73import {useInteractionState} from '#/components/hooks/useInteractionState'
74import {createPortalGroup} from '#/components/Portal'
75import {Text} from '#/components/Typography'
76import {IS_ANDROID, IS_IOS} from '#/env'
77import {Backdrop} from './Backdrop'
78
79export {
80 type DialogControlProps as ContextMenuControlProps,
81 useDialogControl as useContextMenuControl,
82} from '#/components/Dialog'
83
84const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
85
86const SPRING_IN: WithSpringConfig = {
87 mass: 0.75,
88 damping: 300,
89 stiffness: 1200,
90 restDisplacementThreshold: 0.01,
91}
92
93const SPRING_OUT: WithSpringConfig = {
94 mass: IS_IOS ? 1.25 : 0.75,
95 damping: 150,
96 stiffness: 1000,
97 restDisplacementThreshold: 0.01,
98}
99
100/**
101 * Needs placing near the top of the provider stack, but BELOW the theme provider.
102 */
103export function Provider({children}: {children: React.ReactNode}) {
104 return (
105 <PortalProvider>
106 {children}
107 <Outlet />
108 </PortalProvider>
109 )
110}
111
112export function Root({children}: {children: React.ReactNode}) {
113 const playHaptic = useHaptics()
114 const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
115 const [measurement, setMeasurement] = useState<Measurement | null>(null)
116 const returnLocationSV = useSharedValue<{x: number; y: number} | null>(null)
117 const animationSV = useSharedValue(0)
118 const translationSV = useSharedValue(0)
119 const isFocused = useIsFocused()
120 const hoverables = useRef<
121 Map<string, {id: string; rect: Measurement; onTouchUp: () => void}>
122 >(new Map())
123 const hoverablesSV = useSharedValue<
124 Record<string, {id: string; rect: Measurement}>
125 >({})
126 const syncHoverablesThrottleRef =
127 useRef<ReturnType<typeof setTimeout>>(undefined)
128 const [hoveredMenuItem, setHoveredMenuItem] = useState<string | null>(null)
129
130 const onHoverableTouchUp = useCallback((id: string) => {
131 const hoverable = hoverables.current.get(id)
132 if (!hoverable) {
133 logger.warn(`No such hoverable with id ${id}`)
134 return
135 }
136 hoverable.onTouchUp()
137 }, [])
138
139 const onCompletedClose = useCallback(() => {
140 hoverables.current.clear()
141 setMeasurement(null)
142 }, [])
143
144 const context = useMemo(
145 () =>
146 ({
147 isOpen: !!measurement && isFocused,
148 measurement,
149 returnLocationSV,
150 animationSV,
151 translationSV,
152 mode,
153 open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => {
154 setMeasurement(evt)
155 setMode(mode)
156 animationSV.set(withSpring(1, SPRING_IN))
157 // reset return location
158 returnLocationSV.set(null)
159 },
160 close: () => {
161 animationSV.set(
162 withSpring(0, SPRING_OUT, finished => {
163 if (finished) {
164 hoverablesSV.set({})
165 translationSV.set(0)
166 // note: return location has to be reset on open,
167 // rather than on close, otherwise there's a flicker
168 // where the reanimated update is faster than the react render
169 runOnJS(onCompletedClose)()
170 }
171 }),
172 )
173 },
174 registerHoverable: (
175 id: string,
176 rect: Measurement,
177 onTouchUp: () => void,
178 ) => {
179 hoverables.current.set(id, {id, rect, onTouchUp})
180 // we need this data on the UI thread, but we want to limit cross-thread communication
181 // and this function will be called in quick succession, so we need to throttle it
182 if (syncHoverablesThrottleRef.current)
183 clearTimeout(syncHoverablesThrottleRef.current)
184 syncHoverablesThrottleRef.current = setTimeout(() => {
185 syncHoverablesThrottleRef.current = undefined
186 hoverablesSV.set(
187 Object.fromEntries(
188 // eslint-ignore
189 [...hoverables.current.entries()].map(([id, {rect}]) => [
190 id,
191 {id, rect},
192 ]),
193 ),
194 )
195 }, 1)
196 },
197 hoverablesSV,
198 onTouchUpMenuItem: onHoverableTouchUp,
199 hoveredMenuItem,
200 setHoveredMenuItem: item => {
201 if (item) playHaptic('Light')
202 setHoveredMenuItem(item)
203 },
204 }) satisfies ContextType,
205 [
206 measurement,
207 returnLocationSV,
208 setMeasurement,
209 onCompletedClose,
210 isFocused,
211 animationSV,
212 translationSV,
213 hoverablesSV,
214 onHoverableTouchUp,
215 hoveredMenuItem,
216 setHoveredMenuItem,
217 playHaptic,
218 mode,
219 ],
220 )
221
222 useEffect(() => {
223 if (IS_ANDROID && context.isOpen) {
224 const listener = BackHandler.addEventListener('hardwareBackPress', () => {
225 context.close()
226 return true
227 })
228
229 return () => listener.remove()
230 }
231 }, [context])
232
233 return <Context.Provider value={context}>{children}</Context.Provider>
234}
235
236export function Trigger({children, label, contentLabel, style}: TriggerProps) {
237 const context = useContextMenuContext()
238 const playHaptic = useHaptics()
239 const insets = useSafeAreaInsets()
240 const ref = useRef<View>(null)
241 const isFocused = useIsFocused()
242 const [image, setImage] = useState<string | null>(null)
243 const [pendingMeasurement, setPendingMeasurement] = useState<{
244 measurement: Measurement
245 mode: 'full' | 'auxiliary-only'
246 } | null>(null)
247
248 const open = useNonReactiveCallback(
249 async (mode: 'full' | 'auxiliary-only') => {
250 playHaptic()
251 const [measurement, capture] = await Promise.all([
252 measureView(ref.current, insets),
253 captureRef(ref, {result: 'data-uri'}).catch(err => {
254 logger.error(err instanceof Error ? err : String(err), {
255 message: 'Failed to capture image of context menu trigger',
256 })
257 // will cause the image to fail to load, but it will get handled gracefully
258 return '<failed capture>'
259 }),
260 ])
261 Keyboard.dismiss()
262 setImage(capture)
263 if (measurement) {
264 setPendingMeasurement({measurement, mode})
265 }
266 },
267 )
268
269 // after keyboard hides, the position might change - set a return location
270 useEffect(() => {
271 if (context.isOpen && context.measurement) {
272 const hide = KeyboardEvents.addListener('keyboardDidHide', () => {
273 measureView(ref.current, insets)
274 .then(newMeasurement => {
275 if (!newMeasurement || !context.measurement) return
276 if (
277 newMeasurement.x !== context.measurement.x ||
278 newMeasurement.y !== context.measurement.y
279 ) {
280 context.returnLocationSV.set({
281 x: newMeasurement.x,
282 y: newMeasurement.y,
283 })
284 }
285 })
286 .catch(() => {})
287 })
288
289 return () => {
290 hide.remove()
291 }
292 }
293 }, [context, insets])
294
295 const doubleTapGesture = useMemo(() => {
296 return Gesture.Tap()
297 .numberOfTaps(2)
298 .hitSlop(HITSLOP_10)
299 .onEnd(() => void open('auxiliary-only'))
300 .runOnJS(true)
301 }, [open])
302
303 const {
304 hoverablesSV,
305 setHoveredMenuItem,
306 onTouchUpMenuItem,
307 translationSV,
308 animationSV,
309 } = context
310 const hoveredItemSV = useSharedValue<string | null>(null)
311
312 useAnimatedReaction(
313 () => hoveredItemSV.get(),
314 (hovered, prev) => {
315 if (hovered !== prev) {
316 runOnJS(setHoveredMenuItem)(hovered)
317 }
318 },
319 )
320
321 const pressAndHoldGesture = useMemo(() => {
322 return Gesture.Pan()
323 .activateAfterLongPress(500)
324 .cancelsTouchesInView(false)
325 .averageTouches(true)
326 .onStart(() => {
327 'worklet'
328 runOnJS(open)('full')
329 })
330 .onUpdate(evt => {
331 'worklet'
332 const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
333 hoveredItemSV.set(item)
334 })
335 .onEnd(() => {
336 'worklet'
337 // don't recalculate hovered item - if they haven't moved their finger from
338 // the initial press, it's jarring to then select the item underneath
339 // as the menu may have slid into place beneath their finger
340 const item = hoveredItemSV.get()
341 if (item) {
342 runOnJS(onTouchUpMenuItem)(item)
343 }
344 })
345 }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
346
347 const composedGestures = Gesture.Exclusive(
348 doubleTapGesture,
349 pressAndHoldGesture,
350 )
351
352 const measurement = context.measurement || pendingMeasurement?.measurement
353
354 return (
355 <>
356 <GestureDetector gesture={composedGestures}>
357 <View ref={ref} style={[{opacity: context.isOpen ? 0 : 1}, style]}>
358 {children({
359 IS_NATIVE: true,
360 control: {isOpen: context.isOpen, open},
361 state: {
362 pressed: false,
363 hovered: false,
364 focused: false,
365 },
366 props: {
367 ref: null,
368 onPress: null,
369 onFocus: null,
370 onBlur: null,
371 onPressIn: null,
372 onPressOut: null,
373 accessibilityHint: null,
374 accessibilityLabel: label,
375 accessibilityRole: null,
376 },
377 })}
378 </View>
379 </GestureDetector>
380 {isFocused && image && measurement && (
381 <Portal>
382 <TriggerClone
383 label={contentLabel}
384 translation={translationSV}
385 animation={animationSV}
386 image={image}
387 measurement={measurement}
388 returnLocation={context.returnLocationSV}
389 onDisplay={() => {
390 if (pendingMeasurement) {
391 context.open(
392 pendingMeasurement.measurement,
393 pendingMeasurement.mode,
394 )
395 setPendingMeasurement(null)
396 }
397 }}
398 />
399 </Portal>
400 )}
401 </>
402 )
403}
404
405/**
406 * an image of the underlying trigger with a grow animation
407 */
408function TriggerClone({
409 translation,
410 animation,
411 image,
412 measurement,
413 returnLocation,
414 onDisplay,
415 label,
416}: {
417 translation: SharedValue<number>
418 animation: SharedValue<number>
419 image: string
420 measurement: Measurement
421 returnLocation: SharedValue<{x: number; y: number} | null>
422 onDisplay: () => void
423 label: string
424}) {
425 const {_} = useLingui()
426
427 const animatedStyles = useAnimatedStyle(() => {
428 const anim = animation.get()
429 const ret = returnLocation.get()
430 const returnOffsetX = ret
431 ? interpolate(anim, [0, 1], [ret.x - measurement.x, 0])
432 : 0
433 const returnOffsetY = ret
434 ? interpolate(anim, [0, 1], [ret.y - measurement.y, 0])
435 : 0
436
437 return {
438 transform: [
439 {translateX: returnOffsetX},
440 {translateY: translation.get() * anim + returnOffsetY},
441 ],
442 }
443 })
444
445 const handleError = useCallback(
446 (evt: ImageErrorEventData) => {
447 logger.error('Context menu image load error', {message: evt.error})
448 onDisplay()
449 },
450 [onDisplay],
451 )
452
453 return (
454 <Animated.View
455 style={[
456 a.absolute,
457 {
458 top: measurement.y,
459 left: measurement.x,
460 width: measurement.width,
461 height: measurement.height,
462 },
463 a.z_10,
464 a.pointer_events_none,
465 animatedStyles,
466 ]}>
467 <Image
468 onDisplay={onDisplay}
469 onError={handleError}
470 source={image}
471 style={{
472 width: measurement.width,
473 height: measurement.height,
474 }}
475 accessibilityLabel={label}
476 accessibilityHint={_(msg`The subject of the context menu`)}
477 accessibilityIgnoresInvertColors={false}
478 />
479 </Animated.View>
480 )
481}
482
483export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
484 const context = useContextMenuContext()
485 const {width: screenWidth} = useWindowDimensions()
486 const {top: topInset} = useSafeAreaInsets()
487 const ensureOnScreenTranslationSV = useSharedValue(0)
488
489 const {isOpen, mode, measurement, translationSV, animationSV} = context
490
491 const animatedStyle = useAnimatedStyle(() => {
492 return {
493 opacity: clamp(animationSV.get(), 0, 1),
494 transform: [
495 {
496 translateY:
497 (ensureOnScreenTranslationSV.get() || translationSV.get()) *
498 animationSV.get(),
499 },
500 {scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
501 ],
502 }
503 })
504
505 const menuContext = useMemo(() => ({align}), [align])
506
507 const onLayout = useCallback(() => {
508 if (!measurement) return
509
510 let translation = 0
511
512 // vibes based, just assuming it'll fit within this space. revisit if we use
513 // AuxiliaryView for something tall
514 const TOP_INSET = topInset + 80
515
516 const distanceMessageFromTop = measurement.y - TOP_INSET
517 if (distanceMessageFromTop < 0) {
518 translation = -distanceMessageFromTop
519 }
520
521 // normally, the context menu is responsible for measuring itself and moving everything into the right place
522 // however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
523 if (mode === 'auxiliary-only') {
524 translationSV.set(translation)
525 ensureOnScreenTranslationSV.set(0)
526 }
527 // however, we also need to make sure that for super tall triggers, we don't go off the screen
528 // so we have an additional cap on the standard transform every other element has
529 // note: this breaks the press-and-hold gesture for the reaction items. unfortunately I think
530 // we'll just have to live with it for now, fixing it would be possible but be a large complexity
531 // increase for an edge case
532 else {
533 ensureOnScreenTranslationSV.set(translation)
534 }
535 }, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
536
537 if (!isOpen || !measurement) return null
538
539 return (
540 <Portal>
541 <Context.Provider value={context}>
542 <MenuContext.Provider value={menuContext}>
543 <Animated.View
544 onLayout={onLayout}
545 style={[
546 a.absolute,
547 {
548 top: measurement.y,
549 transformOrigin:
550 align === 'left' ? 'bottom left' : 'bottom right',
551 },
552 align === 'left'
553 ? {left: measurement.x}
554 : {right: screenWidth - measurement.x - measurement.width},
555 animatedStyle,
556 a.z_20,
557 ]}>
558 {children}
559 </Animated.View>
560 </MenuContext.Provider>
561 </Context.Provider>
562 </Portal>
563 )
564}
565
566const MENU_WIDTH = 240
567
568export function Outer({
569 children,
570 style,
571 align = 'left',
572}: {
573 children: React.ReactNode
574 style?: StyleProp<ViewStyle>
575 align?: 'left' | 'right'
576}) {
577 const t = useTheme()
578 const context = useContextMenuContext()
579 const insets = useSafeAreaInsets()
580 const frame = useSafeAreaFrame()
581 const {width: screenWidth} = useWindowDimensions()
582
583 const {animationSV, translationSV} = context
584
585 const animatedContainerStyle = useAnimatedStyle(() => ({
586 transform: [{translateY: translationSV.get() * animationSV.get()}],
587 }))
588
589 const animatedStyle = useAnimatedStyle(() => ({
590 opacity: clamp(animationSV.get(), 0, 1),
591 transform: [{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}],
592 }))
593
594 const onLayout = useCallback(
595 (evt: LayoutChangeEvent) => {
596 if (!context.measurement) return // should not happen
597 let translation = 0
598
599 // pure vibes based
600 const TOP_INSET = insets.top + 80
601 const BOTTOM_INSET_IOS = insets.bottom + 20
602 const BOTTOM_INSET_ANDROID = insets.bottom + 12
603
604 const {height} = evt.nativeEvent.layout
605 const topPosition =
606 context.measurement.y + context.measurement.height + tokens.space.xs
607 const bottomPosition = topPosition + height
608 const safeAreaBottomLimit =
609 frame.height -
610 platform({
611 ios: BOTTOM_INSET_IOS,
612 android: BOTTOM_INSET_ANDROID,
613 default: 0,
614 })
615 const diff = bottomPosition - safeAreaBottomLimit
616 if (diff > 0) {
617 translation = -diff
618 } else {
619 const distanceMessageFromTop = context.measurement.y - TOP_INSET
620 if (distanceMessageFromTop < 0) {
621 translation = -Math.max(distanceMessageFromTop, diff)
622 }
623 }
624
625 if (translation !== 0) {
626 translationSV.set(translation)
627 }
628 },
629 [context.measurement, frame.height, insets, translationSV],
630 )
631
632 const menuContext = useMemo(() => ({align}), [align])
633
634 if (!context.isOpen || !context.measurement) return null
635
636 return (
637 <Portal>
638 <Context.Provider value={context}>
639 <MenuContext.Provider value={menuContext}>
640 <Backdrop animation={animationSV} onPress={context.close} />
641 {context.mode === 'full' && (
642 /* containing element - stays the same size, so we measure it
643 to determine if a translation is necessary. also has the positioning */
644 <Animated.View
645 onLayout={onLayout}
646 style={[
647 a.absolute,
648 a.z_10,
649 a.mt_xs,
650 {
651 width: MENU_WIDTH,
652 top: context.measurement.y + context.measurement.height,
653 },
654 align === 'left'
655 ? {left: context.measurement.x}
656 : {
657 right:
658 screenWidth -
659 context.measurement.x -
660 context.measurement.width,
661 },
662 animatedContainerStyle,
663 ]}>
664 {/* scaling element - has the scale/fade animation on it */}
665 <Animated.View
666 style={[
667 a.rounded_md,
668 a.shadow_md,
669 t.atoms.bg_contrast_25,
670 a.w_full,
671 // @ts-ignore react-native-web expects string, and this file is platform-split -sfn
672 // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
673 // in the typecheck CI - presumably because of RNW overriding the types
674 {
675 transformOrigin:
676 // "top right" doesn't seem to work on android, so set explicitly in pixels
677 align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
678 },
679 animatedStyle,
680 style,
681 ]}>
682 {/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
683 so put the shadow on the scaling element and the overflow on the innermost element */}
684 <View
685 style={[
686 a.flex_1,
687 a.rounded_md,
688 a.overflow_hidden,
689 a.border,
690 t.atoms.border_contrast_low,
691 ]}>
692 {flattenReactChildren(children).map((child, i) => {
693 return React.isValidElement(child) &&
694 (child.type === Item || child.type === Divider) ? (
695 <React.Fragment key={i}>
696 {i > 0 ? (
697 <View
698 style={[a.border_b, t.atoms.border_contrast_low]}
699 />
700 ) : null}
701 {React.cloneElement(child, {
702 // @ts-expect-error not typed
703 style: {
704 borderRadius: 0,
705 borderWidth: 0,
706 },
707 })}
708 </React.Fragment>
709 ) : null
710 })}
711 </View>
712 </Animated.View>
713 </Animated.View>
714 )}
715 </MenuContext.Provider>
716 </Context.Provider>
717 </Portal>
718 )
719}
720
721export function Item({
722 children,
723 label,
724 unstyled,
725 style,
726 onPress,
727 position,
728 ...rest
729}: ItemProps) {
730 const t = useTheme()
731 const context = useContextMenuContext()
732 const playHaptic = useHaptics()
733 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
734 const {
735 state: pressed,
736 onIn: onPressIn,
737 onOut: onPressOut,
738 } = useInteractionState()
739 const id = useId()
740 const {align} = useContextMenuMenuContext()
741
742 const {close, measurement, registerHoverable} = context
743
744 const handleLayout = useCallback(
745 (evt: LayoutChangeEvent) => {
746 if (!measurement) return // should be impossible
747
748 const layout = evt.nativeEvent.layout
749
750 const yOffset = position
751 ? position.y
752 : measurement.y + measurement.height + tokens.space.xs
753 const xOffset = position
754 ? position.x
755 : align === 'left'
756 ? measurement.x
757 : measurement.x + measurement.width - layout.width
758
759 registerHoverable(
760 id,
761 {
762 width: layout.width,
763 height: layout.height,
764 y: yOffset + layout.y,
765 x: xOffset + layout.x,
766 },
767 () => {
768 close()
769 onPress()
770 },
771 )
772 },
773 [id, measurement, registerHoverable, close, onPress, align, position],
774 )
775
776 const itemContext = useMemo(
777 () => ({disabled: Boolean(rest.disabled)}),
778 [rest.disabled],
779 )
780
781 return (
782 <Pressable
783 {...rest}
784 onLayout={handleLayout}
785 accessibilityHint=""
786 accessibilityLabel={label}
787 onFocus={onFocus}
788 onBlur={onBlur}
789 onPress={e => {
790 close()
791 onPress?.(e)
792 }}
793 onPressIn={e => {
794 onPressIn()
795 rest.onPressIn?.(e)
796 playHaptic('Light')
797 }}
798 onPressOut={e => {
799 onPressOut()
800 rest.onPressOut?.(e)
801 }}
802 style={[
803 !unstyled && [
804 a.flex_row,
805 a.align_center,
806 a.gap_sm,
807 a.px_md,
808 a.rounded_md,
809 a.border,
810 t.atoms.bg_contrast_25,
811 t.atoms.border_contrast_low,
812 {minHeight: 44, paddingVertical: 10},
813 (focused || pressed || context.hoveredMenuItem === id) &&
814 !rest.disabled &&
815 t.atoms.bg_contrast_50,
816 ],
817 style,
818 ]}>
819 <ItemContext.Provider value={itemContext}>
820 {typeof children === 'function'
821 ? children(
822 (focused || pressed || context.hoveredMenuItem === id) &&
823 !rest.disabled,
824 )
825 : children}
826 </ItemContext.Provider>
827 </Pressable>
828 )
829}
830
831export function ItemText({children, style}: ItemTextProps) {
832 const t = useTheme()
833 const {disabled} = useContextMenuItemContext()
834 return (
835 <Text
836 numberOfLines={2}
837 ellipsizeMode="middle"
838 style={[
839 a.flex_1,
840 a.text_md,
841 a.font_semi_bold,
842 t.atoms.text_contrast_high,
843 {paddingTop: 3},
844 style,
845 disabled && t.atoms.text_contrast_low,
846 ]}>
847 {children}
848 </Text>
849 )
850}
851
852export function ItemIcon({icon: Comp}: ItemIconProps) {
853 const t = useTheme()
854 const {disabled} = useContextMenuItemContext()
855 return (
856 <Comp
857 size="lg"
858 fill={
859 disabled
860 ? t.atoms.text_contrast_low.color
861 : t.atoms.text_contrast_medium.color
862 }
863 />
864 )
865}
866
867export function ItemRadio({selected}: {selected: boolean}) {
868 const t = useTheme()
869 const enableSquareButtons = useEnableSquareButtons()
870 return (
871 <View
872 style={[
873 a.justify_center,
874 a.align_center,
875 enableSquareButtons ? a.rounded_sm : a.rounded_full,
876 t.atoms.border_contrast_high,
877 {
878 borderWidth: 1,
879 height: 20,
880 width: 20,
881 },
882 ]}>
883 {selected ? (
884 <View
885 style={[
886 a.absolute,
887 enableSquareButtons ? a.rounded_sm : a.rounded_full,
888 {height: 14, width: 14},
889 selected ? {backgroundColor: t.palette.primary_500} : {},
890 ]}
891 />
892 ) : null}
893 </View>
894 )
895}
896
897export function LabelText({children}: {children: React.ReactNode}) {
898 const t = useTheme()
899 return (
900 <Text
901 style={[
902 a.font_semi_bold,
903 t.atoms.text_contrast_medium,
904 {marginBottom: -8},
905 ]}>
906 {children}
907 </Text>
908 )
909}
910
911export function Divider() {
912 const t = useTheme()
913 return (
914 <View
915 style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]}
916 />
917 )
918}
919
920function measureView(view: View | null, insets: EdgeInsets) {
921 if (!view) return Promise.resolve(null)
922 return new Promise<Measurement>(resolve => {
923 view?.measureInWindow((x, y, width, height) =>
924 resolve({
925 x,
926 y:
927 y +
928 platform({
929 default: 0,
930 android: insets.top, // not included in measurement
931 }),
932 width,
933 height,
934 }),
935 )
936 })
937}
938
939function getHoveredHoverable(
940 evt:
941 | GestureStateChangeEvent<PanGestureHandlerEventPayload>
942 | GestureUpdateEvent<PanGestureHandlerEventPayload>,
943 hoverables: SharedValue<Record<string, {id: string; rect: Measurement}>>,
944 translation: SharedValue<number>,
945) {
946 'worklet'
947
948 const x = evt.absoluteX
949 const y = evt.absoluteY
950 const yOffset = translation.get()
951
952 const rects = Object.values(hoverables.get())
953
954 for (const {id, rect} of rects) {
955 const isWithinLeftBound = x >= rect.x
956 const isWithinRightBound = x <= rect.x + rect.width
957 const isWithinTopBound = y >= rect.y + yOffset
958 const isWithinBottomBound = y <= rect.y + rect.height + yOffset
959
960 if (
961 isWithinLeftBound &&
962 isWithinRightBound &&
963 isWithinTopBound &&
964 isWithinBottomBound
965 ) {
966 return id
967 }
968 }
969
970 return null
971}