Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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({
240 children,
241 label,
242 contentLabel,
243 style,
244 onTap,
245}: TriggerProps) {
246 const context = useContextMenuContext()
247 const playHaptic = useHaptics()
248 const insets = useSafeAreaInsets()
249 const ref = useRef<View>(null)
250 const isFocused = useIsFocused()
251 const [image, setImage] = useState<string | null>(null)
252 const [pendingMeasurement, setPendingMeasurement] = useState<{
253 measurement: Measurement
254 mode: 'full' | 'auxiliary-only'
255 } | null>(null)
256
257 const open = useNonReactiveCallback(
258 async (mode: 'full' | 'auxiliary-only') => {
259 playHaptic()
260 const [measurement, capture] = await Promise.all([
261 measureView(ref.current, insets),
262 captureRef(ref, {result: 'data-uri'}).catch(err => {
263 logger.error(err instanceof Error ? err : String(err), {
264 message: 'Failed to capture image of context menu trigger',
265 })
266 // will cause the image to fail to load, but it will get handled gracefully
267 return '<failed capture>'
268 }),
269 ])
270 Keyboard.dismiss()
271 setImage(capture)
272 if (measurement) {
273 setPendingMeasurement({measurement, mode})
274 }
275 },
276 )
277
278 // after keyboard hides, the position might change - set a return location
279 useEffect(() => {
280 if (context.isOpen && context.measurement) {
281 const hide = KeyboardEvents.addListener('keyboardDidHide', () => {
282 measureView(ref.current, insets)
283 .then(newMeasurement => {
284 if (!newMeasurement || !context.measurement) return
285 if (
286 newMeasurement.x !== context.measurement.x ||
287 newMeasurement.y !== context.measurement.y
288 ) {
289 context.returnLocationSV.set({
290 x: newMeasurement.x,
291 y: newMeasurement.y,
292 })
293 }
294 })
295 .catch(() => {})
296 })
297
298 return () => {
299 hide.remove()
300 }
301 }
302 }, [context, insets])
303
304 const tapGesture = useMemo(() => {
305 const gesture = Gesture.Tap()
306 .numberOfTaps(1)
307 .cancelsTouchesInView(false)
308 .runOnJS(true)
309 if (onTap) {
310 gesture.onEnd(() => void onTap())
311 }
312 return gesture
313 }, [onTap])
314
315 const doubleTapGesture = useMemo(() => {
316 return Gesture.Tap()
317 .numberOfTaps(2)
318 .hitSlop(HITSLOP_10)
319 .onEnd(() => void open('auxiliary-only'))
320 .runOnJS(true)
321 }, [open])
322
323 const {
324 hoverablesSV,
325 setHoveredMenuItem,
326 onTouchUpMenuItem,
327 translationSV,
328 animationSV,
329 } = context
330 const hoveredItemSV = useSharedValue<string | null>(null)
331
332 useAnimatedReaction(
333 () => hoveredItemSV.get(),
334 (hovered, prev) => {
335 if (hovered !== prev) {
336 runOnJS(setHoveredMenuItem)(hovered)
337 }
338 },
339 )
340
341 const pressAndHoldGesture = useMemo(() => {
342 return Gesture.Pan()
343 .activateAfterLongPress(500)
344 .cancelsTouchesInView(false)
345 .averageTouches(true)
346 .onStart(() => {
347 'worklet'
348 runOnJS(open)('full')
349 })
350 .onUpdate(evt => {
351 'worklet'
352 const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
353 hoveredItemSV.set(item)
354 })
355 .onEnd(() => {
356 'worklet'
357 // don't recalculate hovered item - if they haven't moved their finger from
358 // the initial press, it's jarring to then select the item underneath
359 // as the menu may have slid into place beneath their finger
360 const item = hoveredItemSV.get()
361 if (item) {
362 runOnJS(onTouchUpMenuItem)(item)
363 }
364 })
365 }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
366
367 // Order matters here: doubleTapGesture must come before tapGesture.
368 const composedGestures = Gesture.Exclusive(
369 doubleTapGesture,
370 tapGesture,
371 pressAndHoldGesture,
372 )
373
374 const measurement = context.measurement || pendingMeasurement?.measurement
375
376 return (
377 <>
378 <GestureDetector gesture={composedGestures}>
379 <View ref={ref} style={[{opacity: context.isOpen ? 0 : 1}, style]}>
380 {children({
381 IS_NATIVE: true,
382 control: {isOpen: context.isOpen, open},
383 state: {
384 pressed: false,
385 hovered: false,
386 focused: false,
387 },
388 props: {
389 ref: null,
390 onPress: null,
391 onFocus: null,
392 onBlur: null,
393 onPressIn: null,
394 onPressOut: null,
395 accessibilityHint: null,
396 accessibilityLabel: label,
397 accessibilityRole: null,
398 },
399 })}
400 </View>
401 </GestureDetector>
402 {isFocused && image && measurement && (
403 <Portal>
404 <TriggerClone
405 label={contentLabel}
406 translation={translationSV}
407 animation={animationSV}
408 image={image}
409 measurement={measurement}
410 returnLocation={context.returnLocationSV}
411 onDisplay={() => {
412 if (pendingMeasurement) {
413 context.open(
414 pendingMeasurement.measurement,
415 pendingMeasurement.mode,
416 )
417 setPendingMeasurement(null)
418 }
419 }}
420 />
421 </Portal>
422 )}
423 </>
424 )
425}
426
427/**
428 * an image of the underlying trigger with a grow animation
429 */
430function TriggerClone({
431 translation,
432 animation,
433 image,
434 measurement,
435 returnLocation,
436 onDisplay,
437 label,
438}: {
439 translation: SharedValue<number>
440 animation: SharedValue<number>
441 image: string
442 measurement: Measurement
443 returnLocation: SharedValue<{x: number; y: number} | null>
444 onDisplay: () => void
445 label: string
446}) {
447 const {_} = useLingui()
448
449 const animatedStyles = useAnimatedStyle(() => {
450 const anim = animation.get()
451 const ret = returnLocation.get()
452 const returnOffsetX = ret
453 ? interpolate(anim, [0, 1], [ret.x - measurement.x, 0])
454 : 0
455 const returnOffsetY = ret
456 ? interpolate(anim, [0, 1], [ret.y - measurement.y, 0])
457 : 0
458
459 return {
460 transform: [
461 {translateX: returnOffsetX},
462 {translateY: translation.get() * anim + returnOffsetY},
463 ],
464 }
465 })
466
467 const handleError = useCallback(
468 (evt: ImageErrorEventData) => {
469 logger.error('Context menu image load error', {message: evt.error})
470 onDisplay()
471 },
472 [onDisplay],
473 )
474
475 return (
476 <Animated.View
477 style={[
478 a.absolute,
479 {
480 top: measurement.y,
481 left: measurement.x,
482 width: measurement.width,
483 height: measurement.height,
484 },
485 a.z_10,
486 a.pointer_events_none,
487 animatedStyles,
488 ]}>
489 <Image
490 onDisplay={onDisplay}
491 onError={handleError}
492 source={image}
493 style={{
494 width: measurement.width,
495 height: measurement.height,
496 }}
497 accessibilityLabel={label}
498 accessibilityHint={_(msg`The subject of the context menu`)}
499 accessibilityIgnoresInvertColors={false}
500 />
501 </Animated.View>
502 )
503}
504
505export function AuxiliaryView({
506 children,
507 align = 'left',
508 style,
509}: AuxiliaryViewProps) {
510 const context = useContextMenuContext()
511 const {width: screenWidth} = useWindowDimensions()
512 const {top: topInset} = useSafeAreaInsets()
513 const ensureOnScreenTranslationSV = useSharedValue(0)
514
515 const {isOpen, mode, measurement, translationSV, animationSV} = context
516
517 const animatedStyle = useAnimatedStyle(() => {
518 return {
519 opacity: clamp(animationSV.get(), 0, 1),
520 transform: [
521 {
522 translateY:
523 (ensureOnScreenTranslationSV.get() || translationSV.get()) *
524 animationSV.get(),
525 },
526 {scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
527 ],
528 }
529 })
530
531 const menuContext = useMemo(() => ({align}), [align])
532
533 const onLayout = useCallback(() => {
534 if (!measurement) return
535
536 let translation = 0
537
538 // vibes based, just assuming it'll fit within this space. revisit if we use
539 // AuxiliaryView for something tall
540 const TOP_INSET = topInset + 80
541
542 const distanceMessageFromTop = measurement.y - TOP_INSET
543 if (distanceMessageFromTop < 0) {
544 translation = -distanceMessageFromTop
545 }
546
547 // normally, the context menu is responsible for measuring itself and moving everything into the right place
548 // however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
549 if (mode === 'auxiliary-only') {
550 translationSV.set(translation)
551 ensureOnScreenTranslationSV.set(0)
552 }
553 // however, we also need to make sure that for super tall triggers, we don't go off the screen
554 // so we have an additional cap on the standard transform every other element has
555 // note: this breaks the press-and-hold gesture for the reaction items. unfortunately I think
556 // we'll just have to live with it for now, fixing it would be possible but be a large complexity
557 // increase for an edge case
558 else {
559 ensureOnScreenTranslationSV.set(translation)
560 }
561 }, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
562
563 if (!isOpen || !measurement) return null
564
565 return (
566 <Portal>
567 <Context.Provider value={context}>
568 <MenuContext.Provider value={menuContext}>
569 <Animated.View
570 onLayout={onLayout}
571 style={[
572 a.absolute,
573 {
574 top: measurement.y,
575 transformOrigin:
576 align === 'left' ? 'bottom left' : 'bottom right',
577 },
578 align === 'left'
579 ? {left: measurement.x}
580 : {right: screenWidth - measurement.x - measurement.width},
581 animatedStyle,
582 a.z_20,
583 style,
584 ]}>
585 {children}
586 </Animated.View>
587 </MenuContext.Provider>
588 </Context.Provider>
589 </Portal>
590 )
591}
592
593const MENU_WIDTH = 240
594
595export function Outer({
596 children,
597 style,
598 align = 'left',
599}: {
600 children: React.ReactNode
601 style?: StyleProp<ViewStyle>
602 align?: 'left' | 'right'
603}) {
604 const t = useTheme()
605 const context = useContextMenuContext()
606 const insets = useSafeAreaInsets()
607 const frame = useSafeAreaFrame()
608 const {width: screenWidth} = useWindowDimensions()
609
610 const {animationSV, translationSV} = context
611
612 const animatedContainerStyle = useAnimatedStyle(() => ({
613 transform: [{translateY: translationSV.get() * animationSV.get()}],
614 }))
615
616 const animatedStyle = useAnimatedStyle(() => ({
617 opacity: clamp(animationSV.get(), 0, 1),
618 transform: [{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}],
619 }))
620
621 const onLayout = useCallback(
622 (evt: LayoutChangeEvent) => {
623 if (!context.measurement) return // should not happen
624 let translation = 0
625
626 // pure vibes based
627 const TOP_INSET = insets.top + 80
628 const BOTTOM_INSET_IOS = insets.bottom + 20
629 const BOTTOM_INSET_ANDROID = insets.bottom + 12
630
631 const {height} = evt.nativeEvent.layout
632 const topPosition =
633 context.measurement.y + context.measurement.height + tokens.space.xs
634 const bottomPosition = topPosition + height
635 const safeAreaBottomLimit =
636 frame.height -
637 platform({
638 ios: BOTTOM_INSET_IOS,
639 android: BOTTOM_INSET_ANDROID,
640 default: 0,
641 })
642 const diff = bottomPosition - safeAreaBottomLimit
643 if (diff > 0) {
644 translation = -diff
645 } else {
646 const distanceMessageFromTop = context.measurement.y - TOP_INSET
647 if (distanceMessageFromTop < 0) {
648 translation = -Math.max(distanceMessageFromTop, diff)
649 }
650 }
651
652 if (translation !== 0) {
653 translationSV.set(translation)
654 }
655 },
656 [context.measurement, frame.height, insets, translationSV],
657 )
658
659 const menuContext = useMemo(() => ({align}), [align])
660
661 if (!context.isOpen || !context.measurement) return null
662
663 return (
664 <Portal>
665 <Context.Provider value={context}>
666 <MenuContext.Provider value={menuContext}>
667 <Backdrop animation={animationSV} onPress={context.close} />
668 {context.mode === 'full' && (
669 /* containing element - stays the same size, so we measure it
670 to determine if a translation is necessary. also has the positioning */
671 <Animated.View
672 onLayout={onLayout}
673 style={[
674 a.absolute,
675 a.z_10,
676 a.mt_xs,
677 {
678 width: MENU_WIDTH,
679 top: context.measurement.y + context.measurement.height,
680 },
681 align === 'left'
682 ? {left: context.measurement.x}
683 : {
684 right:
685 screenWidth -
686 context.measurement.x -
687 context.measurement.width,
688 },
689 animatedContainerStyle,
690 ]}>
691 {/* scaling element - has the scale/fade animation on it */}
692 <Animated.View
693 style={[
694 a.rounded_md,
695 a.shadow_md,
696 t.atoms.bg_contrast_25,
697 a.w_full,
698 // @ts-ignore react-native-web expects string, and this file is platform-split -sfn
699 // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
700 // in the typecheck CI - presumably because of RNW overriding the types
701 {
702 transformOrigin:
703 // "top right" doesn't seem to work on android, so set explicitly in pixels
704 align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
705 },
706 animatedStyle,
707 style,
708 ]}>
709 {/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
710 so put the shadow on the scaling element and the overflow on the innermost element */}
711 <View
712 style={[
713 a.flex_1,
714 a.rounded_md,
715 a.overflow_hidden,
716 a.border,
717 t.atoms.border_contrast_low,
718 ]}>
719 {flattenReactChildren(children).map((child, i) => {
720 return isValidElement(child) &&
721 (child.type === Item || child.type === Divider) ? (
722 <Fragment key={i}>
723 {i > 0 ? (
724 <View
725 style={[a.border_b, t.atoms.border_contrast_low]}
726 />
727 ) : null}
728 {cloneElement(child, {
729 // @ts-expect-error not typed
730 style: {
731 borderRadius: 0,
732 borderWidth: 0,
733 },
734 })}
735 </Fragment>
736 ) : null
737 })}
738 </View>
739 </Animated.View>
740 </Animated.View>
741 )}
742 </MenuContext.Provider>
743 </Context.Provider>
744 </Portal>
745 )
746}
747
748export function Item({
749 children,
750 label,
751 unstyled,
752 style,
753 onPress,
754 position,
755 ...rest
756}: ItemProps) {
757 const t = useTheme()
758 const context = useContextMenuContext()
759 const playHaptic = useHaptics()
760 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
761 const {
762 state: pressed,
763 onIn: onPressIn,
764 onOut: onPressOut,
765 } = useInteractionState()
766 const id = useId()
767 const {align} = useContextMenuMenuContext()
768
769 const {close, measurement, registerHoverable} = context
770
771 const handleLayout = useCallback(
772 (evt: LayoutChangeEvent) => {
773 if (!measurement) return // should be impossible
774
775 const layout = evt.nativeEvent.layout
776
777 const yOffset = position
778 ? position.y
779 : measurement.y + measurement.height + tokens.space.xs
780 const xOffset = position
781 ? position.x
782 : align === 'left'
783 ? measurement.x
784 : measurement.x + measurement.width - layout.width
785
786 registerHoverable(
787 id,
788 {
789 width: layout.width,
790 height: layout.height,
791 y: yOffset + layout.y,
792 x: xOffset + layout.x,
793 },
794 () => {
795 close()
796 onPress()
797 },
798 )
799 },
800 [id, measurement, registerHoverable, close, onPress, align, position],
801 )
802
803 const itemContext = useMemo(
804 () => ({disabled: Boolean(rest.disabled)}),
805 [rest.disabled],
806 )
807
808 return (
809 <Pressable
810 {...rest}
811 onLayout={handleLayout}
812 accessibilityHint=""
813 accessibilityLabel={label}
814 onFocus={onFocus}
815 onBlur={onBlur}
816 onPress={e => {
817 close()
818 onPress?.(e)
819 }}
820 onPressIn={e => {
821 onPressIn()
822 rest.onPressIn?.(e)
823 playHaptic('Light')
824 }}
825 onPressOut={e => {
826 onPressOut()
827 rest.onPressOut?.(e)
828 }}
829 style={[
830 !unstyled && [
831 a.flex_row,
832 a.align_center,
833 a.gap_sm,
834 a.px_md,
835 a.rounded_md,
836 a.border,
837 t.atoms.bg_contrast_25,
838 t.atoms.border_contrast_low,
839 {minHeight: 44, paddingVertical: 10},
840 (focused || pressed || context.hoveredMenuItem === id) &&
841 !rest.disabled &&
842 t.atoms.bg_contrast_50,
843 ],
844 style,
845 ]}>
846 <ItemContext.Provider value={itemContext}>
847 {typeof children === 'function'
848 ? children(
849 (focused || pressed || context.hoveredMenuItem === id) &&
850 !rest.disabled,
851 )
852 : children}
853 </ItemContext.Provider>
854 </Pressable>
855 )
856}
857
858export function ItemText({children, style}: ItemTextProps) {
859 const t = useTheme()
860 const {disabled} = useContextMenuItemContext()
861 return (
862 <Text
863 numberOfLines={2}
864 ellipsizeMode="middle"
865 style={[
866 a.flex_1,
867 a.text_md,
868 a.font_semi_bold,
869 t.atoms.text_contrast_high,
870 {paddingTop: 3},
871 style,
872 disabled && t.atoms.text_contrast_low,
873 ]}>
874 {children}
875 </Text>
876 )
877}
878
879export function ItemIcon({icon: Comp}: ItemIconProps) {
880 const t = useTheme()
881 const {disabled} = useContextMenuItemContext()
882 return (
883 <Comp
884 size="lg"
885 fill={
886 disabled
887 ? t.atoms.text_contrast_low.color
888 : t.atoms.text_contrast_medium.color
889 }
890 />
891 )
892}
893
894export function ItemRadio({selected}: {selected: boolean}) {
895 const t = useTheme()
896 const enableSquareButtons = useEnableSquareButtons()
897 return (
898 <View
899 style={[
900 a.justify_center,
901 a.align_center,
902 enableSquareButtons ? a.rounded_sm : a.rounded_full,
903 t.atoms.border_contrast_high,
904 {
905 borderWidth: 1,
906 height: 20,
907 width: 20,
908 },
909 ]}>
910 {selected ? (
911 <View
912 style={[
913 a.absolute,
914 enableSquareButtons ? a.rounded_sm : a.rounded_full,
915 {height: 14, width: 14},
916 selected ? {backgroundColor: t.palette.primary_500} : {},
917 ]}
918 />
919 ) : null}
920 </View>
921 )
922}
923
924export function LabelText({children}: {children: React.ReactNode}) {
925 const t = useTheme()
926 return (
927 <Text
928 style={[
929 a.font_semi_bold,
930 t.atoms.text_contrast_medium,
931 {marginBottom: -8},
932 ]}>
933 {children}
934 </Text>
935 )
936}
937
938export function Divider() {
939 const t = useTheme()
940 return (
941 <View
942 style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]}
943 />
944 )
945}
946
947function measureView(view: View | null, insets: EdgeInsets) {
948 if (!view) return Promise.resolve(null)
949 return new Promise<Measurement>(resolve => {
950 view?.measureInWindow((x, y, width, height) =>
951 resolve({
952 x,
953 y:
954 y +
955 platform({
956 default: 0,
957 android: insets.top, // not included in measurement
958 }),
959 width,
960 height,
961 }),
962 )
963 })
964}
965
966function getHoveredHoverable(
967 evt:
968 | GestureStateChangeEvent<PanGestureHandlerEventPayload>
969 | GestureUpdateEvent<PanGestureHandlerEventPayload>,
970 hoverables: SharedValue<Record<string, {id: string; rect: Measurement}>>,
971 translation: SharedValue<number>,
972) {
973 'worklet'
974
975 const x = evt.absoluteX
976 const y = evt.absoluteY
977 const yOffset = translation.get()
978
979 const rects = Object.values(hoverables.get())
980
981 for (const {id, rect} of rects) {
982 const isWithinLeftBound = x >= rect.x
983 const isWithinRightBound = x <= rect.x + rect.width
984 const isWithinTopBound = y >= rect.y + yOffset
985 const isWithinBottomBound = y <= rect.y + rect.height + yOffset
986
987 if (
988 isWithinLeftBound &&
989 isWithinRightBound &&
990 isWithinTopBound &&
991 isWithinBottomBound
992 ) {
993 return id
994 }
995 }
996
997 return null
998}