Swipe to reply in chat (#10920)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user