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, PIN_COMMIT_THRESHOLD, PIN_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; // Per-pane pinned overlay. When true the curtain renders at the top // regardless of `snap`, and the gesture interprets a drag-down as // unpin instead of peek-reveal. Also mirrored into a ref for the // bound-once listener. pinned: boolean; // Setter for the pinned overlay; called on release once the user's // up-drag (from closed) or down-drag (from pinned) past the commit // threshold qualifies the gesture. setPinned: (next: boolean) => void; // 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; // Suppress gesture binding entirely. Used to gate pinning when a // bottom sheet (Settings, workspace switcher) is open — otherwise // a drag-up on the visible list above the sheet would mutate the // pin atom underneath the sheet and the user would see an unexpected // pinned curtain on sheet dismissal. Also gated when this pane is // inactive inside the swipe pager so stray touches on offscreen // panes can't mutate any tab's pin. disabled?: boolean; }; // Touch-gesture driver for the curtain. Native-only: on web/PC the // listeners aren't attached at all. // // Peek path: drag down from `closed` (not pinned) 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`. // // Pin path: drag UP from `closed` (not pinned) tracks the finger 1:1 // with a hard clamp at -PIN_TRAVEL_PX (curtain stops flush above the // tabs row — never enters the system-tray safe-top zone). On release // past `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` flips `pinned = true`. // Only reachable when the list has no scrollable content — with scroll // the user's drag-up means «scroll list down» and the gesture defers // to native (see the scroll-aware bail in `onTouchMove`). // // Unpin: NOT handled here. Once pinned, the list-bound gesture is // inert in both directions — `useCurtainHandleGesture` (the handle at // the top of the curtain) owns the unpin transition exclusively. A // drag-down on the list while pinned used to unpin here, but that // path was a confusing back-door: the handle is the canonical // pin/unpin surface and the only one the user is taught. // // 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, pinned, setPinned, setLiveDrag, commit, disabled, }: 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; // Same mirror trick for the global pinned overlay so the listener // sees fresh values without re-binding on every render. const pinnedRef = useRef(pinned); pinnedRef.current = pinned; // Stable setter ref so the bound-once listener can call the latest // setPinned without us needing to add it to the effect deps. const setPinnedRef = useRef(setPinned); setPinnedRef.current = setPinned; useEffect(() => { if (!isNativePlatform()) return undefined; if (disabled) return undefined; const list = scrollRef.current; if (!list) return undefined; let startX: number | null = null; 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; startX = e.touches[0].clientX; 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) { startX = null; startY = null; } }; const onTouchMove = (e: TouchEvent) => { if (e.touches.length !== 1) { // Second finger landed mid-gesture — abort. startX = null; 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)) { startX = e.touches[0].clientX; startY = e.touches[0].clientY; } else { return; } } const delta = e.touches[0].clientY - startY; const deltaX = startX !== null ? e.touches[0].clientX - startX : 0; const currentSnap = snapRef.current; const currentPinned = pinnedRef.current; // Resolve a direction once the finger crosses the dead-zone. if (direction === null) { if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return; // Horizontal-bail: if the finger crosses the dead-zone with // |dx| strictly greater than |dy|, the user is swiping the // mobile tab pager, not pulling the curtain. Drop our tracking // state so the pager owns the gesture; ties still resolve to // vertical (curtain) because pull-down is the more common // intent on the listing surface. if (Math.abs(deltaX) > Math.abs(delta)) { startX = null; startY = null; direction = null; return; } direction = delta > 0 ? 'down' : 'up'; // Direction guards: // - pinned ⇒ list-bound gesture is inert. UP would push past // y = -safe-top into the system-tray zone; DOWN would // unpin, but unpin is handle-only territory (the dedicated // drag-handle at the top of the curtain — see // `useCurtainHandleGesture`). Both bail here. // - peek ⇒ only UP (close); DOWN has nowhere lower to go. // - form ⇒ only UP (close); DOWN reads as a list scroll. // - closed + UP ⇒ pin path (only when list has no scroll). // - closed + DOWN ⇒ peek path. if (currentPinned) { startX = null; startY = null; direction = null; return; } // Below this point currentPinned is provably false (the pinned // bail above returned). The `!currentPinned` qualifier on each // remaining guard would be redundant, so it's dropped. if (currentSnap === 'peek' && direction === 'down') { startX = null; startY = null; direction = null; return; } if (isFormSnap(currentSnap) && direction === 'down') { startX = null; startY = null; direction = null; return; } // Pin from the LIST is allowed only when the list has nothing // to scroll (single-screen chat list). With scrollable content, // drag-up at `scrollTop === 0` is the user's natural «scroll // list down to reveal more rows» gesture and hijacking it for // pin (and the resulting preventDefault) leaves the user // staring at a frozen list. Pinning a scrollable list goes // through the dedicated handle at the top of the curtain // instead (see `useCurtainHandleGesture`). Single-screen lists // keep the drag-from-anywhere pin path so the original UX is // preserved when there's no list-scroll to compete with. if ( currentSnap === 'closed' && direction === 'up' && list.scrollHeight > list.clientHeight + 1 ) { startX = null; startY = null; direction = null; return; } } engaged = true; e.preventDefault(); // currentPinned is false here (the pinned bail above returned). // List-bound pin/unpin doesn't exist as a path on this hook — // unpin is exclusively the handle hook's responsibility, and // pin from the list is only reachable in the no-scroll branch // below. 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 if (currentSnap === 'closed' && direction === 'up') { // Pin from list (only reachable when the list has no scroll // — see the scroll-aware bail above). 1:1 finger ↔ curtain // so the user gets the same direct feel as the dedicated // handle. Clamped at -PIN_TRAVEL_PX so the curtain never // enters the system-tray safe-top zone (see geometry's // «pinned visual contract»). lastDelta = Math.max(-PIN_TRAVEL_PX, 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; const currentPinned = pinnedRef.current; let next: CurtainSnap = currentSnap; let nextPinned = currentPinned; // currentPinned is false here in any engaged gesture (the // direction guard bails when pinned). The unpin commit branch // that used to live here moved to `useCurtainHandleGesture`. if (isFormSnap(currentSnap)) { if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) { next = 'closed'; } } else if (currentSnap === 'closed' && direction === 'up') { // Pin commit: drag UP past ≥ PIN_COMMIT_THRESHOLD of the full // travel flips pinned → true. The user emphasised this must // require «дотянул прям до самого верха» — high threshold // matches that intent. const progress = -lastDelta / PIN_TRAVEL_PX; if (progress >= PIN_COMMIT_THRESHOLD) { nextPinned = true; } } 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 (nextPinned !== currentPinned) { // setPinned() resets liveDragPx + isDragging in the same // batched update — React renders the curtain at the new // pinned-derived top with the snap transition re-enabled. setPinnedRef.current(nextPinned); } else 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); } startX = null; 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); startX = null; 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`, `pinned` and `setPinned` are mirrored via the refs above // so the listener (bound once per disabled-flip) reads fresh values // without re-attaching every render. `disabled` is the only signal // that needs to tear the listeners down — it goes into the deps. // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrollRef, setLiveDrag, commit, disabled]); }