Swipe to reply in chat (#10920)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-16 21:38:36 +03:00
committed by GitHub
parent bb6847f396
commit 721965e67c
7 changed files with 251 additions and 29 deletions
+26 -27
View File
@@ -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 (
<MessageContextMenu
message={message}
senderProfile={senderProfile}
moderationOpts={moderationOpts}>
{trigger =>
// will always be true, since this file is platform split
trigger.IS_NATIVE && (
<View style={[a.flex_1, a.relative]}>
<View
style={[
{maxWidth: '80%'},
isFromSelf
? [a.self_end, a.align_end]
: [a.self_start, a.align_start],
]}
accessible={true}
accessibilityActions={[
{name: 'activate', label: l`Open message options`},
]}
onAccessibilityAction={() => trigger.control.open('full')}>
{children}
</View>
</View>
)
}
</MessageContextMenu>
<SwipeToReply isFromSelf={isFromSelf} onReply={() => setReply(message)}>
{swipeGesture => (
<MessageContextMenu
message={message}
senderProfile={senderProfile}
moderationOpts={moderationOpts}
swipeGesture={swipeGesture}>
{trigger =>
// will always be true, since this file is platform split
trigger.IS_NATIVE && (
<View
accessible={true}
accessibilityActions={[
{name: 'activate', label: l`Open message options`},
]}
onAccessibilityAction={() => trigger.control.open('full')}>
{children}
</View>
)
}
</MessageContextMenu>
)}
</SwipeToReply>
)
}
+9 -1
View File
@@ -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}
</ContextMenu.Trigger>
+1
View File
@@ -29,6 +29,7 @@ let MessageItemEmbed = ({
<MessageContextProvider>
<View
style={[
isFromSelf ? a.self_end : a.self_start,
!isFromSelf && isGroupChat && a.ml_sm,
native({
flexBasis: 0,
@@ -37,6 +37,7 @@ let MessageItemInviteEmbed = ({
<MessageContextProvider>
<View
style={[
isFromSelf ? a.self_end : a.self_start,
!isFromSelf && isGroupChat && a.ml_sm,
native({
flexBasis: 0,
+195
View File
@@ -0,0 +1,195 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {Gesture, type GestureType} from 'react-native-gesture-handler'
import Animated, {
clamp,
interpolate,
runOnJS,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSequence,
withTiming,
} from 'react-native-reanimated'
import {useHaptics} from '#/lib/haptics'
import {atoms as a, tokens, useTheme} from '#/alf'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
// Distance the bubble must travel before releasing fires a reply.
const ACTIVATION_THRESHOLD = 56
// Past the activation point the bubble keeps moving but with exponentially
// increasing resistance, asymptotically approaching this much extra travel.
const MAX_OVERSHOOT = 32
const ICON_DIAMETER = 32
// Absurdly high value so the gesture doesn't arm on the vertical axis and
// hijack list scrolling. reanimated/RNGH don't offer clean per-axis disabling.
const EFFECTIVELY_DISABLED_OFFSET = 200
// 1:1 up to `threshold`, then exponentially diminishing travel: the marginal
// movement decays the further you pull, easing toward a soft cap of
// `threshold + MAX_OVERSHOOT` rather than hitting a hard wall.
function applyResistance(value: number) {
'worklet'
const abs = Math.abs(value)
if (abs <= ACTIVATION_THRESHOLD) return value
const sign = value < 0 ? -1 : 1
const excess = abs - ACTIVATION_THRESHOLD
return (
sign *
(ACTIVATION_THRESHOLD +
MAX_OVERSHOOT * (1 - Math.exp(-excess / MAX_OVERSHOOT)))
)
}
/**
* Swipe a message bubble inward to reply, Signal/WhatsApp style. Self messages
* (right-aligned) swipe left; others' messages (left-aligned) swipe right. A
* reply icon sits stationary in the gutter on the bubble's resting edge and is
* revealed (fade + scale) as the bubble slides away from it. Crossing the
* activation threshold fires a haptic; releasing past it triggers `onReply`.
*
* The pan gesture is built here but handed to `children` via a render prop so
* the consumer can compose it into the message's context-menu gestures with
* `Gesture.Exclusive`. Sharing one arbitration group is what makes the swipe
* and the long-press mutually exclusive - only one can ever win the touch.
*/
export function SwipeToReply({
isFromSelf,
onReply,
enabled = true,
children,
}: {
isFromSelf: boolean
onReply: () => 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 (
<View style={[a.flex_1, a.relative]}>
<Animated.View
pointerEvents="none"
style={[
a.absolute,
a.justify_center,
{top: 0, bottom: 0},
// Centred roughly half the activation threshold in from the bubble's
// resting edge, so it lands in the gap the bubble reveals.
isFromSelf ? {right: tokens.space.md} : {left: tokens.space.md},
iconStyle,
]}>
<View
style={[
a.justify_center,
a.align_center,
a.rounded_full,
t.atoms.bg_contrast_50,
{width: ICON_DIAMETER, height: ICON_DIAMETER},
]}>
<ReplyIcon size="sm" style={t.atoms.text_contrast_medium} />
</View>
</Animated.View>
{/* 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. */}
<Animated.View
style={[
{maxWidth: '80%'},
isFromSelf ? a.self_end : a.self_start,
contentStyle,
]}>
{children(swipeGesture)}
</Animated.View>
</View>
)
}