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

Configure Feed

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

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