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)}
+
+
+ )
+}