a892057e29
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
197 lines
6.2 KiB
TypeScript
197 lines
6.2 KiB
TypeScript
import {useMemo} from 'react'
|
|
import {View} from 'react-native'
|
|
import {Gesture, type GestureType} from 'react-native-gesture-handler'
|
|
import Animated, {
|
|
clamp,
|
|
interpolate,
|
|
useAnimatedStyle,
|
|
useReducedMotion,
|
|
useSharedValue,
|
|
withSequence,
|
|
withTiming,
|
|
} from 'react-native-reanimated'
|
|
import {scheduleOnRN} from 'react-native-worklets'
|
|
|
|
import {useHaptics} from '#/lib/haptics'
|
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
|
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
|
|
import {MESSAGE_BUBBLE_MAX_WIDTH} from './util'
|
|
|
|
// 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()
|
|
scheduleOnRN(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()) {
|
|
scheduleOnRN(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: MESSAGE_BUBBLE_MAX_WIDTH},
|
|
isFromSelf ? a.self_end : a.self_start,
|
|
contentStyle,
|
|
]}>
|
|
{children(swipeGesture)}
|
|
</Animated.View>
|
|
</View>
|
|
)
|
|
}
|