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