import { MutableRefObject, useEffect, useRef } from 'react'; import { isNativePlatform } from '../../utils/capacitor'; import { ACTIVE_CLOSE_THRESHOLD_PX, COMMIT_THRESHOLD, DIRECTION_DEAD_ZONE_PX, PEEK_TRAVEL_PX, RUBBER_BAND, } from './geometry'; import { CurtainSnap, isFormSnap } from './useCurtainState'; type Args = { // The scroll viewport that hosts the chat list inside the curtain. // Touch events fire here; gestures engage when the list is at // `scrollTop === 0` (peek path) or any scrollTop (form-close path). scrollRef: MutableRefObject; // Current snap stop. Read at touchstart to decide gesture meaning. // Mirrored into a ref so the listener (bound once) reads fresh values. snap: CurtainSnap; // Setter for the live drag delta during touchmove. The hook reads // `liveDragPx` from the parent state too, so React drives the // curtain's `top` re-render — no direct DOM writes, no inline-vs- // React transition coordination. setLiveDrag: (px: number, dragging: boolean) => void; // Commit a new snap stop on release. commit: (next: CurtainSnap) => void; }; // Touch-gesture driver for the curtain. Native-only: on web/PC the // listeners aren't attached at all. // // Peek path: drag down from `closed` rubber-bands the live delta and // on release past the threshold commits to `peek` (both chips // revealed in one motion). Drag UP from `peek` retreats to `closed`. // // Form-close path: drag UP from a form snap tracks the finger 1:1; on // release past `ACTIVE_CLOSE_THRESHOLD_PX` commits to `closed`. export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args): void { // Mirror snap into a ref so the listener — bound once via useEffect — // always reads the freshest value without re-attaching. const snapRef = useRef(snap); snapRef.current = snap; useEffect(() => { if (!isNativePlatform()) return undefined; const list = scrollRef.current; if (!list) return undefined; let startY: number | null = null; let direction: 'up' | 'down' | null = null; let engaged = false; let lastDelta = 0; const onTouchStart = (e: TouchEvent) => { if (e.touches.length !== 1) return; startY = e.touches[0].clientY; direction = null; engaged = false; lastDelta = 0; // Peek-reveal requires scrollTop === 0 (no content above to // scroll). Form-close engages regardless of scrollTop (the form // is open, the list scroll is the close target). if (!isFormSnap(snapRef.current) && list.scrollTop !== 0) { startY = null; } }; const onTouchMove = (e: TouchEvent) => { if (e.touches.length !== 1) { // Second finger landed mid-gesture — abort. startY = null; direction = null; if (engaged) setLiveDrag(0, false); engaged = false; lastDelta = 0; return; } if (startY === null) { // Active mode may re-arm startY here if onTouchStart bailed. if (isFormSnap(snapRef.current)) { startY = e.touches[0].clientY; } else { return; } } const delta = e.touches[0].clientY - startY; const currentSnap = snapRef.current; // Resolve a direction once the finger crosses the dead-zone. if (direction === null) { if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return; direction = delta > 0 ? 'down' : 'up'; // Direction guards: nothing higher than `closed`; nothing // lower than `peek`; form snaps only close (up). if (currentSnap === 'closed' && direction === 'up') { startY = null; direction = null; return; } if (currentSnap === 'peek' && direction === 'down') { startY = null; direction = null; return; } if (isFormSnap(currentSnap) && direction === 'down') { startY = null; direction = null; return; } } engaged = true; e.preventDefault(); if (isFormSnap(currentSnap)) { // Form close: finger moves UP (delta < 0). Track 1:1, capped // at 0 so an accidental downward jitter doesn't push the // curtain below its resting position. lastDelta = Math.min(0, delta); } else { // Peek: rubber-banded BOTH directions. Down (delta > 0) reveals // more chips; up (delta < 0) retreats toward `closed`. Bounds // are enforced by the direction guards above plus the snap // clamp on touchend, so we don't clamp here. lastDelta = delta * RUBBER_BAND; } setLiveDrag(lastDelta, true); }; const onTouchEnd = () => { if (!engaged) { startY = null; direction = null; return; } const currentSnap = snapRef.current; let next: CurtainSnap = currentSnap; if (isFormSnap(currentSnap)) { if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) { next = 'closed'; } } else { // Single-stage peek toggle. Threshold is COMMIT_THRESHOLD of // the FULL peek travel — the rubber-banded drag must reach // ≈90% of the closed→peek distance before release for the // snap to flip. Anything shorter springs back so a brisk-but- // short drag doesn't accidentally open (or close) the curtain. const progress = lastDelta / PEEK_TRAVEL_PX; if (Math.abs(progress) >= COMMIT_THRESHOLD) { if (currentSnap === 'closed' && progress > 0) next = 'peek'; else if (currentSnap === 'peek' && progress < 0) next = 'closed'; } } if (next !== currentSnap) { // commit() also resets liveDragPx + isDragging to 0/false in // one batched update — React renders the curtain at the new // resting top with the snap transition re-enabled. commit(next); } else { // No commit: drop the live drag back to 0 with transition // active so the curtain springs back to its resting position. setLiveDrag(0, false); } startY = null; direction = null; engaged = false; lastDelta = 0; }; const onTouchCancel = () => { // System cancel never commits — always snap back to current snap. if (engaged) setLiveDrag(0, false); startY = null; direction = null; engaged = false; lastDelta = 0; }; list.addEventListener('touchstart', onTouchStart, { passive: true }); list.addEventListener('touchmove', onTouchMove, { passive: false }); list.addEventListener('touchend', onTouchEnd, { passive: true }); list.addEventListener('touchcancel', onTouchCancel, { passive: true }); return () => { list.removeEventListener('touchstart', onTouchStart); list.removeEventListener('touchmove', onTouchMove); list.removeEventListener('touchend', onTouchEnd); list.removeEventListener('touchcancel', onTouchCancel); }; // setLiveDrag/commit are stable useCallbacks; scrollRef is stable. // `snap` is mirrored via snapRef written above on every render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrollRef, setLiveDrag, commit]); }