Bluesky app fork with some witchin' additions 💫

Allow selecting `ContextMenu` options via press-and-hold (#8020)

* save locations of menu items

* enable panning to select items

* rm unused type

* fix haptic overfiring

authored by samuel.fm and committed by

GitHub 7d1ebf6a 1e688dee

+330 -101
+19 -1
src/components/ContextMenu/context.tsx
··· 1 1 import React from 'react' 2 2 3 - import type {ContextType, ItemContextType} from '#/components/ContextMenu/types' 3 + import { 4 + type ContextType, 5 + type ItemContextType, 6 + type MenuContextType, 7 + } from '#/components/ContextMenu/types' 4 8 5 9 export const Context = React.createContext<ContextType | null>(null) 10 + 11 + export const MenuContext = React.createContext<MenuContextType | null>(null) 6 12 7 13 export const ItemContext = React.createContext<ItemContextType | null>(null) 8 14 ··· 12 18 if (!context) { 13 19 throw new Error( 14 20 'useContextMenuContext must be used within a Context.Provider', 21 + ) 22 + } 23 + 24 + return context 25 + } 26 + 27 + export function useContextMenuMenuContext() { 28 + const context = React.useContext(MenuContext) 29 + 30 + if (!context) { 31 + throw new Error( 32 + 'useContextMenuMenuContext must be used within a Context.Provider', 15 33 ) 16 34 } 17 35
+283 -97
src/components/ContextMenu/index.tsx
··· 1 - import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' 1 + import React, { 2 + useCallback, 3 + useEffect, 4 + useId, 5 + useMemo, 6 + useRef, 7 + useState, 8 + } from 'react' 2 9 import { 3 10 BackHandler, 4 11 Keyboard, 5 12 LayoutChangeEvent, 6 13 Pressable, 7 14 StyleProp, 15 + useWindowDimensions, 8 16 View, 9 17 ViewStyle, 10 18 } from 'react-native' 11 - import {Gesture, GestureDetector} from 'react-native-gesture-handler' 19 + import { 20 + Gesture, 21 + GestureDetector, 22 + GestureStateChangeEvent, 23 + GestureUpdateEvent, 24 + PanGestureHandlerEventPayload, 25 + } from 'react-native-gesture-handler' 12 26 import Animated, { 13 27 clamp, 14 28 interpolate, 15 29 runOnJS, 16 30 SharedValue, 31 + useAnimatedReaction, 17 32 useAnimatedStyle, 18 33 useSharedValue, 19 34 withSpring, ··· 35 50 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 36 51 import {logger} from '#/logger' 37 52 import {isAndroid, isIOS} from '#/platform/detection' 38 - import {atoms as a, platform, useTheme} from '#/alf' 53 + import {atoms as a, platform, tokens, useTheme} from '#/alf' 39 54 import { 40 55 Context, 41 56 ItemContext, 57 + MenuContext, 42 58 useContextMenuContext, 43 59 useContextMenuItemContext, 60 + useContextMenuMenuContext, 44 61 } from '#/components/ContextMenu/context' 45 62 import { 46 63 ContextType, ··· 82 99 } 83 100 84 101 export function Root({children}: {children: React.ReactNode}) { 102 + const playHaptic = useHaptics() 85 103 const [measurement, setMeasurement] = useState<Measurement | null>(null) 86 104 const animationSV = useSharedValue(0) 87 105 const translationSV = useSharedValue(0) 88 106 const isFocused = useIsFocused() 107 + const hoverables = useRef< 108 + Map<string, {id: string; rect: Measurement; onTouchUp: () => void}> 109 + >(new Map()) 110 + const hoverablesSV = useSharedValue< 111 + Record<string, {id: string; rect: Measurement}> 112 + >({}) 113 + const syncHoverablesThrottleRef = useRef<ReturnType<typeof setTimeout>>() 114 + const [hoveredMenuItem, setHoveredMenuItem] = useState<string | null>(null) 89 115 90 - const clearMeasurement = useCallback(() => setMeasurement(null), []) 116 + const onHoverableTouchUp = useCallback((id: string) => { 117 + const hoverable = hoverables.current.get(id) 118 + if (!hoverable) { 119 + logger.warn(`No such hoverable with id ${id}`) 120 + return 121 + } 122 + hoverable.onTouchUp() 123 + }, []) 91 124 92 - const context = useMemo<ContextType>( 93 - () => ({ 94 - isOpen: !!measurement && isFocused, 95 - measurement, 96 - animationSV, 97 - translationSV, 98 - open: (evt: Measurement) => { 99 - setMeasurement(evt) 100 - animationSV.set(withSpring(1, SPRING)) 101 - }, 102 - close: () => { 103 - animationSV.set( 104 - withSpring(0, SPRING, finished => { 105 - if (finished) { 106 - translationSV.set(0) 107 - runOnJS(clearMeasurement)() 108 - } 109 - }), 110 - ) 111 - }, 112 - }), 125 + const onCompletedClose = useCallback(() => { 126 + hoverables.current.clear() 127 + setMeasurement(null) 128 + }, []) 129 + 130 + const context = useMemo( 131 + () => 132 + ({ 133 + isOpen: !!measurement && isFocused, 134 + measurement, 135 + animationSV, 136 + translationSV, 137 + open: (evt: Measurement) => { 138 + setMeasurement(evt) 139 + animationSV.set(withSpring(1, SPRING)) 140 + }, 141 + close: () => { 142 + animationSV.set( 143 + withSpring(0, SPRING, finished => { 144 + if (finished) { 145 + hoverablesSV.set({}) 146 + translationSV.set(0) 147 + runOnJS(onCompletedClose)() 148 + } 149 + }), 150 + ) 151 + }, 152 + registerHoverable: ( 153 + id: string, 154 + rect: Measurement, 155 + onTouchUp: () => void, 156 + ) => { 157 + hoverables.current.set(id, {id, rect, onTouchUp}) 158 + // we need this data on the UI thread, but we want to limit cross-thread communication 159 + // and this function will be called in quick succession, so we need to throttle it 160 + if (syncHoverablesThrottleRef.current) 161 + clearTimeout(syncHoverablesThrottleRef.current) 162 + syncHoverablesThrottleRef.current = setTimeout(() => { 163 + syncHoverablesThrottleRef.current = undefined 164 + hoverablesSV.set( 165 + Object.fromEntries( 166 + // eslint-ignore 167 + [...hoverables.current.entries()].map(([id, {rect}]) => [ 168 + id, 169 + {id, rect}, 170 + ]), 171 + ), 172 + ) 173 + }, 1) 174 + }, 175 + hoverablesSV, 176 + onTouchUpMenuItem: onHoverableTouchUp, 177 + hoveredMenuItem, 178 + setHoveredMenuItem: item => { 179 + if (item) playHaptic('Light') 180 + setHoveredMenuItem(item) 181 + }, 182 + } satisfies ContextType), 113 183 [ 114 184 measurement, 115 185 setMeasurement, 186 + onCompletedClose, 116 187 isFocused, 117 188 animationSV, 118 189 translationSV, 119 - clearMeasurement, 190 + hoverablesSV, 191 + onHoverableTouchUp, 192 + hoveredMenuItem, 193 + setHoveredMenuItem, 194 + playHaptic, 120 195 ], 121 196 ) 122 197 ··· 183 258 .runOnJS(true) 184 259 }, [open]) 185 260 261 + const { 262 + hoverablesSV, 263 + setHoveredMenuItem, 264 + onTouchUpMenuItem, 265 + translationSV, 266 + animationSV, 267 + } = context 268 + const hoveredItemSV = useSharedValue<string | null>(null) 269 + 270 + useAnimatedReaction( 271 + () => hoveredItemSV.get(), 272 + (hovered, prev) => { 273 + if (hovered !== prev) { 274 + runOnJS(setHoveredMenuItem)(hovered) 275 + } 276 + }, 277 + ) 278 + 186 279 const pressAndHoldGesture = useMemo(() => { 187 - return Gesture.LongPress() 280 + return Gesture.Pan() 281 + .activateAfterLongPress(500) 282 + .cancelsTouchesInView(false) 283 + .averageTouches(true) 188 284 .onStart(() => { 285 + 'worklet' 189 286 runOnJS(open)() 190 287 }) 191 - .cancelsTouchesInView(false) 192 - }, [open]) 288 + .onUpdate(evt => { 289 + 'worklet' 290 + const item = getHoveredHoverable(evt, hoverablesSV, translationSV) 291 + hoveredItemSV.set(item) 292 + }) 293 + .onEnd(evt => { 294 + 'worklet' 295 + const item = getHoveredHoverable(evt, hoverablesSV, translationSV) 296 + hoveredItemSV.set(null) 297 + if (item) { 298 + runOnJS(onTouchUpMenuItem)(item) 299 + } 300 + }) 301 + }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV]) 193 302 194 303 const composedGestures = Gesture.Exclusive( 195 304 doubleTapGesture, 196 305 pressAndHoldGesture, 197 306 ) 198 - 199 - const {translationSV, animationSV} = context 200 307 201 308 const measurement = context.measurement || pendingMeasurement 202 309 ··· 324 431 const context = useContextMenuContext() 325 432 const insets = useSafeAreaInsets() 326 433 const frame = useSafeAreaFrame() 434 + const {width: screenWidth} = useWindowDimensions() 327 435 328 436 const {animationSV, translationSV} = context 329 437 ··· 347 455 const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn 348 456 349 457 const {height} = evt.nativeEvent.layout 350 - const topPosition = context.measurement.y + context.measurement.height + 4 458 + const topPosition = 459 + context.measurement.y + context.measurement.height + tokens.space.xs 351 460 const bottomPosition = topPosition + height 352 461 const safeAreaBottomLimit = 353 462 frame.height - ··· 372 481 }, 373 482 [context.measurement, frame.height, insets, translationSV], 374 483 ) 484 + 485 + const menuContext = useMemo(() => ({align}), [align]) 375 486 376 487 if (!context.isOpen || !context.measurement) return null 377 488 378 489 return ( 379 490 <Portal> 380 491 <Context.Provider value={context}> 381 - <Backdrop animation={animationSV} onPress={context.close} /> 382 - {/* containing element - stays the same size, so we measure it 383 - to determine if a translation is necessary. also has the positioning */} 384 - <Animated.View 385 - onLayout={onLayout} 386 - style={[ 387 - a.absolute, 388 - a.z_10, 389 - a.mt_xs, 390 - { 391 - width: MENU_WIDTH, 392 - top: context.measurement.y + context.measurement.height, 393 - }, 394 - align === 'left' 395 - ? {left: context.measurement.x} 396 - : { 397 - right: 398 - frame.x + 399 - frame.width - 400 - context.measurement.x - 401 - context.measurement.width, 402 - }, 403 - animatedContainerStyle, 404 - ]}> 405 - {/* scaling element - has the scale/fade animation on it */} 492 + <MenuContext.Provider value={menuContext}> 493 + <Backdrop animation={animationSV} onPress={context.close} /> 494 + {/* containing element - stays the same size, so we measure it 495 + to determine if a translation is necessary. also has the positioning */} 406 496 <Animated.View 497 + onLayout={onLayout} 407 498 style={[ 408 - a.rounded_md, 409 - a.shadow_md, 410 - t.atoms.bg_contrast_25, 411 - a.w_full, 412 - // @ts-ignore react-native-web expects string, and this file is platform-split -sfn 413 - // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error 414 - // in the typecheck CI - presumably because of RNW overriding the types 499 + a.absolute, 500 + a.z_10, 501 + a.mt_xs, 415 502 { 416 - transformOrigin: 417 - align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0], 503 + width: MENU_WIDTH, 504 + top: context.measurement.y + context.measurement.height, 418 505 }, 419 - animatedStyle, 420 - style, 506 + align === 'left' 507 + ? {left: context.measurement.x} 508 + : { 509 + right: 510 + screenWidth - 511 + context.measurement.x - 512 + context.measurement.width, 513 + }, 514 + animatedContainerStyle, 421 515 ]}> 422 - {/* innermost element - needs an overflow: hidden for children, but we also need a shadow, 423 - so put the shadow on the scaling element and the overflow on the innermost element */} 424 - <View 516 + {/* scaling element - has the scale/fade animation on it */} 517 + <Animated.View 425 518 style={[ 426 - a.flex_1, 427 519 a.rounded_md, 428 - a.overflow_hidden, 429 - a.border, 430 - t.atoms.border_contrast_low, 520 + a.shadow_md, 521 + t.atoms.bg_contrast_25, 522 + a.w_full, 523 + // @ts-ignore react-native-web expects string, and this file is platform-split -sfn 524 + // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error 525 + // in the typecheck CI - presumably because of RNW overriding the types 526 + { 527 + transformOrigin: 528 + // "top right" doesn't seem to work on android, so set explicity in pixels 529 + align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0], 530 + }, 531 + animatedStyle, 532 + style, 431 533 ]}> 432 - {flattenReactChildren(children).map((child, i) => { 433 - return React.isValidElement(child) && 434 - (child.type === Item || child.type === Divider) ? ( 435 - <React.Fragment key={i}> 436 - {i > 0 ? ( 437 - <View style={[a.border_b, t.atoms.border_contrast_low]} /> 438 - ) : null} 439 - {React.cloneElement(child, { 440 - // @ts-expect-error not typed 441 - style: { 442 - borderRadius: 0, 443 - borderWidth: 0, 444 - }, 445 - })} 446 - </React.Fragment> 447 - ) : null 448 - })} 449 - </View> 534 + {/* innermost element - needs an overflow: hidden for children, but we also need a shadow, 535 + so put the shadow on the scaling element and the overflow on the innermost element */} 536 + <View 537 + style={[ 538 + a.flex_1, 539 + a.rounded_md, 540 + a.overflow_hidden, 541 + a.border, 542 + t.atoms.border_contrast_low, 543 + ]}> 544 + {flattenReactChildren(children).map((child, i) => { 545 + return React.isValidElement(child) && 546 + (child.type === Item || child.type === Divider) ? ( 547 + <React.Fragment key={i}> 548 + {i > 0 ? ( 549 + <View 550 + style={[a.border_b, t.atoms.border_contrast_low]} 551 + /> 552 + ) : null} 553 + {React.cloneElement(child, { 554 + // @ts-expect-error not typed 555 + style: { 556 + borderRadius: 0, 557 + borderWidth: 0, 558 + }, 559 + })} 560 + </React.Fragment> 561 + ) : null 562 + })} 563 + </View> 564 + </Animated.View> 450 565 </Animated.View> 451 - </Animated.View> 566 + </MenuContext.Provider> 452 567 </Context.Provider> 453 568 </Portal> 454 569 ) ··· 464 579 onIn: onPressIn, 465 580 onOut: onPressOut, 466 581 } = useInteractionState() 582 + const id = useId() 583 + const {align} = useContextMenuMenuContext() 584 + 585 + const {close, measurement, registerHoverable} = context 586 + 587 + const handleLayout = useCallback( 588 + (evt: LayoutChangeEvent) => { 589 + if (!measurement) return // should be impossible 590 + 591 + const layout = evt.nativeEvent.layout 592 + 593 + registerHoverable( 594 + id, 595 + { 596 + width: layout.width, 597 + height: layout.height, 598 + y: measurement.y + measurement.height + tokens.space.xs + layout.y, 599 + x: 600 + align === 'left' 601 + ? measurement.x 602 + : measurement.x + measurement.width - layout.width, 603 + }, 604 + () => { 605 + close() 606 + onPress() 607 + }, 608 + ) 609 + }, 610 + [id, measurement, registerHoverable, close, onPress, align], 611 + ) 612 + 613 + const itemContext = useMemo( 614 + () => ({disabled: Boolean(rest.disabled)}), 615 + [rest.disabled], 616 + ) 467 617 468 618 return ( 469 619 <Pressable 470 620 {...rest} 621 + onLayout={handleLayout} 471 622 accessibilityHint="" 472 623 accessibilityLabel={label} 473 624 onFocus={onFocus} 474 625 onBlur={onBlur} 475 626 onPress={e => { 476 - context.close() 627 + close() 477 628 onPress?.(e) 478 629 }} 479 630 onPressIn={e => { ··· 497 648 t.atoms.border_contrast_low, 498 649 {minHeight: 40}, 499 650 style, 500 - (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50], 651 + (focused || pressed || context.hoveredMenuItem === id) && 652 + !rest.disabled && [t.atoms.bg_contrast_50], 501 653 ]}> 502 - <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}> 654 + <ItemContext.Provider value={itemContext}> 503 655 {children} 504 656 </ItemContext.Provider> 505 657 </Pressable> ··· 589 741 /> 590 742 ) 591 743 } 744 + 745 + function getHoveredHoverable( 746 + evt: 747 + | GestureStateChangeEvent<PanGestureHandlerEventPayload> 748 + | GestureUpdateEvent<PanGestureHandlerEventPayload>, 749 + hoverables: SharedValue<Record<string, {id: string; rect: Measurement}>>, 750 + translation: SharedValue<number>, 751 + ) { 752 + 'worklet' 753 + 754 + const x = evt.absoluteX 755 + const y = evt.absoluteY 756 + const yOffset = translation.get() 757 + 758 + const rects = Object.values(hoverables.get()) 759 + 760 + for (const {id, rect} of rects) { 761 + const isWithinLeftBound = x >= rect.x 762 + const isWithinRightBound = x <= rect.x + rect.width 763 + const isWithinTopBound = y >= rect.y + yOffset 764 + const isWithinBottomBound = y <= rect.y + rect.height + yOffset 765 + 766 + if ( 767 + isWithinLeftBound && 768 + isWithinRightBound && 769 + isWithinTopBound && 770 + isWithinBottomBound 771 + ) { 772 + return id 773 + } 774 + } 775 + 776 + return null 777 + }
+28 -3
src/components/ContextMenu/types.ts
··· 1 1 import React from 'react' 2 - import {AccessibilityRole, StyleProp, ViewStyle} from 'react-native' 2 + import { 3 + AccessibilityRole, 4 + GestureResponderEvent, 5 + StyleProp, 6 + ViewStyle, 7 + } from 'react-native' 3 8 import {SharedValue} from 'react-native-reanimated' 4 9 5 10 import * as Dialog from '#/components/Dialog' 6 - import {RadixPassThroughTriggerProps} from '#/components/Menu/types' 11 + import { 12 + ItemProps as MenuItemProps, 13 + RadixPassThroughTriggerProps, 14 + } from '#/components/Menu/types' 7 15 8 16 export type { 9 17 GroupProps, 10 18 ItemIconProps, 11 - ItemProps, 12 19 ItemTextProps, 13 20 } from '#/components/Menu/types' 14 21 22 + // Same as Menu.ItemProps, but onPress is not guaranteed to get an event 23 + export type ItemProps = Omit<MenuItemProps, 'onPress'> & { 24 + onPress: (evt?: GestureResponderEvent) => void 25 + } 26 + 15 27 export type Measurement = { 16 28 x: number 17 29 y: number ··· 28 40 translationSV: SharedValue<number> 29 41 open: (evt: Measurement) => void 30 42 close: () => void 43 + registerHoverable: ( 44 + id: string, 45 + rect: Measurement, 46 + onTouchUp: () => void, 47 + ) => void 48 + hoverablesSV: SharedValue<Record<string, {id: string; rect: Measurement}>> 49 + hoveredMenuItem: string | null 50 + setHoveredMenuItem: React.Dispatch<React.SetStateAction<string | null>> 51 + onTouchUpMenuItem: (id: string) => void 52 + } 53 + 54 + export type MenuContextType = { 55 + align: 'left' | 'right' 31 56 } 32 57 33 58 export type ItemContextType = {