Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

at main 1009 lines 28 kB view raw
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, flatten, 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 xOffset = (flatten(style)?.marginLeft as number) ?? 0 532 const menuContext = useMemo(() => ({align, xOffset}), [align, xOffset]) 533 534 const onLayout = useCallback(() => { 535 if (!measurement) return 536 537 let translation = 0 538 539 // vibes based, just assuming it'll fit within this space. revisit if we use 540 // AuxiliaryView for something tall 541 const TOP_INSET = topInset + 80 542 543 const distanceMessageFromTop = measurement.y - TOP_INSET 544 if (distanceMessageFromTop < 0) { 545 translation = -distanceMessageFromTop 546 } 547 548 // normally, the context menu is responsible for measuring itself and moving everything into the right place 549 // however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here 550 if (mode === 'auxiliary-only') { 551 translationSV.set(translation) 552 ensureOnScreenTranslationSV.set(0) 553 } 554 // however, we also need to make sure that for super tall triggers, we don't go off the screen 555 // so we have an additional cap on the standard transform every other element has 556 // note: this breaks the press-and-hold gesture for the reaction items. unfortunately I think 557 // we'll just have to live with it for now, fixing it would be possible but be a large complexity 558 // increase for an edge case 559 else { 560 ensureOnScreenTranslationSV.set(translation) 561 } 562 }, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV]) 563 564 if (!isOpen || !measurement) return null 565 566 return ( 567 <Portal> 568 <Context.Provider value={context}> 569 <MenuContext.Provider value={menuContext}> 570 <Animated.View 571 onLayout={onLayout} 572 style={[ 573 a.absolute, 574 { 575 top: measurement.y, 576 transformOrigin: 577 align === 'left' ? 'bottom left' : 'bottom right', 578 }, 579 align === 'left' 580 ? {left: measurement.x} 581 : {right: screenWidth - measurement.x - measurement.width}, 582 animatedStyle, 583 a.z_20, 584 style, 585 ]}> 586 {children} 587 </Animated.View> 588 </MenuContext.Provider> 589 </Context.Provider> 590 </Portal> 591 ) 592} 593 594const MENU_WIDTH = 240 595 596export function Outer({ 597 children, 598 style, 599 align = 'left', 600}: { 601 children: React.ReactNode 602 style?: StyleProp<ViewStyle> 603 align?: 'left' | 'right' 604}) { 605 const t = useTheme() 606 const context = useContextMenuContext() 607 const insets = useSafeAreaInsets() 608 const frame = useSafeAreaFrame() 609 const {width: screenWidth} = useWindowDimensions() 610 611 const {animationSV, translationSV} = context 612 613 const animatedContainerStyle = useAnimatedStyle(() => ({ 614 transform: [{translateY: translationSV.get() * animationSV.get()}], 615 })) 616 617 const animatedStyle = useAnimatedStyle(() => ({ 618 opacity: clamp(animationSV.get(), 0, 1), 619 transform: [{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}], 620 })) 621 622 const onLayout = useCallback( 623 (evt: LayoutChangeEvent) => { 624 if (!context.measurement) return // should not happen 625 let translation = 0 626 627 // pure vibes based 628 const TOP_INSET = insets.top + 80 629 const BOTTOM_INSET_IOS = insets.bottom + 20 630 const BOTTOM_INSET_ANDROID = insets.bottom + 12 631 632 const {height} = evt.nativeEvent.layout 633 const topPosition = 634 context.measurement.y + context.measurement.height + tokens.space.xs 635 const bottomPosition = topPosition + height 636 const safeAreaBottomLimit = 637 frame.height - 638 platform({ 639 ios: BOTTOM_INSET_IOS, 640 android: BOTTOM_INSET_ANDROID, 641 default: 0, 642 }) 643 const diff = bottomPosition - safeAreaBottomLimit 644 if (diff > 0) { 645 translation = -diff 646 } else { 647 const distanceMessageFromTop = context.measurement.y - TOP_INSET 648 if (distanceMessageFromTop < 0) { 649 translation = -Math.max(distanceMessageFromTop, diff) 650 } 651 } 652 653 if (translation !== 0) { 654 translationSV.set(translation) 655 } 656 }, 657 [context.measurement, frame.height, insets, translationSV], 658 ) 659 660 const xOffset = (flatten(style)?.marginLeft as number) ?? 0 661 const menuContext = useMemo(() => ({align, xOffset}), [align, xOffset]) 662 663 if (!context.isOpen || !context.measurement) return null 664 665 return ( 666 <Portal> 667 <Context.Provider value={context}> 668 <MenuContext.Provider value={menuContext}> 669 <Backdrop animation={animationSV} onPress={context.close} /> 670 {context.mode === 'full' && ( 671 /* containing element - stays the same size, so we measure it 672 to determine if a translation is necessary. also has the positioning */ 673 <Animated.View 674 onLayout={onLayout} 675 style={[ 676 a.absolute, 677 a.z_10, 678 a.mt_xs, 679 { 680 width: MENU_WIDTH, 681 top: context.measurement.y + context.measurement.height, 682 }, 683 align === 'left' 684 ? {left: context.measurement.x} 685 : { 686 right: 687 screenWidth - 688 context.measurement.x - 689 context.measurement.width, 690 }, 691 animatedContainerStyle, 692 ]}> 693 {/* scaling element - has the scale/fade animation on it */} 694 <Animated.View 695 style={[ 696 a.rounded_md, 697 a.shadow_md, 698 t.atoms.bg_contrast_25, 699 a.w_full, 700 // @ts-ignore react-native-web expects string, and this file is platform-split -sfn 701 // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error 702 // in the typecheck CI - presumably because of RNW overriding the types 703 { 704 transformOrigin: 705 // "top right" doesn't seem to work on android, so set explicitly in pixels 706 align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0], 707 }, 708 animatedStyle, 709 style, 710 ]}> 711 {/* innermost element - needs an overflow: hidden for children, but we also need a shadow, 712 so put the shadow on the scaling element and the overflow on the innermost element */} 713 <View 714 style={[ 715 a.flex_1, 716 a.rounded_md, 717 a.overflow_hidden, 718 a.border, 719 t.atoms.border_contrast_low, 720 ]}> 721 {flattenReactChildren(children).map((child, i) => { 722 return isValidElement(child) && 723 (child.type === Item || child.type === Divider) ? ( 724 <Fragment key={i}> 725 {i > 0 ? ( 726 <View 727 style={[a.border_b, t.atoms.border_contrast_low]} 728 /> 729 ) : null} 730 {cloneElement(child, { 731 // @ts-expect-error not typed 732 style: { 733 borderRadius: 0, 734 borderWidth: 0, 735 }, 736 })} 737 </Fragment> 738 ) : null 739 })} 740 </View> 741 </Animated.View> 742 </Animated.View> 743 )} 744 </MenuContext.Provider> 745 </Context.Provider> 746 </Portal> 747 ) 748} 749 750export function Item({ 751 children, 752 label, 753 unstyled, 754 style, 755 onPress, 756 position, 757 ...rest 758}: ItemProps) { 759 const t = useTheme() 760 const context = useContextMenuContext() 761 const playHaptic = useHaptics() 762 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 763 const { 764 state: pressed, 765 onIn: onPressIn, 766 onOut: onPressOut, 767 } = useInteractionState() 768 const id = useId() 769 const {align, xOffset: menuXOffset} = useContextMenuMenuContext() 770 771 const {close, measurement, registerHoverable} = context 772 773 const handleLayout = useCallback( 774 (evt: LayoutChangeEvent) => { 775 if (!measurement) return // should be impossible 776 777 const layout = evt.nativeEvent.layout 778 779 const yOffset = position 780 ? position.y 781 : measurement.y + measurement.height + tokens.space.xs 782 const xOffset = position 783 ? position.x 784 : align === 'left' 785 ? measurement.x + menuXOffset 786 : measurement.x + measurement.width - layout.width - menuXOffset 787 788 registerHoverable( 789 id, 790 { 791 width: layout.width, 792 height: layout.height, 793 y: yOffset + layout.y, 794 x: xOffset + layout.x, 795 }, 796 () => { 797 close() 798 onPress() 799 }, 800 ) 801 }, 802 [ 803 id, 804 measurement, 805 registerHoverable, 806 close, 807 onPress, 808 align, 809 menuXOffset, 810 position, 811 ], 812 ) 813 814 const itemContext = useMemo( 815 () => ({disabled: Boolean(rest.disabled)}), 816 [rest.disabled], 817 ) 818 819 return ( 820 <Pressable 821 {...rest} 822 onLayout={handleLayout} 823 accessibilityHint="" 824 accessibilityLabel={label} 825 onFocus={onFocus} 826 onBlur={onBlur} 827 onPress={e => { 828 close() 829 onPress?.(e) 830 }} 831 onPressIn={e => { 832 onPressIn() 833 rest.onPressIn?.(e) 834 playHaptic('Light') 835 }} 836 onPressOut={e => { 837 onPressOut() 838 rest.onPressOut?.(e) 839 }} 840 style={[ 841 !unstyled && [ 842 a.flex_row, 843 a.align_center, 844 a.gap_sm, 845 a.px_md, 846 a.rounded_md, 847 a.border, 848 t.atoms.bg_contrast_25, 849 t.atoms.border_contrast_low, 850 {minHeight: 44, paddingVertical: 10}, 851 (focused || pressed || context.hoveredMenuItem === id) && 852 !rest.disabled && 853 t.atoms.bg_contrast_50, 854 ], 855 style, 856 ]}> 857 <ItemContext.Provider value={itemContext}> 858 {typeof children === 'function' 859 ? children( 860 (focused || pressed || context.hoveredMenuItem === id) && 861 !rest.disabled, 862 ) 863 : children} 864 </ItemContext.Provider> 865 </Pressable> 866 ) 867} 868 869export function ItemText({children, style}: ItemTextProps) { 870 const t = useTheme() 871 const {disabled} = useContextMenuItemContext() 872 return ( 873 <Text 874 numberOfLines={2} 875 ellipsizeMode="middle" 876 style={[ 877 a.flex_1, 878 a.text_md, 879 a.font_semi_bold, 880 t.atoms.text_contrast_high, 881 {paddingTop: 3}, 882 style, 883 disabled && t.atoms.text_contrast_low, 884 ]}> 885 {children} 886 </Text> 887 ) 888} 889 890export function ItemIcon({icon: Comp}: ItemIconProps) { 891 const t = useTheme() 892 const {disabled} = useContextMenuItemContext() 893 return ( 894 <Comp 895 size="lg" 896 fill={ 897 disabled 898 ? t.atoms.text_contrast_low.color 899 : t.atoms.text_contrast_medium.color 900 } 901 /> 902 ) 903} 904 905export function ItemRadio({selected}: {selected: boolean}) { 906 const t = useTheme() 907 const enableSquareButtons = useEnableSquareButtons() 908 return ( 909 <View 910 style={[ 911 a.justify_center, 912 a.align_center, 913 enableSquareButtons ? a.rounded_sm : a.rounded_full, 914 t.atoms.border_contrast_high, 915 { 916 borderWidth: 1, 917 height: 20, 918 width: 20, 919 }, 920 ]}> 921 {selected ? ( 922 <View 923 style={[ 924 a.absolute, 925 enableSquareButtons ? a.rounded_sm : a.rounded_full, 926 {height: 14, width: 14}, 927 selected ? {backgroundColor: t.palette.primary_500} : {}, 928 ]} 929 /> 930 ) : null} 931 </View> 932 ) 933} 934 935export function LabelText({children}: {children: React.ReactNode}) { 936 const t = useTheme() 937 return ( 938 <Text 939 style={[ 940 a.font_semi_bold, 941 t.atoms.text_contrast_medium, 942 {marginBottom: -8}, 943 ]}> 944 {children} 945 </Text> 946 ) 947} 948 949export function Divider() { 950 const t = useTheme() 951 return ( 952 <View 953 style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]} 954 /> 955 ) 956} 957 958function measureView(view: View | null, insets: EdgeInsets) { 959 if (!view) return Promise.resolve(null) 960 return new Promise<Measurement>(resolve => { 961 view?.measureInWindow((x, y, width, height) => 962 resolve({ 963 x, 964 y: 965 y + 966 platform({ 967 default: 0, 968 android: insets.top, // not included in measurement 969 }), 970 width, 971 height, 972 }), 973 ) 974 }) 975} 976 977function getHoveredHoverable( 978 evt: 979 | GestureStateChangeEvent<PanGestureHandlerEventPayload> 980 | GestureUpdateEvent<PanGestureHandlerEventPayload>, 981 hoverables: SharedValue<Record<string, {id: string; rect: Measurement}>>, 982 translation: SharedValue<number>, 983) { 984 'worklet' 985 986 const x = evt.absoluteX 987 const y = evt.absoluteY 988 const yOffset = translation.get() 989 990 const rects = Object.values(hoverables.get()) 991 992 for (const {id, rect} of rects) { 993 const isWithinLeftBound = x >= rect.x 994 const isWithinRightBound = x <= rect.x + rect.width 995 const isWithinTopBound = y >= rect.y + yOffset 996 const isWithinBottomBound = y <= rect.y + rect.height + yOffset 997 998 if ( 999 isWithinLeftBound && 1000 isWithinRightBound && 1001 isWithinTopBound && 1002 isWithinBottomBound 1003 ) { 1004 return id 1005 } 1006 } 1007 1008 return null 1009}