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