From cde461999f8417be5d7c4d4d6c08da8a721c9370 Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:52:07 +0200 Subject: [PATCH 1/2] fix(ui): synchronize swipe action gesture handlers with latest props Fixes #30. PanResponder in SwipeableRow was created once on mount and captured initial props in a stale closure. Using mutable refs ensures gesture handlers always read and execute the latest leftAction, rightAction, mode, and onAction props. --- src/components/SwipeableRow.tsx | 56 ++++++++++++------- src/components/__tests__/SwipeableRow.test.ts | 30 ++++++++++ 2 files changed, 66 insertions(+), 20 deletions(-) create mode 100644 src/components/__tests__/SwipeableRow.test.ts diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index 8360d7bc..95fbb78e 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -73,6 +73,15 @@ export function SwipeableRow({ const claimed = useRef(false); const widthRef = useRef(Dimensions.get('window').width); + const leftActionRef = useRef(leftAction); + leftActionRef.current = leftAction; + const rightActionRef = useRef(rightAction); + rightActionRef.current = rightAction; + const modeRef = useRef(mode); + modeRef.current = mode; + const onActionRef = useRef(onAction); + onActionRef.current = onAction; + // Reveal-mode-only state: which side (if any) is currently sitting open. const openSideRef = useRef<'left' | 'right' | null>(null); const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null); @@ -101,13 +110,13 @@ export function SwipeableRow({ duration: 180, useNativeDriver: true, }).start(() => { - onAction(action); + onActionRef.current(action); // The row is about to be removed; reset translation in case it isn't // (e.g. the action failed silently) so we don't leave it off-screen. dx.setValue(0); }); } else { - onAction(action); + onActionRef.current(action); Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); } }; @@ -118,9 +127,9 @@ export function SwipeableRow({ if (exitsRow(action)) { // Let the row collapse a frame, then fire so the parent's list update // has a clean starting point. - requestAnimationFrame(() => onAction(action)); + requestAnimationFrame(() => onActionRef.current(action)); } else { - onAction(action); + onActionRef.current(action); } }; @@ -130,22 +139,26 @@ export function SwipeableRow({ onMoveShouldSetPanResponder: (_, g) => { if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false; if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false; + const currentMode = modeRef.current; + const currentRightAction = rightActionRef.current; + const currentLeftAction = leftActionRef.current; // Reveal mode: if a side is already open, allow the gesture so the // user can drag it closed. - if (mode === 'reveal' && openSideRef.current === null) { - if (g.dx > 0 && rightAction === 'none') return false; - if (g.dx < 0 && leftAction === 'none') return false; - } else if (mode !== 'reveal') { - if (g.dx > 0 && rightAction === 'none') return false; - if (g.dx < 0 && leftAction === 'none') return false; + if (currentMode === 'reveal' && openSideRef.current === null) { + if (g.dx > 0 && currentRightAction === 'none') return false; + if (g.dx < 0 && currentLeftAction === 'none') return false; + } else if (currentMode !== 'reveal') { + if (g.dx > 0 && currentRightAction === 'none') return false; + if (g.dx < 0 && currentLeftAction === 'none') return false; } claimed.current = true; return true; }, onPanResponderMove: (_, g) => { - const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT; + const currentMode = modeRef.current; + const overshoot = currentMode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT; const base = - mode === 'reveal' + currentMode === 'reveal' ? openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0 @@ -156,15 +169,18 @@ export function SwipeableRow({ }, onPanResponderRelease: (_, g) => { claimed.current = false; - if (mode === 'reveal') { + const currentMode = modeRef.current; + const currentRightAction = rightActionRef.current; + const currentLeftAction = leftActionRef.current; + if (currentMode === 'reveal') { const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0; const finalDx = base + g.dx; - if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') { + if (finalDx > ACTIVATION_THRESHOLD && currentRightAction !== 'none') { openTo('right'); - } else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') { + } else if (finalDx < -ACTIVATION_THRESHOLD && currentLeftAction !== 'none') { openTo('left'); } else { close(); @@ -172,16 +188,16 @@ export function SwipeableRow({ return; } // Instant mode: fire on release-past-threshold, no second tap. - if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && rightAction !== 'none') { - fly(widthRef.current || EXIT_DISTANCE, rightAction); - } else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && leftAction !== 'none') { - fly(-(widthRef.current || EXIT_DISTANCE), leftAction); + if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && currentRightAction !== 'none') { + fly(widthRef.current || EXIT_DISTANCE, currentRightAction); + } else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && currentLeftAction !== 'none') { + fly(-(widthRef.current || EXIT_DISTANCE), currentLeftAction); } else { Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); } }, onPanResponderTerminate: () => { - if (mode === 'reveal') close(); + if (modeRef.current === 'reveal') close(); else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); claimed.current = false; }, diff --git a/src/components/__tests__/SwipeableRow.test.ts b/src/components/__tests__/SwipeableRow.test.ts new file mode 100644 index 00000000..050fa447 --- /dev/null +++ b/src/components/__tests__/SwipeableRow.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest'; + +describe('SwipeableRow logic', () => { + it('should use mutable refs so updated props are immediately reflected in gesture callbacks', () => { + let leftAction = 'read'; + let rightAction = 'archive'; + const onAction = vi.fn(); + + const leftActionRef = { current: leftAction }; + const rightActionRef = { current: rightAction }; + const onActionRef = { current: onAction }; + + const fireAction = (side: 'left' | 'right') => { + const action = side === 'left' ? leftActionRef.current : rightActionRef.current; + onActionRef.current(action); + }; + + // Initially left action is 'read' + fireAction('left'); + expect(onAction).toHaveBeenCalledWith('read'); + + // Simulate prop change on re-render (e.g. user updated setting to 'delete') + leftAction = 'delete'; + leftActionRef.current = leftAction; + + // Call gesture release handler again without re-creating gesture instance + fireAction('left'); + expect(onAction).toHaveBeenLastCalledWith('delete'); + }); +}); From 941e5ede1217f0a037ba935d966d47a4fdc1439d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:28:48 +0200 Subject: [PATCH 2/2] refactor(ui): move swipe gesture decisions into a pure module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps the fix — gesture handlers now read current props — but makes the class of bug structurally impossible rather than patched case by case. The PanResponder is created once, so its handlers closed over the props of the render that built it. Four call sites had to be corrected individually, and nothing stopped a fifth from being added stale. Now the decisions live in swipe-gesture.ts as pure functions taking the config as an argument, so there is nothing left for a handler to capture; the component keeps one `latest` ref pointing at the current props and hands it over on each event. Also: - Replace the placeholder test, which asserted that assigning to a locally declared `{current}` object works and passed against the unfixed component, with 20 tests over the real gesture logic: claim/decline, overshoot clamping, commit thresholds, flick velocity, reveal open/close from an offset base, and the 'none' cases. - Build the responder in a useMemo instead of re-running PanResponder.create on every render of every visible row and discarding the result. - Fold the four repeated Animated.spring calls into springTo/settle. Beyond the reported symptom, the same staleness broke two more cases: entering selection mode passes 'none' for both actions, but a swipe still fired the old action; and onAction captured a handleSwipeAction from before the mailbox ids resolved, so on a slow connection swipe silently did nothing without anyone touching the setting — which matches the deeper problem Arnaud suspected on the PR. --- src/components/SwipeableRow.tsx | 177 ++++++++---------- src/components/__tests__/SwipeableRow.test.ts | 30 --- .../__tests__/swipe-gesture.test.ts | 162 ++++++++++++++++ src/components/swipe-gesture.ts | 132 +++++++++++++ 4 files changed, 367 insertions(+), 134 deletions(-) delete mode 100644 src/components/__tests__/SwipeableRow.test.ts create mode 100644 src/components/__tests__/swipe-gesture.test.ts create mode 100644 src/components/swipe-gesture.ts diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index 95fbb78e..b594f2c2 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -9,6 +9,11 @@ import { import { typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import type { SwipeAction, SwipeMode } from '../stores/settings-store'; +import { + shouldClaimGesture, dragOffset, resolveRelease, exitsRow, + COMMIT_THRESHOLD, REVEAL_WIDTH, + type SwipeConfig, +} from './swipe-gesture'; interface SwipeableRowProps { children: React.ReactNode; @@ -26,17 +31,6 @@ interface SwipeableRowProps { mode?: SwipeMode; } -// Instant mode: distance the user must drag past on release to fire the action. -const COMMIT_THRESHOLD = 96; -// Reveal mode: drag past this on release to snap to the open (revealed) state. -const ACTIVATION_THRESHOLD = 32; -// Reveal mode: width of the action band shown when revealed. -const REVEAL_WIDTH = 88; - -const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor -const MIN_DX_TO_CLAIM = 6; -const MAX_DRAG_OVERSHOOT_INSTANT = 240; -const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4; // Distance the row flies off-screen by before the action callback fires. const EXIT_DISTANCE = 600; @@ -58,12 +52,6 @@ function actionLabel(action: SwipeAction, context: { unread: boolean; starred: b return ACTION_META[action].defaultLabel; } -// Actions that visibly remove the row from the list (so the row should fly off -// instead of snapping back). Toggle-style actions stay in place. -function exitsRow(action: SwipeAction): boolean { - return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move'; -} - export function SwipeableRow({ children, leftAction, rightAction, context, onAction, mode = 'instant', }: SwipeableRowProps) { @@ -73,28 +61,37 @@ export function SwipeableRow({ const claimed = useRef(false); const widthRef = useRef(Dimensions.get('window').width); - const leftActionRef = useRef(leftAction); - leftActionRef.current = leftAction; - const rightActionRef = useRef(rightAction); - rightActionRef.current = rightAction; - const modeRef = useRef(mode); - modeRef.current = mode; - const onActionRef = useRef(onAction); - onActionRef.current = onAction; + // The PanResponder below is built once, so its handlers close over the props + // of the render that built it. Reading them through a ref instead keeps the + // gesture in step with the row it is actually attached to. Three ways that + // went wrong before: changing a swipe action in settings kept firing the old + // one; entering selection mode (which passes 'none') still fired actions; + // and `onAction` captured a `handleSwipeAction` from before the mailbox ids + // had loaded, so on a slow connection swipe did nothing even untouched. + // Same idiom as RichTextEditor's callback refs. + const latest = useRef void }>({ + leftAction, rightAction, mode, onAction, + }); + latest.current = { leftAction, rightAction, mode, onAction }; // Reveal-mode-only state: which side (if any) is currently sitting open. const openSideRef = useRef<'left' | 'right' | null>(null); const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null); + const springTo = (toValue: number) => { + Animated.spring(dx, { toValue, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); + }; + // Spring back to rest without changing which side is open. + const settle = () => springTo(0); + const close = () => { - Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); + springTo(0); openSideRef.current = null; setOpenSide(null); }; const openTo = (side: 'left' | 'right') => { - const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH; - Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); + springTo(side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH); openSideRef.current = side; setOpenSide(side); }; @@ -110,14 +107,14 @@ export function SwipeableRow({ duration: 180, useNativeDriver: true, }).start(() => { - onActionRef.current(action); + latest.current.onAction(action); // The row is about to be removed; reset translation in case it isn't // (e.g. the action failed silently) so we don't leave it off-screen. dx.setValue(0); }); } else { - onActionRef.current(action); - Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); + latest.current.onAction(action); + settle(); } }; @@ -126,84 +123,56 @@ export function SwipeableRow({ if (action === 'none') return; if (exitsRow(action)) { // Let the row collapse a frame, then fire so the parent's list update - // has a clean starting point. - requestAnimationFrame(() => onActionRef.current(action)); + // has a clean starting point. Deferred, so read the callback on arrival + // rather than capturing today's. + requestAnimationFrame(() => latest.current.onAction(action)); } else { - onActionRef.current(action); + latest.current.onAction(action); } }; - const responder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => false, - onMoveShouldSetPanResponder: (_, g) => { - if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false; - if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false; - const currentMode = modeRef.current; - const currentRightAction = rightActionRef.current; - const currentLeftAction = leftActionRef.current; - // Reveal mode: if a side is already open, allow the gesture so the - // user can drag it closed. - if (currentMode === 'reveal' && openSideRef.current === null) { - if (g.dx > 0 && currentRightAction === 'none') return false; - if (g.dx < 0 && currentLeftAction === 'none') return false; - } else if (currentMode !== 'reveal') { - if (g.dx > 0 && currentRightAction === 'none') return false; - if (g.dx < 0 && currentLeftAction === 'none') return false; - } - claimed.current = true; - return true; - }, - onPanResponderMove: (_, g) => { - const currentMode = modeRef.current; - const overshoot = currentMode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT; - const base = - currentMode === 'reveal' - ? openSideRef.current === 'right' ? REVEAL_WIDTH - : openSideRef.current === 'left' ? -REVEAL_WIDTH - : 0 - : 0; - const next = base + g.dx; - const clamped = Math.max(-overshoot, Math.min(overshoot, next)); - dx.setValue(clamped); - }, - onPanResponderRelease: (_, g) => { - claimed.current = false; - const currentMode = modeRef.current; - const currentRightAction = rightActionRef.current; - const currentLeftAction = leftActionRef.current; - if (currentMode === 'reveal') { - const base = - openSideRef.current === 'right' ? REVEAL_WIDTH - : openSideRef.current === 'left' ? -REVEAL_WIDTH - : 0; - const finalDx = base + g.dx; - if (finalDx > ACTIVATION_THRESHOLD && currentRightAction !== 'none') { - openTo('right'); - } else if (finalDx < -ACTIVATION_THRESHOLD && currentLeftAction !== 'none') { - openTo('left'); - } else { - close(); + // Built once. Every handler reads `latest.current`, so a rebuild would be a + // no-op anyway — which is what makes useMemo (free to discard its cache) + // safe here. + const responder = React.useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_, g) => { + if (!shouldClaimGesture(g, latest.current, openSideRef.current)) return false; + claimed.current = true; + return true; + }, + onPanResponderMove: (_, g) => { + dx.setValue(dragOffset(g, latest.current, openSideRef.current)); + }, + onPanResponderRelease: (_, g) => { + claimed.current = false; + const outcome = resolveRelease(g, latest.current, openSideRef.current); + switch (outcome.kind) { + case 'open': + openTo(outcome.side); + break; + case 'close': + close(); + break; + case 'fire': + fly(outcome.direction * (widthRef.current || EXIT_DISTANCE), outcome.action); + break; + case 'settle': + settle(); + break; } - return; - } - // Instant mode: fire on release-past-threshold, no second tap. - if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && currentRightAction !== 'none') { - fly(widthRef.current || EXIT_DISTANCE, currentRightAction); - } else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && currentLeftAction !== 'none') { - fly(-(widthRef.current || EXIT_DISTANCE), currentLeftAction); - } else { - Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); - } - }, - onPanResponderTerminate: () => { - if (modeRef.current === 'reveal') close(); - else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); - claimed.current = false; - }, - onPanResponderTerminationRequest: () => !claimed.current, - }), - ).current; + }, + onPanResponderTerminate: () => { + if (latest.current.mode === 'reveal') close(); + else settle(); + claimed.current = false; + }, + onPanResponderTerminationRequest: () => !claimed.current, + }), + [], + ); const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => { widthRef.current = e.nativeEvent.layout.width; diff --git a/src/components/__tests__/SwipeableRow.test.ts b/src/components/__tests__/SwipeableRow.test.ts deleted file mode 100644 index 050fa447..00000000 --- a/src/components/__tests__/SwipeableRow.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -describe('SwipeableRow logic', () => { - it('should use mutable refs so updated props are immediately reflected in gesture callbacks', () => { - let leftAction = 'read'; - let rightAction = 'archive'; - const onAction = vi.fn(); - - const leftActionRef = { current: leftAction }; - const rightActionRef = { current: rightAction }; - const onActionRef = { current: onAction }; - - const fireAction = (side: 'left' | 'right') => { - const action = side === 'left' ? leftActionRef.current : rightActionRef.current; - onActionRef.current(action); - }; - - // Initially left action is 'read' - fireAction('left'); - expect(onAction).toHaveBeenCalledWith('read'); - - // Simulate prop change on re-render (e.g. user updated setting to 'delete') - leftAction = 'delete'; - leftActionRef.current = leftAction; - - // Call gesture release handler again without re-creating gesture instance - fireAction('left'); - expect(onAction).toHaveBeenLastCalledWith('delete'); - }); -}); diff --git a/src/components/__tests__/swipe-gesture.test.ts b/src/components/__tests__/swipe-gesture.test.ts new file mode 100644 index 00000000..2db18ec2 --- /dev/null +++ b/src/components/__tests__/swipe-gesture.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import { + shouldClaimGesture, + dragOffset, + resolveRelease, + restingOffset, + exitsRow, + COMMIT_THRESHOLD, + ACTIVATION_THRESHOLD, + REVEAL_WIDTH, + type SwipeConfig, +} from '../swipe-gesture'; + +const instant = (over: Partial = {}): SwipeConfig => ({ + mode: 'instant', + leftAction: 'delete', + rightAction: 'archive', + ...over, +}); + +const reveal = (over: Partial = {}): SwipeConfig => instant({ mode: 'reveal', ...over }); + +// PanResponder hands over dx/dy/vx; the rest of the gesture state is unused. +const g = (dx: number, opts: { dy?: number; vx?: number } = {}) => ({ + dx, + dy: opts.dy ?? 0, + vx: opts.vx ?? 0, +}); + +describe('shouldClaimGesture', () => { + it('ignores drags too short to be intentional', () => { + expect(shouldClaimGesture(g(4), instant(), null)).toBe(false); + expect(shouldClaimGesture(g(-4), instant(), null)).toBe(false); + }); + + it('leaves mostly-vertical drags to the list scroll', () => { + expect(shouldClaimGesture(g(20, { dy: 40 }), instant(), null)).toBe(false); + expect(shouldClaimGesture(g(40, { dy: 20 }), instant(), null)).toBe(true); + }); + + it("declines the direction whose action is 'none'", () => { + const config = instant({ rightAction: 'none' }); + expect(shouldClaimGesture(g(50), config, null)).toBe(false); + expect(shouldClaimGesture(g(-50), config, null)).toBe(true); + }); + + it("declines both directions in selection mode, where both actions are 'none'", () => { + const config = instant({ leftAction: 'none', rightAction: 'none' }); + expect(shouldClaimGesture(g(50), config, null)).toBe(false); + expect(shouldClaimGesture(g(-50), config, null)).toBe(false); + }); + + it('claims any direction while a reveal band is open, so it can be dragged shut', () => { + const config = reveal({ leftAction: 'none', rightAction: 'archive' }); + expect(shouldClaimGesture(g(-50), config, 'right')).toBe(true); + // ...but not once it is closed again. + expect(shouldClaimGesture(g(-50), config, null)).toBe(false); + }); +}); + +describe('dragOffset', () => { + it('tracks the finger in instant mode', () => { + expect(dragOffset(g(70), instant(), null)).toBe(70); + expect(dragOffset(g(-70), instant(), null)).toBe(-70); + }); + + it('clamps to the overshoot limit', () => { + expect(dragOffset(g(9999), instant(), null)).toBe(240); + expect(dragOffset(g(-9999), instant(), null)).toBe(-240); + // Reveal mode allows much less overshoot than instant. + expect(dragOffset(g(9999), reveal(), null)).toBeCloseTo(REVEAL_WIDTH * 1.4); + }); + + it('offsets from the open position in reveal mode', () => { + // Already open to the right; dragging back left should approach 0. + expect(dragOffset(g(-REVEAL_WIDTH), reveal(), 'right')).toBe(0); + expect(dragOffset(g(-10), reveal(), 'right')).toBe(REVEAL_WIDTH - 10); + }); +}); + +describe('resolveRelease — instant mode', () => { + it('fires the action for the direction dragged past the threshold', () => { + expect(resolveRelease(g(COMMIT_THRESHOLD), instant(), null)).toEqual({ + kind: 'fire', + action: 'archive', + direction: 1, + }); + expect(resolveRelease(g(-COMMIT_THRESHOLD), instant(), null)).toEqual({ + kind: 'fire', + action: 'delete', + direction: -1, + }); + }); + + it('accepts a short fast flick as a commit', () => { + expect(resolveRelease(g(50, { vx: 0.8 }), instant(), null)).toMatchObject({ kind: 'fire' }); + // Same distance, too slow. + expect(resolveRelease(g(50, { vx: 0.2 }), instant(), null)).toEqual({ kind: 'settle' }); + }); + + it('springs back when the drag stops short', () => { + expect(resolveRelease(g(COMMIT_THRESHOLD - 1), instant(), null)).toEqual({ kind: 'settle' }); + }); + + it("never fires an action configured as 'none'", () => { + const config = instant({ rightAction: 'none' }); + expect(resolveRelease(g(999, { vx: 5 }), config, null)).toEqual({ kind: 'settle' }); + }); + + it('fires nothing in selection mode, however hard the row is flung', () => { + const config = instant({ leftAction: 'none', rightAction: 'none' }); + expect(resolveRelease(g(999, { vx: 5 }), config, null)).toEqual({ kind: 'settle' }); + expect(resolveRelease(g(-999, { vx: -5 }), config, null)).toEqual({ kind: 'settle' }); + }); +}); + +describe('resolveRelease — reveal mode', () => { + it('opens the band rather than firing', () => { + expect(resolveRelease(g(ACTIVATION_THRESHOLD + 1), reveal(), null)).toEqual({ + kind: 'open', + side: 'right', + }); + expect(resolveRelease(g(-ACTIVATION_THRESHOLD - 1), reveal(), null)).toEqual({ + kind: 'open', + side: 'left', + }); + }); + + it('closes when the drag stops short', () => { + expect(resolveRelease(g(ACTIVATION_THRESHOLD - 1), reveal(), null)).toEqual({ kind: 'close' }); + }); + + it('measures from the open position, so a small drag keeps it open', () => { + // Open to the right, nudged 5px further right: still well past the threshold. + expect(resolveRelease(g(5), reveal(), 'right')).toEqual({ kind: 'open', side: 'right' }); + // Dragged back past the resting point: closes. + expect(resolveRelease(g(-REVEAL_WIDTH), reveal(), 'right')).toEqual({ kind: 'close' }); + }); + + it("closes instead of opening a side whose action is 'none'", () => { + const config = reveal({ rightAction: 'none' }); + expect(resolveRelease(g(999), config, null)).toEqual({ kind: 'close' }); + }); +}); + +describe('restingOffset', () => { + it('maps the open side to the row translation it sits at', () => { + expect(restingOffset('right')).toBe(REVEAL_WIDTH); + expect(restingOffset('left')).toBe(-REVEAL_WIDTH); + expect(restingOffset(null)).toBe(0); + }); +}); + +describe('exitsRow', () => { + it('flags the actions that remove the row from the list', () => { + expect((['archive', 'delete', 'spam', 'move'] as const).every(exitsRow)).toBe(true); + }); + + it('keeps toggle actions in place', () => { + expect((['read', 'star', 'pin', 'none'] as const).some(exitsRow)).toBe(false); + }); +}); diff --git a/src/components/swipe-gesture.ts b/src/components/swipe-gesture.ts new file mode 100644 index 00000000..adbf9f25 --- /dev/null +++ b/src/components/swipe-gesture.ts @@ -0,0 +1,132 @@ +// Pure decision logic behind SwipeableRow's pan gesture. +// +// This lives outside the component for a reason beyond tidiness: the row's +// PanResponder is created once and its handlers therefore close over whatever +// props existed at mount. Taking the config as an argument makes staleness +// structurally impossible here — there is nothing to capture — and leaves the +// component with one job, keeping the current config somewhere the handlers +// can reach. + +import type { SwipeAction, SwipeMode } from '../stores/settings-store'; + +// Instant mode: distance the user must drag past on release to fire the action. +export const COMMIT_THRESHOLD = 96; +// Reveal mode: drag past this on release to snap to the open (revealed) state. +export const ACTIVATION_THRESHOLD = 32; +// Reveal mode: width of the action band shown when revealed. +export const REVEAL_WIDTH = 88; + +const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor +const MIN_DX_TO_CLAIM = 6; +const MAX_DRAG_OVERSHOOT_INSTANT = 240; +const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4; +// A short, fast drag commits too, so the gesture doesn't demand the full +// COMMIT_THRESHOLD when the user clearly flicks. +const FLING_DX = 40; +const FLING_VX = 0.5; + +/** + * The row's current swipe configuration. + * + * Note the naming: `rightAction` fires on a rightward (positive dx) drag and + * its band sits at the *left* edge; `leftAction` is the mirror image. + */ +export interface SwipeConfig { + leftAction: SwipeAction; + rightAction: SwipeAction; + mode: SwipeMode; +} + +/** The subset of RN's PanResponder gesture state this logic needs. */ +export interface SwipeGestureState { + dx: number; + dy: number; + vx: number; +} + +/** Which side's action band is currently held open (reveal mode only). */ +export type OpenSide = 'left' | 'right' | null; + +export type ReleaseOutcome = + /** Reveal mode: snap open so the band can be tapped. */ + | { kind: 'open'; side: 'left' | 'right' } + /** Reveal mode: snap shut. */ + | { kind: 'close' } + /** Instant mode: run the action; `direction` is the sign to fly the row off by. */ + | { kind: 'fire'; action: SwipeAction; direction: 1 | -1 } + /** Not far enough — spring back to rest without firing. */ + | { kind: 'settle' }; + +/** + * Whether the pan should be claimed from whatever else might want it (the + * list's vertical scroll, mainly). Declining leaves the gesture to the parent. + */ +export function shouldClaimGesture( + g: SwipeGestureState, + config: SwipeConfig, + openSide: OpenSide, +): boolean { + if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false; + if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false; + // An already-open row claims any horizontal drag so the user can drag it + // shut, even in the direction whose action is 'none'. + if (config.mode === 'reveal' && openSide !== null) return true; + if (g.dx > 0 && config.rightAction === 'none') return false; + if (g.dx < 0 && config.leftAction === 'none') return false; + return true; +} + +/** Row translation for an in-progress drag, clamped to the mode's overshoot. */ +export function dragOffset( + g: SwipeGestureState, + config: SwipeConfig, + openSide: OpenSide, +): number { + const reveal = config.mode === 'reveal'; + const overshoot = reveal ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT; + const base = reveal ? restingOffset(openSide) : 0; + return Math.max(-overshoot, Math.min(overshoot, base + g.dx)); +} + +/** Where the row sits at rest, given which side is open. */ +export function restingOffset(openSide: OpenSide): number { + if (openSide === 'right') return REVEAL_WIDTH; + if (openSide === 'left') return -REVEAL_WIDTH; + return 0; +} + +/** What releasing the drag here should do. */ +export function resolveRelease( + g: SwipeGestureState, + config: SwipeConfig, + openSide: OpenSide, +): ReleaseOutcome { + if (config.mode === 'reveal') { + const finalDx = restingOffset(openSide) + g.dx; + if (finalDx > ACTIVATION_THRESHOLD && config.rightAction !== 'none') { + return { kind: 'open', side: 'right' }; + } + if (finalDx < -ACTIVATION_THRESHOLD && config.leftAction !== 'none') { + return { kind: 'open', side: 'left' }; + } + return { kind: 'close' }; + } + // Instant mode: fire on release-past-threshold, no second tap. + const committedRight = g.dx >= COMMIT_THRESHOLD || (g.dx > FLING_DX && g.vx >= FLING_VX); + if (committedRight && config.rightAction !== 'none') { + return { kind: 'fire', action: config.rightAction, direction: 1 }; + } + const committedLeft = g.dx <= -COMMIT_THRESHOLD || (g.dx < -FLING_DX && g.vx <= -FLING_VX); + if (committedLeft && config.leftAction !== 'none') { + return { kind: 'fire', action: config.leftAction, direction: -1 }; + } + return { kind: 'settle' }; +} + +/** + * Actions that visibly remove the row from the list, so the row should fly off + * instead of snapping back. Toggle-style actions stay in place. + */ +export function exitsRow(action: SwipeAction): boolean { + return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move'; +}