Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at f82bc98d8d7d34edbf9b75c664987b000ae2db2b 909 lines 26 kB view raw
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}