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