Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
117
fork

Configure Feed

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

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