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