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