import { MutableRefObject, useEffect, useRef } from 'react'; import { COMMIT_FRACTION, DEAD_ZONE_PX, EDGE_GUARD_PX, MIN_COMMIT_PX, RUBBER_BAND_FACTOR, } from './geometry'; type Args = { // Root element the touch listeners attach to. Touches outside this // element never reach the pager — that's how we keep the gesture // scoped to the listing surface and out of detail routes. rootRef: MutableRefObject; // Index of the currently active pane. Mirrored into a ref so the // single bound effect reads fresh values without re-attaching. activeIdx: number; // Total number of panes. Used to clamp commit + rubber-band edges. tabsCount: number; // While true the listeners stay bound but every touchstart bails // immediately. Used by the parent to suppress the gesture when an // overlay sheet (settings, workspace switcher) is open — a swipe // there shouldn't navigate sibling tabs. disabled: boolean; // Setter for the live drag delta. The pager component re-renders the // strip transform on every change. setDragPx: (px: number, dragging: boolean) => void; // Commit a tab change. The caller is expected to reset dragPx to 0 // AND call navigate(replace) in the same React batch so the strip's // transform jumps from (oldIdx, dragPx) to (newIdx, 0) in one render // — CSS transition then animates the (small) remaining distance // smoothly without an intermediate "snap back" flash. commitTo: (idx: number) => void; }; // Horizontal swipe driver for the mobile listing tab pager. Mirrors // the shape of `useCurtainHandleGesture`: single listener bound to // the pager root, refs for the latest snap/index state, axis-resolve // in the dead-zone, rubber-band at boundaries, threshold-commit on // release. // // Conflict resolution with other gestures sharing the same surface // (curtain, MobileSettingsHorseshoe, ChannelsWorkspaceHorseshoe) is // cooperative: every gesture-owner resolves axis inside a dead-zone // and bails when its own axis doesn't dominate. The pager and the // horseshoes resolve at 12px; the CURTAIN hooks resolve at 10px // (`stream-header/geometry.ts::DIRECTION_DEAD_ZONE_PX`) — i.e. in the // 10–12px window the curtain decides first, by design (its vertical // pull is the most frequent gesture and should feel the most eager). // The pager wins horizontal; the others win vertical. export function useMobileTabsPagerGesture({ rootRef, activeIdx, tabsCount, disabled, setDragPx, commitTo, }: Args): void { const activeRef = useRef(activeIdx); const countRef = useRef(tabsCount); const disabledRef = useRef(disabled); activeRef.current = activeIdx; countRef.current = tabsCount; disabledRef.current = disabled; useEffect(() => { const root = rootRef.current; if (!root) return undefined; let startX: number | null = null; let startY: number | null = null; let engaged = false; let bailed = false; let lastDragPx = 0; const reset = () => { startX = null; startY = null; engaged = false; bailed = false; lastDragPx = 0; }; const onTouchStart = (e: TouchEvent) => { if (disabledRef.current) { reset(); return; } if (e.touches.length !== 1) { reset(); return; } const t = e.touches[0]; const vw = window.innerWidth; // Android system back-gesture lives in the L/R edge strip in // edge-to-edge mode. Ignore touches there so we don't fight it. if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) { reset(); return; } startX = t.clientX; startY = t.clientY; engaged = false; bailed = false; lastDragPx = 0; }; const onTouchMove = (e: TouchEvent) => { if (e.touches.length !== 1) { // Second finger landed mid-gesture — abort without commit. if (engaged) setDragPx(0, false); reset(); bailed = true; return; } // Defensive symmetry with onTouchStart's disabled check: a sheet // opening async between touchstart and touchmove (e.g. atom flip // from a delayed effect) shouldn't let an already-armed pager // gesture commit through. if (disabledRef.current) { if (engaged) setDragPx(0, false); reset(); bailed = true; return; } if (startX === null || startY === null || bailed) return; const t = e.touches[0]; const dx = t.clientX - startX; const dy = t.clientY - startY; if (!engaged) { // Wait for the finger to leave the dead-zone before deciding // who owns the gesture. The pager only engages when |dx| // strictly dominates |dy|; ties go to vertical (curtain + // horseshoe pull-down feels more natural than horizontal // commit for ambiguous gestures). if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return; if (Math.abs(dy) >= Math.abs(dx)) { bailed = true; return; } engaged = true; } if (e.cancelable) e.preventDefault(); const vw = window.innerWidth; const idx = activeRef.current; const count = countRef.current; let drag = dx; // Rubber-band at the leftmost (dx > 0 = trying to go past idx 0) // and rightmost (dx < 0 = trying to go past last idx) boundary. // Soft attenuation — commit threshold can never be reached, so // the spring-back on release lands us back on the current tab. if (idx === 0 && dx > 0) drag = dx * RUBBER_BAND_FACTOR; else if (idx === count - 1 && dx < 0) drag = dx * RUBBER_BAND_FACTOR; // Clamp to ±one viewport so an overshooting swipe doesn't // translate the strip into nonsense territory. drag = Math.max(-vw, Math.min(vw, drag)); lastDragPx = drag; setDragPx(drag, true); }; const onTouchEnd = () => { if (!engaged) { reset(); return; } // Defensive recheck symmetric with onTouchStart / onTouchMove: // an overlay sheet could have opened between the last touchmove // and this touchend (atom flip from a delayed effect, a system // dialog, etc.). Committing under those circumstances would // navigate sibling tabs from beneath the overlay — same hazard // the touchstart/move gates exist to prevent. Spring back // instead. if (disabledRef.current) { setDragPx(0, false); reset(); return; } const vw = window.innerWidth; const idx = activeRef.current; const count = countRef.current; const threshold = Math.max(MIN_COMMIT_PX, vw * COMMIT_FRACTION); let nextIdx = idx; // Negative drag (finger moved left) → next tab to the right. // Positive drag (finger moved right) → previous tab to the left. if (lastDragPx <= -threshold && idx < count - 1) nextIdx = idx + 1; else if (lastDragPx >= threshold && idx > 0) nextIdx = idx - 1; if (nextIdx !== idx) { commitTo(nextIdx); } else { // No commit — re-enable transition and animate the strip back // to its resting position at the current tab. setDragPx(0, false); } reset(); }; const onTouchCancel = () => { // System cancel (incoming call, scroll-take-over, etc.) — never // commit; just spring back if a drag was in flight. if (engaged) setDragPx(0, false); reset(); }; root.addEventListener('touchstart', onTouchStart, { passive: true }); root.addEventListener('touchmove', onTouchMove, { passive: false }); root.addEventListener('touchend', onTouchEnd, { passive: true }); root.addEventListener('touchcancel', onTouchCancel, { passive: true }); return () => { root.removeEventListener('touchstart', onTouchStart); root.removeEventListener('touchmove', onTouchMove); root.removeEventListener('touchend', onTouchEnd); root.removeEventListener('touchcancel', onTouchCancel); }; // setDragPx / commitTo are stable useCallbacks from the parent; // activeIdx / tabsCount are mirrored via the refs above so the // listener reads fresh values without re-binding on every nav. // eslint-disable-next-line react-hooks/exhaustive-deps }, [rootRef, setDragPx, commitTo]); }