From 721965e67cc9b70a597a186a22382270efbaf837 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 16 Jun 2026 21:38:36 +0300 Subject: [PATCH] Swipe to reply in chat (#10920) Co-authored-by: Claude Opus 4.8 (1M context) --- src/components/ContextMenu/index.tsx | 11 +- src/components/ContextMenu/types.ts | 9 + src/components/dms/ActionsWrapper.tsx | 53 +++-- src/components/dms/MessageContextMenu.tsx | 10 +- src/components/dms/MessageItemEmbed.tsx | 1 + src/components/dms/MessageItemInviteEmbed.tsx | 1 + src/components/dms/SwipeToReply.tsx | 195 ++++++++++++++++++ 7 files changed, 251 insertions(+), 29 deletions(-) create mode 100644 src/components/dms/SwipeToReply.tsx diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 0d8a2761e5..258e2898e4 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -241,6 +241,7 @@ export function Trigger({ contentLabel, style, onTap, + swipeGesture, }: TriggerProps) { const context = useContextMenuContext() const playHaptic = useHaptics() @@ -364,12 +365,20 @@ export function Trigger({ }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV]) // Order matters here: doubleTapGesture must come before tapGesture. - const composedGestures = Gesture.Exclusive( + const tapAndHoldGestures = Gesture.Exclusive( doubleTapGesture, tapGesture, pressAndHoldGesture, ) + // An optional swipe gesture (e.g. swipe-to-reply) races against the tap/hold + // group: whichever activates first wins and cancels the rest, so they're + // mutually exclusive. Race (not Exclusive) avoids a held-but-not-yet-moved + // swipe Pan blocking the long-press from firing. + const composedGestures = swipeGesture + ? Gesture.Race(swipeGesture, tapAndHoldGestures) + : tapAndHoldGestures + const measurement = context.measurement || pendingMeasurement?.measurement return ( diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts index 111c972f5a..6fb97b6816 100644 --- a/src/components/ContextMenu/types.ts +++ b/src/components/ContextMenu/types.ts @@ -4,6 +4,7 @@ import { type StyleProp, type ViewStyle, } from 'react-native' +import {type GestureType} from 'react-native-gesture-handler' import {type SharedValue} from 'react-native-reanimated' import type * as Dialog from '#/components/Dialog' @@ -94,6 +95,14 @@ export type TriggerProps = { * @platform ios, android */ onTap?: () => void + /** + * An optional gesture (e.g. swipe-to-reply) composed into the same + * arbitration group as the tap and press-and-hold gestures via `Gesture.Race`, + * making it mutually exclusive with them - only one can win a given touch. + * + * @platform ios, android + */ + swipeGesture?: GestureType } export type TriggerChildProps = | { diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index d96dcbdca1..ce58dba31c 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -2,8 +2,9 @@ import {View} from 'react-native' import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api' import {useLingui} from '@lingui/react/macro' -import {atoms as a} from '#/alf' import {MessageContextMenu} from '#/components/dms/MessageContextMenu' +import {useMessageReplies} from '#/components/dms/MessageReplies' +import {SwipeToReply} from '#/components/dms/SwipeToReply' import type * as bsky from '#/types/bsky' export function ActionsWrapper({ @@ -20,33 +21,31 @@ export function ActionsWrapper({ children: React.ReactNode }) { const {t: l} = useLingui() + const {setReply} = useMessageReplies() return ( - - {trigger => - // will always be true, since this file is platform split - trigger.IS_NATIVE && ( - - trigger.control.open('full')}> - {children} - - - ) - } - + setReply(message)}> + {swipeGesture => ( + + {trigger => + // will always be true, since this file is platform split + trigger.IS_NATIVE && ( + trigger.control.open('full')}> + {children} + + ) + } + + )} + ) } diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index a9d3108cef..e16a617ca5 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -1,5 +1,6 @@ import {memo, useCallback} from 'react' import {Platform} from 'react-native' +import {type GestureType} from 'react-native-gesture-handler' import * as Clipboard from 'expo-clipboard' import { type ChatBskyConvoDefs, @@ -38,11 +39,17 @@ export let MessageContextMenu = ({ senderProfile, moderationOpts, children, + swipeGesture, }: { message: ChatBskyConvoDefs.MessageView senderProfile?: bsky.profile.AnyProfileView moderationOpts: ModerationOpts | undefined children: TriggerProps['children'] + /** + * Native only. A swipe gesture (swipe-to-reply) composed into the trigger's + * gesture group so it's mutually exclusive with the tap and long-press. + */ + swipeGesture?: GestureType }): React.ReactNode => { const {t: l, i18n} = useLingui() const ax = useAnalytics() @@ -144,7 +151,8 @@ export let MessageContextMenu = ({ label={l`Message options`} contentLabel={l`Message from @${ sender?.handle ?? 'unknown' // should always be defined - }: ${message.text}`}> + }: ${message.text}`} + swipeGesture={swipeGesture}> {children} diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index 7f7abf16c0..3b44947f6c 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -29,6 +29,7 @@ let MessageItemEmbed = ({ void + enabled?: boolean + children: (swipeGesture: GestureType) => React.ReactNode +}) { + const t = useTheme() + const playHaptic = useHaptics() + const isReducedMotion = useReducedMotion() + + const transX = useSharedValue(0) + const hit = useSharedValue(false) + const iconScale = useSharedValue(1) + + const swipeGesture = useMemo(() => { + const runPop = () => { + 'worklet' + if (isReducedMotion) return + iconScale.set(() => + withSequence( + withTiming(1.2, {duration: 175}), + withTiming(1, {duration: 100}), + ), + ) + } + + return ( + Gesture.Pan() + .enabled(enabled) + // Arm only on the inward axis; the outward direction is effectively + // disabled so the bubble can't be dragged off its own edge. + .activeOffsetX( + isFromSelf + ? [-10, EFFECTIVELY_DISABLED_OFFSET] + : [-EFFECTIVELY_DISABLED_OFFSET, 10], + ) + .activeOffsetY([ + -EFFECTIVELY_DISABLED_OFFSET, + EFFECTIVELY_DISABLED_OFFSET, + ]) + .onChange(e => { + 'worklet' + const dir = isFromSelf + ? Math.min(e.translationX, 0) + : Math.max(e.translationX, 0) + transX.set(applyResistance(dir)) + + const pastThreshold = Math.abs(transX.get()) >= ACTIVATION_THRESHOLD + if (pastThreshold && !hit.get()) { + hit.set(true) + runPop() + runOnJS(playHaptic)('Medium') + } else if (!pastThreshold && hit.get()) { + hit.set(false) + } + }) + .onEnd(() => { + 'worklet' + // Only a clean end (finger lifted past threshold) triggers the reply. + if (hit.get()) { + runOnJS(onReply)() + } + }) + .onFinalize(() => { + 'worklet' + // Runs on both end and cancellation, so the bubble always animates + // home even if the gesture is interrupted mid-swipe. + transX.set(withTiming(0, {duration: 200})) + hit.set(false) + }) + ) + }, [ + isFromSelf, + enabled, + onReply, + playHaptic, + isReducedMotion, + transX, + hit, + iconScale, + ]) + + const contentStyle = useAnimatedStyle(() => ({ + transform: [{translateX: transX.get()}], + })) + + const iconStyle = useAnimatedStyle(() => { + const progress = clamp(Math.abs(transX.get()) / ACTIVATION_THRESHOLD, 0, 1) + return { + opacity: progress, + transform: [ + {scale: iconScale.get() * interpolate(progress, [0, 1], [0.5, 1])}, + ], + } + }) + + return ( + + + + + + + {/* Sized + aligned to the bubble so the consumer's GestureDetector (the + context menu's, which composes `swipeGesture`) covers only the bubble, + leaving the gutter free for the back/scroll gestures. */} + + {children(swipeGesture)} + + + ) +}