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,
|
contentLabel,
|
||||||
style,
|
style,
|
||||||
onTap,
|
onTap,
|
||||||
|
swipeGesture,
|
||||||
}: TriggerProps) {
|
}: TriggerProps) {
|
||||||
const context = useContextMenuContext()
|
const context = useContextMenuContext()
|
||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
@@ -364,12 +365,20 @@ export function Trigger({
|
|||||||
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
|
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
|
||||||
|
|
||||||
// Order matters here: doubleTapGesture must come before tapGesture.
|
// Order matters here: doubleTapGesture must come before tapGesture.
|
||||||
const composedGestures = Gesture.Exclusive(
|
const tapAndHoldGestures = Gesture.Exclusive(
|
||||||
doubleTapGesture,
|
doubleTapGesture,
|
||||||
tapGesture,
|
tapGesture,
|
||||||
pressAndHoldGesture,
|
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
|
const measurement = context.measurement || pendingMeasurement?.measurement
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type StyleProp,
|
type StyleProp,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import {type GestureType} from 'react-native-gesture-handler'
|
||||||
import {type SharedValue} from 'react-native-reanimated'
|
import {type SharedValue} from 'react-native-reanimated'
|
||||||
|
|
||||||
import type * as Dialog from '#/components/Dialog'
|
import type * as Dialog from '#/components/Dialog'
|
||||||
@@ -94,6 +95,14 @@ export type TriggerProps = {
|
|||||||
* @platform ios, android
|
* @platform ios, android
|
||||||
*/
|
*/
|
||||||
onTap?: () => void
|
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 =
|
export type TriggerChildProps =
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import {View} from 'react-native'
|
|||||||
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
|
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
|
||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
|
||||||
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
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'
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export function ActionsWrapper({
|
export function ActionsWrapper({
|
||||||
@@ -20,33 +21,31 @@ export function ActionsWrapper({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
const {setReply} = useMessageReplies()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageContextMenu
|
<SwipeToReply isFromSelf={isFromSelf} onReply={() => setReply(message)}>
|
||||||
message={message}
|
{swipeGesture => (
|
||||||
senderProfile={senderProfile}
|
<MessageContextMenu
|
||||||
moderationOpts={moderationOpts}>
|
message={message}
|
||||||
{trigger =>
|
senderProfile={senderProfile}
|
||||||
// will always be true, since this file is platform split
|
moderationOpts={moderationOpts}
|
||||||
trigger.IS_NATIVE && (
|
swipeGesture={swipeGesture}>
|
||||||
<View style={[a.flex_1, a.relative]}>
|
{trigger =>
|
||||||
<View
|
// will always be true, since this file is platform split
|
||||||
style={[
|
trigger.IS_NATIVE && (
|
||||||
{maxWidth: '80%'},
|
<View
|
||||||
isFromSelf
|
accessible={true}
|
||||||
? [a.self_end, a.align_end]
|
accessibilityActions={[
|
||||||
: [a.self_start, a.align_start],
|
{name: 'activate', label: l`Open message options`},
|
||||||
]}
|
]}
|
||||||
accessible={true}
|
onAccessibilityAction={() => trigger.control.open('full')}>
|
||||||
accessibilityActions={[
|
{children}
|
||||||
{name: 'activate', label: l`Open message options`},
|
</View>
|
||||||
]}
|
)
|
||||||
onAccessibilityAction={() => trigger.control.open('full')}>
|
}
|
||||||
{children}
|
</MessageContextMenu>
|
||||||
</View>
|
)}
|
||||||
</View>
|
</SwipeToReply>
|
||||||
)
|
|
||||||
}
|
|
||||||
</MessageContextMenu>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {memo, useCallback} from 'react'
|
import {memo, useCallback} from 'react'
|
||||||
import {Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
|
import {type GestureType} from 'react-native-gesture-handler'
|
||||||
import * as Clipboard from 'expo-clipboard'
|
import * as Clipboard from 'expo-clipboard'
|
||||||
import {
|
import {
|
||||||
type ChatBskyConvoDefs,
|
type ChatBskyConvoDefs,
|
||||||
@@ -38,11 +39,17 @@ export let MessageContextMenu = ({
|
|||||||
senderProfile,
|
senderProfile,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
children,
|
children,
|
||||||
|
swipeGesture,
|
||||||
}: {
|
}: {
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
senderProfile?: bsky.profile.AnyProfileView
|
senderProfile?: bsky.profile.AnyProfileView
|
||||||
moderationOpts: ModerationOpts | undefined
|
moderationOpts: ModerationOpts | undefined
|
||||||
children: TriggerProps['children']
|
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 => {
|
}): React.ReactNode => {
|
||||||
const {t: l, i18n} = useLingui()
|
const {t: l, i18n} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
@@ -144,7 +151,8 @@ export let MessageContextMenu = ({
|
|||||||
label={l`Message options`}
|
label={l`Message options`}
|
||||||
contentLabel={l`Message from @${
|
contentLabel={l`Message from @${
|
||||||
sender?.handle ?? 'unknown' // should always be defined
|
sender?.handle ?? 'unknown' // should always be defined
|
||||||
}: ${message.text}`}>
|
}: ${message.text}`}
|
||||||
|
swipeGesture={swipeGesture}>
|
||||||
{children}
|
{children}
|
||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ let MessageItemEmbed = ({
|
|||||||
<MessageContextProvider>
|
<MessageContextProvider>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
isFromSelf ? a.self_end : a.self_start,
|
||||||
!isFromSelf && isGroupChat && a.ml_sm,
|
!isFromSelf && isGroupChat && a.ml_sm,
|
||||||
native({
|
native({
|
||||||
flexBasis: 0,
|
flexBasis: 0,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ let MessageItemInviteEmbed = ({
|
|||||||
<MessageContextProvider>
|
<MessageContextProvider>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
isFromSelf ? a.self_end : a.self_start,
|
||||||
!isFromSelf && isGroupChat && a.ml_sm,
|
!isFromSelf && isGroupChat && a.ml_sm,
|
||||||
native({
|
native({
|
||||||
flexBasis: 0,
|
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