Bluesky app fork with some witchin' additions 馃挮
at readme-update 911 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 {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}