Repository: bluesky-social/social-app
Pull request #8020: Allow selecting `ContextMenu` options via press-and-hold
Author: login: mozzius
Created: 2025-03-18T19:13:53Z | Merged: 2025-03-27T16:53:42Z

Stacked on #8014

If the context menu is opened via a long press, you can now keep your finger down, move it over a menu item, and release to select.

https://github.com/user-attachments/assets/4ca58633-de0c-488b-8fb6-ef90e8b375f0

## Test plan

Test long pressing to select stuff. Ensure other functionality is not impacted
Ensure it works when the menu moves around, ensure hover zone locations are accurate


Diff:
diff --git a/src/components/ContextMenu/context.tsx b/src/components/ContextMenu/context.tsx
index 213d87a8c3a..cecb6a18d97 100644
--- a/src/components/ContextMenu/context.tsx
+++ b/src/components/ContextMenu/context.tsx
@@ -1,9 +1,15 @@
 import React from 'react'
 
-import type {ContextType, ItemContextType} from '#/components/ContextMenu/types'
+import {
+  type ContextType,
+  type ItemContextType,
+  type MenuContextType,
+} from '#/components/ContextMenu/types'
 
 export const Context = React.createContext<ContextType | null>(null)
 
+export const MenuContext = React.createContext<MenuContextType | null>(null)
+
 export const ItemContext = React.createContext<ItemContextType | null>(null)
 
 export function useContextMenuContext() {
@@ -18,6 +24,18 @@ export function useContextMenuContext() {
   return context
 }
 
+export function useContextMenuMenuContext() {
+  const context = React.useContext(MenuContext)
+
+  if (!context) {
+    throw new Error(
+      'useContextMenuMenuContext must be used within a Context.Provider',
+    )
+  }
+
+  return context
+}
+
 export function useContextMenuItemContext() {
   const context = React.useContext(ItemContext)
 
diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx
index d172935d6cc..840e2caea6a 100644
--- a/src/components/ContextMenu/index.tsx
+++ b/src/components/ContextMenu/index.tsx
@@ -1,19 +1,34 @@
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
+import React, {
+  useCallback,
+  useEffect,
+  useId,
+  useMemo,
+  useRef,
+  useState,
+} from 'react'
 import {
   BackHandler,
   Keyboard,
   LayoutChangeEvent,
   Pressable,
   StyleProp,
+  useWindowDimensions,
   View,
   ViewStyle,
 } from 'react-native'
-import {Gesture, GestureDetector} from 'react-native-gesture-handler'
+import {
+  Gesture,
+  GestureDetector,
+  GestureStateChangeEvent,
+  GestureUpdateEvent,
+  PanGestureHandlerEventPayload,
+} from 'react-native-gesture-handler'
 import Animated, {
   clamp,
   interpolate,
   runOnJS,
   SharedValue,
+  useAnimatedReaction,
   useAnimatedStyle,
   useSharedValue,
   withSpring,
@@ -35,12 +50,14 @@ import {useHaptics} from '#/lib/haptics'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {logger} from '#/logger'
 import {isAndroid, isIOS} from '#/platform/detection'
-import {atoms as a, platform, useTheme} from '#/alf'
+import {atoms as a, platform, tokens, useTheme} from '#/alf'
 import {
   Context,
   ItemContext,
+  MenuContext,
   useContextMenuContext,
   useContextMenuItemContext,
+  useContextMenuMenuContext,
 } from '#/components/ContextMenu/context'
 import {
   ContextType,
@@ -82,41 +99,99 @@ export function Provider({children}: {children: React.ReactNode}) {
 }
 
 export function Root({children}: {children: React.ReactNode}) {
+  const playHaptic = useHaptics()
   const [measurement, setMeasurement] = useState<Measurement | null>(null)
   const animationSV = useSharedValue(0)
   const translationSV = useSharedValue(0)
   const isFocused = useIsFocused()
-
-  const clearMeasurement = useCallback(() => setMeasurement(null), [])
-
-  const context = useMemo<ContextType>(
-    () => ({
-      isOpen: !!measurement && isFocused,
-      measurement,
-      animationSV,
-      translationSV,
-      open: (evt: Measurement) => {
-        setMeasurement(evt)
-        animationSV.set(withSpring(1, SPRING))
-      },
-      close: () => {
-        animationSV.set(
-          withSpring(0, SPRING, finished => {
-            if (finished) {
-              translationSV.set(0)
-              runOnJS(clearMeasurement)()
-            }
-          }),
-        )
-      },
-    }),
+  const hoverables = useRef<
+    Map<string, {id: string; rect: Measurement; onTouchUp: () => void}>
+  >(new Map())
+  const hoverablesSV = useSharedValue<
+    Record<string, {id: string; rect: Measurement}>
+  >({})
+  const syncHoverablesThrottleRef = useRef<ReturnType<typeof setTimeout>>()
+  const [hoveredMenuItem, setHoveredMenuItem] = useState<string | null>(null)
+
+  const onHoverableTouchUp = useCallback((id: string) => {
+    const hoverable = hoverables.current.get(id)
+    if (!hoverable) {
+      logger.warn(`No such hoverable with id ${id}`)
+      return
+    }
+    hoverable.onTouchUp()
+  }, [])
+
+  const onCompletedClose = useCallback(() => {
+    hoverables.current.clear()
+    setMeasurement(null)
+  }, [])
+
+  const context = useMemo(
+    () =>
+      ({
+        isOpen: !!measurement && isFocused,
+        measurement,
+        animationSV,
+        translationSV,
+        open: (evt: Measurement) => {
+          setMeasurement(evt)
+          animationSV.set(withSpring(1, SPRING))
+        },
+        close: () => {
+          animationSV.set(
+            withSpring(0, SPRING, finished => {
+              if (finished) {
+                hoverablesSV.set({})
+                translationSV.set(0)
+                runOnJS(onCompletedClose)()
+              }
+            }),
+          )
+        },
+        registerHoverable: (
+          id: string,
+          rect: Measurement,
+          onTouchUp: () => void,
+        ) => {
+          hoverables.current.set(id, {id, rect, onTouchUp})
+          // we need this data on the UI thread, but we want to limit cross-thread communication
+          // and this function will be called in quick succession, so we need to throttle it
+          if (syncHoverablesThrottleRef.current)
+            clearTimeout(syncHoverablesThrottleRef.current)
+          syncHoverablesThrottleRef.current = setTimeout(() => {
+            syncHoverablesThrottleRef.current = undefined
+            hoverablesSV.set(
+              Object.fromEntries(
+                // eslint-ignore
+                [...hoverables.current.entries()].map(([id, {rect}]) => [
+                  id,
+                  {id, rect},
+                ]),
+              ),
+            )
+          }, 1)
+        },
+        hoverablesSV,
+        onTouchUpMenuItem: onHoverableTouchUp,
+        hoveredMenuItem,
+        setHoveredMenuItem: item => {
+          if (item) playHaptic('Light')
+          setHoveredMenuItem(item)
+        },
+      } satisfies ContextType),
     [
       measurement,
       setMeasurement,
+      onCompletedClose,
       isFocused,
       animationSV,
       translationSV,
-      clearMeasurement,
+      hoverablesSV,
+      onHoverableTouchUp,
+      hoveredMenuItem,
+      setHoveredMenuItem,
+      playHaptic,
     ],
   )
 
@@ -183,21 +258,53 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
       .runOnJS(true)
   }, [open])
 
+  const {
+    hoverablesSV,
+    setHoveredMenuItem,
+    onTouchUpMenuItem,
+    translationSV,
+    animationSV,
+  } = context
+  const hoveredItemSV = useSharedValue<string | null>(null)
+
+  useAnimatedReaction(
+    () => hoveredItemSV.get(),
+    (hovered, prev) => {
+      if (hovered !== prev) {
+        runOnJS(setHoveredMenuItem)(hovered)
+      }
+    },
+  )
+
   const pressAndHoldGesture = useMemo(() => {
-    return Gesture.LongPress()
+    return Gesture.Pan()
+      .activateAfterLongPress(500)
+      .cancelsTouchesInView(false)
+      .averageTouches(true)
       .onStart(() => {
+        'worklet'
         runOnJS(open)()
       })
-      .cancelsTouchesInView(false)
-  }, [open])
+      .onUpdate(evt => {
+        'worklet'
+        const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
+        hoveredItemSV.set(item)
+      })
+      .onEnd(evt => {
+        'worklet'
+        const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
+        hoveredItemSV.set(null)
+        if (item) {
+          runOnJS(onTouchUpMenuItem)(item)
+        }
+      })
+  }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
 
   const composedGestures = Gesture.Exclusive(
     doubleTapGesture,
     pressAndHoldGesture,
   )
 
-  const {translationSV, animationSV} = context
-
   const measurement = context.measurement || pendingMeasurement
 
   return (
@@ -324,6 +431,7 @@ export function Outer({
   const context = useContextMenuContext()
   const insets = useSafeAreaInsets()
   const frame = useSafeAreaFrame()
+  const {width: screenWidth} = useWindowDimensions()
 
   const {animationSV, translationSV} = context
 
@@ -347,7 +455,8 @@ export function Outer({
       const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn
 
       const {height} = evt.nativeEvent.layout
-      const topPosition = context.measurement.y + context.measurement.height + 4
+      const topPosition =
+        context.measurement.y + context.measurement.height + tokens.space.xs
       const bottomPosition = topPosition + height
       const safeAreaBottomLimit =
         frame.height -
@@ -373,82 +482,88 @@ export function Outer({
     [context.measurement, frame.height, insets, translationSV],
   )
 
+  const menuContext = useMemo(() => ({align}), [align])
+
   if (!context.isOpen || !context.measurement) return null
 
   return (
     <Portal>
       <Context.Provider value={context}>
-        <Backdrop animation={animationSV} onPress={context.close} />
-        {/* containing element - stays the same size, so we measure it
-         to determine if a translation is necessary. also has the positioning */}
-        <Animated.View
-          onLayout={onLayout}
-          style={[
-            a.absolute,
-            a.z_10,
-            a.mt_xs,
-            {
-              width: MENU_WIDTH,
-              top: context.measurement.y + context.measurement.height,
-            },
-            align === 'left'
-              ? {left: context.measurement.x}
-              : {
-                  right:
-                    frame.x +
-                    frame.width -
-                    context.measurement.x -
-                    context.measurement.width,
-                },
-            animatedContainerStyle,
-          ]}>
-          {/* scaling element - has the scale/fade animation on it */}
+        <MenuContext.Provider value={menuContext}>
+          <Backdrop animation={animationSV} onPress={context.close} />
+          {/* containing element - stays the same size, so we measure it
+           to determine if a translation is necessary. also has the positioning */}
           <Animated.View
+            onLayout={onLayout}
             style={[
-              a.rounded_md,
-              a.shadow_md,
-              t.atoms.bg_contrast_25,
-              a.w_full,
-              // @ts-ignore react-native-web expects string, and this file is platform-split -sfn
-              // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
-              // in the typecheck CI - presumably because of RNW overriding the types
+              a.absolute,
+              a.z_10,
+              a.mt_xs,
               {
-                transformOrigin:
-                  align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
+                width: MENU_WIDTH,
+                top: context.measurement.y + context.measurement.height,
               },
-              animatedStyle,
-              style,
+              align === 'left'
+                ? {left: context.measurement.x}
+                : {
+                    right:
+                      screenWidth -
+                      context.measurement.x -
+                      context.measurement.width,
+                  },
+              animatedContainerStyle,
             ]}>
-            {/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
-              so put the shadow on the scaling element and the overflow on the innermost element */}
-            <View
+            {/* scaling element - has the scale/fade animation on it */}
+            <Animated.View
               style={[
-                a.flex_1,
                 a.rounded_md,
-                a.overflow_hidden,
-                a.border,
-                t.atoms.border_contrast_low,
+                a.shadow_md,
+                t.atoms.bg_contrast_25,
+                a.w_full,
+                // @ts-ignore react-native-web expects string, and this file is platform-split -sfn
+                // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
+                // in the typecheck CI - presumably because of RNW overriding the types
+                {
+                  transformOrigin:
+                    // "top right" doesn't seem to work on android, so set explicity in pixels
+                    align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
+                },
+                animatedStyle,
+                style,
               ]}>
-              {flattenReactChildren(children).map((child, i) => {
-                return React.isValidElement(child) &&
-                  (child.type === Item || child.type === Divider) ? (
-                  <React.Fragment key={i}>
-                    {i > 0 ? (
-                      <View style={[a.border_b, t.atoms.border_contrast_low]} />
-                    ) : null}
-                    {React.cloneElement(child, {
-                      // @ts-expect-error not typed
-                      style: {
-                        borderRadius: 0,
-                        borderWidth: 0,
-                      },
-                    })}
-                  </React.Fragment>
-                ) : null
-              })}
-            </View>
+              {/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
+                so put the shadow on the scaling element and the overflow on the innermost element */}
+              <View
+                style={[
+                  a.flex_1,
+                  a.rounded_md,
+                  a.overflow_hidden,
+                  a.border,
+                  t.atoms.border_contrast_low,
+                ]}>
+                {flattenReactChildren(children).map((child, i) => {
+                  return React.isValidElement(child) &&
+                    (child.type === Item || child.type === Divider) ? (
+                    <React.Fragment key={i}>
+                      {i > 0 ? (
+                        <View
+                          style={[a.border_b, t.atoms.border_contrast_low]}
+                        />
+                      ) : null}
+                      {React.cloneElement(child, {
+                        // @ts-expect-error not typed
+                        style: {
+                          borderRadius: 0,
+                          borderWidth: 0,
+                        },
+                      })}
+                    </React.Fragment>
+                  ) : null
+                })}
+              </View>
+            </Animated.View>
           </Animated.View>
-        </Animated.View>
+        </MenuContext.Provider>
       </Context.Provider>
     </Portal>
   )
@@ -464,16 +579,52 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
     onIn: onPressIn,
     onOut: onPressOut,
   } = useInteractionState()
+  const id = useId()
+  const {align} = useContextMenuMenuContext()
+
+  const {close, measurement, registerHoverable} = context
+
+  const handleLayout = useCallback(
+    (evt: LayoutChangeEvent) => {
+      if (!measurement) return // should be impossible
+
+      const layout = evt.nativeEvent.layout
+
+      registerHoverable(
+        id,
+        {
+          width: layout.width,
+          height: layout.height,
+          y: measurement.y + measurement.height + tokens.space.xs + layout.y,
+          x:
+            align === 'left'
+              ? measurement.x
+              : measurement.x + measurement.width - layout.width,
+        },
+        () => {
+          close()
+          onPress()
+        },
+      )
+    },
+    [id, measurement, registerHoverable, close, onPress, align],
+  )
+
+  const itemContext = useMemo(
+    () => ({disabled: Boolean(rest.disabled)}),
+    [rest.disabled],
+  )
 
   return (
     <Pressable
       {...rest}
+      onLayout={handleLayout}
       accessibilityHint=""
       accessibilityLabel={label}
       onFocus={onFocus}
       onBlur={onBlur}
       onPress={e => {
-        context.close()
+        close()
         onPress?.(e)
       }}
       onPressIn={e => {
@@ -497,9 +648,10 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
         t.atoms.border_contrast_low,
         {minHeight: 40},
         style,
-        (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
+        (focused || pressed || context.hoveredMenuItem === id) &&
+          !rest.disabled && [t.atoms.bg_contrast_50],
       ]}>
-      <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
+      <ItemContext.Provider value={itemContext}>
         {children}
       </ItemContext.Provider>
     </Pressable>
@@ -589,3 +741,37 @@ export function Divider() {
     />
   )
 }
+
+function getHoveredHoverable(
+  evt:
+    | GestureStateChangeEvent<PanGestureHandlerEventPayload>
+    | GestureUpdateEvent<PanGestureHandlerEventPayload>,
+  hoverables: SharedValue<Record<string, {id: string; rect: Measurement}>>,
+  translation: SharedValue<number>,
+) {
+  'worklet'
+
+  const x = evt.absoluteX
+  const y = evt.absoluteY
+  const yOffset = translation.get()
+
+  const rects = Object.values(hoverables.get())
+
+  for (const {id, rect} of rects) {
+    const isWithinLeftBound = x >= rect.x
+    const isWithinRightBound = x <= rect.x + rect.width
+    const isWithinTopBound = y >= rect.y + yOffset
+    const isWithinBottomBound = y <= rect.y + rect.height + yOffset
+
+    if (
+      isWithinLeftBound &&
+      isWithinRightBound &&
+      isWithinTopBound &&
+      isWithinBottomBound
+    ) {
+      return id
+    }
+  }
+
+  return null
+}
diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts
index 0b3fedc55ef..2084ecf1975 100644
--- a/src/components/ContextMenu/types.ts
+++ b/src/components/ContextMenu/types.ts
@@ -1,17 +1,29 @@
 import React from 'react'
-import {AccessibilityRole, StyleProp, ViewStyle} from 'react-native'
+import {
+  AccessibilityRole,
+  GestureResponderEvent,
+  StyleProp,
+  ViewStyle,
+} from 'react-native'
 import {SharedValue} from 'react-native-reanimated'
 
 import * as Dialog from '#/components/Dialog'
-import {RadixPassThroughTriggerProps} from '#/components/Menu/types'
+import {
+  ItemProps as MenuItemProps,
+  RadixPassThroughTriggerProps,
+} from '#/components/Menu/types'
 
 export type {
   GroupProps,
   ItemIconProps,
-  ItemProps,
   ItemTextProps,
 } from '#/components/Menu/types'
 
+// Same as Menu.ItemProps, but onPress is not guaranteed to get an event
+export type ItemProps = Omit<MenuItemProps, 'onPress'> & {
+  onPress: (evt?: GestureResponderEvent) => void
+}
+
 export type Measurement = {
   x: number
   y: number
@@ -28,6 +40,19 @@ export type ContextType = {
   translationSV: SharedValue<number>
   open: (evt: Measurement) => void
   close: () => void
+  registerHoverable: (
+    id: string,
+    rect: Measurement,
+    onTouchUp: () => void,
+  ) => void
+  hoverablesSV: SharedValue<Record<string, {id: string; rect: Measurement}>>
+  hoveredMenuItem: string | null
+  setHoveredMenuItem: React.Dispatch<React.SetStateAction<string | null>>
+  onTouchUpMenuItem: (id: string) => void
+}
+
+export type MenuContextType = {
+  align: 'left' | 'right'
 }
 
 export type ItemContextType = {


Reviews:
user: login: estrattonbailey
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T15:15:26Z
user: login: mozzius
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T15:16:00Z
user: login: estrattonbailey
body: This is inspired stuff my dude. Approach looks sound, poked around a bit to grok and tested on both my physical devices and simulators. LGTM!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-27T16:52:55Z

Review comments:
diff_hunk: @@ -18,6 +24,18 @@ export function useContextMenuContext() {
   return context
 }
 
+export function useContextMenuMenuContext() {
path: src/components/ContextMenu/context.tsx
user: login: estrattonbailey
body: how dare you
created_at: 2025-03-27T15:15:26Z
author_association: MEMBER
diff_hunk: @@ -18,6 +24,18 @@ export function useContextMenuContext() {
   return context
 }
 
+export function useContextMenuMenuContext() {
path: src/components/ContextMenu/context.tsx
user: login: mozzius
body: 😅
created_at: 2025-03-27T15:16:00Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-18T19:21:27Z
body: | Old size | New size | Diff                  |
|----------|----------|-----------------------|
| 6.97 MB | 6.97 MB | 0 B (0.00%) |
---
<!-- Sticky Pull Request Commentbundle-diff -->
author_association: CONTRIBUTOR
user: login: mozzius
created_at: 2025-03-19T11:18:12Z
body: Tested on iOS and Android
author_association: MEMBER