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