import { MutableRefObject, useEffect, useRef } from 'react'; import { isNativePlatform } from '../../utils/capacitor'; import { ACTIVE_CLOSE_THRESHOLD_PX, COMMIT_THRESHOLD, DIRECTION_DEAD_ZONE_PX, RUBBER_BAND, } from './geometry'; import { CurtainSnap, isFormSnap } from './useCurtainState'; import { assertNeverCurtainTransition, CurtainTransition, resolveCurtainTransition, } from './useCurtainHandleGesture'; type Args = { // The curtain element. Touch listeners bind here so anywhere on the // curtain body — the chat list, an empty-state placeholder, the // DirectSelfRow / WorkspaceFooter at the bottom — can drive a // gesture. The handle's own listener (`useCurtainHandleGesture`) // is bound to a child element of this curtain and runs first; we // explicitly bail on touches that originate inside the handle so // the two surfaces don't double-engage. curtainRef: MutableRefObject; // The handle element. Used solely to short-circuit our listener // when the touch starts inside the handle's hit-zone (the handle // hook has already armed for that touch). handleRef: MutableRefObject; // The `bottomPinned` slot at the bottom of the curtain (hosts // DirectSelfRow, WorkspaceFooter). These rows open their own bottom // sheets via vertical drag, so a touch that starts there must NOT // engage the curtain body — otherwise the // user's «pull settings up» gesture would also pin the curtain // and the two motions would visually fight. `null` is fine (the // surface has no bottomPinned content); the contains() check is // optional-chained. bottomPinnedRef: MutableRefObject; // Scroll viewport of the chat list inside the curtain. The body // gesture engages only when this element is NOT scrollable // (scrollHeight ≤ clientHeight + 1): on long lists the user's // vertical drag must remain a native scroll gesture, on short / // empty lists the same drag drives the curtain instead. Treated // as «not scrollable» when `scrollRef.current` is null (some // listing surfaces render their empty state DIRECTLY as a curtain // child, bypassing `PageNavContent` — `Direct.tsx::DirectEmpty`, // `ChannelsRootNav::ChannelsLanding` — so scrollRef stays null and // the body gesture must still engage). scrollRef: MutableRefObject; // Current snap stop. Mirrored into a ref so the listener — bound // once per `disabled` flip — reads fresh values without rebinding. snap: CurtainSnap; // Per-pane pinned overlay; also ref-mirrored. Only READ here (used // by the touchstart bail) — pin / unpin commits are the handle's // exclusive contract, see «Direction asymmetry» on the hook. pinned: boolean; // Closed→peek travel for this curtain's chip count (1 chip on Direct, // 2 on Channels). The peek commit threshold scales off this so the // gesture matches the actual rest position. Ref-mirrored like `snap`. peekTravelPx: number; // Live drag delta sink — feeds the curtain's `top` via React state, // no direct DOM writes. setLiveDrag: (px: number, dragging: boolean) => void; // Snap commit. `'peek'` fires from closed-free's down-half; // `'closed'` fires from close-peek and form-close. The pin / unpin // paths are handle-only and never flip state through this setter // from the body. commit: (next: 'peek' | 'closed') => void; // Suppress gesture binding entirely. Same conditions as the handle // hook — see StreamHeader's `gestureDisabled`. disabled?: boolean; // Shared handle-visual sink. The grabber pill at the top of the // curtain animates Primary-blue + stretches whenever the user has // crossed the per-transition commit threshold, on ANY surface — // handle or body. Dedupe inside the hook keeps consumer re-renders // bounded to actual state flips. setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void; }; // Touch-gesture driver for the curtain BODY (everything outside the // dedicated drag-handle). Native-only. // // Why a second surface? On listing surfaces with content that fits in // one screen (empty Direct / Bots / Channels states, the ChannelsLanding // CTA, a workspace with few rooms) the user's natural «pull the curtain // down to peek» gesture happens anywhere on the visible card. // Restricting all motion to the 32 px handle on these surfaces felt // artificial. On the other hand, surfaces with a scrollable list need // their native vertical scroll preserved — so the body gesture is // *conditional*: it engages only when the chat list has no scrollable // content (scrollHeight ≤ clientHeight + 1). Long lists keep using the // handle for curtain motion. // // Direction asymmetry — pinning is handle-only, retracting is shared. // The body engages on: // * closed + DOWN → peek (closed-free, down-half only) // * peek + UP → closed (close-peek) // * form-* + UP → closed (form-close) // The body does NOT engage on: // * closed + UP → would be pin via closed-free's up-half. The // user reported that arbitrary upward drag on // the body made it too easy to accidentally // close the directs/channels/bots header by // pinning. Pin must be a deliberate gesture on // the dedicated pin-handle. After close-peek / // form-close lands at `closed`, the curtain // can only go further up via the handle. // * pinned + DOWN → unpin / peek-from-pinned. Same rationale: the // pin handle owns the unpin contract too, so an // accidental drag on the visible card can't // undo it. Bailed at touchstart (see Pinned // override below). // The asymmetric block on closed+UP is implemented in onTouchMove // after the transition resolves — we only bail closed-free's UP half, // not every upward drag, so close-peek and form-close still engage on // the body. // // Dynamics: all transitions use rubber-band 0.65 (= RUBBER_BAND) so // the body drag feels physically «heavier» than the handle's crisp // 1:1 — the user reads the two surfaces as distinct affordances. The // commit math is expressed in CURTAIN displacement (lastDelta), not // raw finger pull, so a body «commit at COMMIT_THRESHOLD × // PEEK_TRAVEL_PX» visually matches a handle commit at the same point — // only the finger pull needed to get there differs. // // Form-snap override: when a form is mounted, the chat list under it // is mostly hidden but still in DOM with whatever scrollHeight it has. // Skip the scrollable-bail in that case — the body's visible area is // the strip BELOW the form, and a drag there is unambiguously a // form-close intent (the only valid transition from form-* snap). // // Pinned override: the body gesture is INERT while the curtain is // pinned. Unpin is exclusively the handle's contract — the user has // to grab the dedicated pin-handle to release the lock, so an // accidental drag anywhere on the visible card doesn't undo it. We // bail at touchstart so no listener side-effects (preventDefault, // liveDrag emit, …) can fire either. export function useCurtainBodyGesture({ curtainRef, handleRef, bottomPinnedRef, scrollRef, snap, pinned, peekTravelPx, setLiveDrag, commit, disabled, setHandleState, }: Args): void { const snapRef = useRef(snap); snapRef.current = snap; const pinnedRef = useRef(pinned); pinnedRef.current = pinned; const peekTravelPxRef = useRef(peekTravelPx); peekTravelPxRef.current = peekTravelPx; const commitRef = useRef(commit); commitRef.current = commit; const setHandleStateRef = useRef(setHandleState); setHandleStateRef.current = setHandleState; useEffect(() => { if (!isNativePlatform()) return undefined; if (disabled) return undefined; const curtain = curtainRef.current; if (!curtain) return undefined; let startX: number | null = null; let startY: number | null = null; let direction: 'up' | 'down' | null = null; let transition: CurtainTransition | null = null; let engaged = false; let lastDelta = 0; // Same dedupe pattern as the handle hook — re-render the consumer // only on actual visual-state flips. let emittedDragging = false; let emittedAtCommit = false; const emitHandle = (dragging: boolean, atCommit: boolean) => { if (dragging === emittedDragging && atCommit === emittedAtCommit) return; emittedDragging = dragging; emittedAtCommit = atCommit; setHandleStateRef.current?.({ dragging, atCommit }); }; const onTouchStart = (e: TouchEvent) => { if (e.touches.length !== 1) return; // Pinned bail — handle owns unpin exclusively. See the «Pinned // override» note above the hook for the rationale. if (pinnedRef.current) return; // Hand off to the handle hook if the touch starts inside the // handle's 32 px hit-zone — the handle's own listener has // already armed for this touch. const target = e.target as Node | null; if (target && handleRef.current?.contains(target)) return; // Hand off to the bottomPinned region (DirectSelfRow, // WorkspaceFooter). Those rows host their own drag-to-open // bottom sheets — engaging the curtain gesture here would pin // the curtain in parallel with the sheet opening, and the two // motions would visually fight. if (target && bottomPinnedRef.current?.contains(target)) return; // Scroll-aware bail: leave a scrollable chat list to its native // vertical scroll. Skipped in form-* snaps because the visible // body area there is the strip BELOW the form (where the list // mostly isn't paintable anyway), and form-close is the only // valid transition — letting the list scroll instead would // strand the user in the form. const list = scrollRef.current; if (!isFormSnap(snapRef.current) && list && list.scrollHeight > list.clientHeight + 1) { return; } startX = e.touches[0].clientX; startY = e.touches[0].clientY; direction = null; transition = null; engaged = false; lastDelta = 0; }; const onTouchMove = (e: TouchEvent) => { if (e.touches.length !== 1) { // Second finger landed mid-gesture — abort. startX = null; startY = null; direction = null; transition = null; if (engaged) setLiveDrag(0, false); engaged = false; lastDelta = 0; emitHandle(false, false); return; } if (startY === null) return; const delta = e.touches[0].clientY - startY; const deltaX = startX !== null ? e.touches[0].clientX - startX : 0; if (direction === null) { if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return; // Horizontal-bail: pager horizontal swipe wins ties → we drop. if (Math.abs(deltaX) > Math.abs(delta)) { startX = null; startY = null; direction = null; return; } direction = delta > 0 ? 'down' : 'up'; transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction); if (transition === null) { // (snap, pinned, direction) has no valid motion — peek+down, // form+down (pinned+up also resolves to null, though the // touchstart pinned-bail already filters every pinned // gesture before we reach here). Bail without preventDefault // so any native default (overscroll bounce, etc.) can still // play. startX = null; startY = null; direction = null; return; } // Closed-free UP-half bail. closed-free is the only transition // whose upward direction commits to pin — and pin via body is // exactly what the user banned (see «Direction asymmetry» on // the hook). The downward half (closed → peek) stays on body. // close-peek and form-close are also upward, but their commit // target is `closed` — they're the «retract» gestures the user // explicitly wants to keep on the body, so they pass through. if (transition === 'closed-free' && direction === 'up') { startX = null; startY = null; direction = null; transition = null; return; } } engaged = true; e.preventDefault(); // Per-transition rubber-band dynamics + atCommit semantics. All // thresholds expressed against CURTAIN displacement (lastDelta) // so the body and the handle commit at the same visual point, // only the finger pull needed differs. let atCommit = false; switch (transition) { case 'closed-free': // Body-side `closed-free` is DOWN-only: the handle owns the // UP half (pin commit) per «Direction asymmetry» above, and // the closed-free up-bail in the dead-zone block makes sure // we only ever engage this branch with direction='down'. // Clamp at 0 below so a mid-gesture finger-up past the // start point can't drag the curtain into pin territory // and offer a pin commit that the user explicitly didn't // want exposed on the body. Rubber-banded 0.65× // displacement matches the «physically heavier» body feel. lastDelta = Math.max(0, delta * RUBBER_BAND); atCommit = lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD; break; case 'close-peek': // Rubber-banded up. No clamp either side — matches the // original list-bound peek feel; a downward jitter past the // peek snap is visually negligible against the rubber-band // damping. Commit target is `closed`; no path into pin // territory (the user's hard rule — pin is handle-only). lastDelta = delta * RUBBER_BAND; atCommit = -lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD; break; case 'form-close': // Rubber-banded up; capped at 0 so an accidental downward // jitter doesn't push the curtain below its form-snap top. // Commit target is `closed` (the form-close drag retracts // through the form's vertical footprint into the closed // snap). lastDelta = Math.min(0, delta * RUBBER_BAND); atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX; break; case 'pinned-free': // Unreachable on the body — the pinned bail at touchstart // prevents the hook from ever resolving this transition. // Kept here so the `never` default below stays exhaustive // and a future opening of pinned-free on the body would // need to wire the dispatch explicitly. break; case null: // Unreachable: `engaged` is set only after `transition` is // resolved non-null in the dead-zone block above. break; default: { assertNeverCurtainTransition(transition); break; } } setLiveDrag(lastDelta, true); emitHandle(true, atCommit); }; const onTouchEnd = () => { if (!engaged) { startX = null; startY = null; direction = null; transition = null; return; } switch (transition) { case 'closed-free': // Body is DOWN-only — peek is the sole commit target. Pin // commit lives on the handle (see «Direction asymmetry» // above and the touchmove switch). Below threshold the // curtain springs back to closed. if (lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) { commitRef.current('peek'); } else { setLiveDrag(0, false); } break; case 'close-peek': if (-lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) { commitRef.current('closed'); } else { setLiveDrag(0, false); } break; case 'form-close': if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) { commitRef.current('closed'); } else { setLiveDrag(0, false); } break; case 'pinned-free': case null: // Both unreachable per the touchmove switch above (pinned // bail at touchstart filters pinned-free; `engaged` only // flips once `transition` is non-null). The setLiveDrag // fallback preserves spring-back behaviour if a future // change exposes either path here. setLiveDrag(0, false); break; default: { assertNeverCurtainTransition(transition); setLiveDrag(0, false); break; } } startX = null; startY = null; direction = null; transition = null; engaged = false; lastDelta = 0; emitHandle(false, false); }; const onTouchCancel = () => { if (engaged) setLiveDrag(0, false); startX = null; startY = null; direction = null; transition = null; engaged = false; lastDelta = 0; emitHandle(false, false); }; curtain.addEventListener('touchstart', onTouchStart, { passive: true }); curtain.addEventListener('touchmove', onTouchMove, { passive: false }); curtain.addEventListener('touchend', onTouchEnd, { passive: true }); curtain.addEventListener('touchcancel', onTouchCancel, { passive: true }); return () => { curtain.removeEventListener('touchstart', onTouchStart); curtain.removeEventListener('touchmove', onTouchMove); curtain.removeEventListener('touchend', onTouchEnd); curtain.removeEventListener('touchcancel', onTouchCancel); // Same teardown contract as the handle hook — see its cleanup for // the rationale. If `disabled` flips true while a body drag is in // flight, the touchend never reaches us and the curtain would stay // frozen at the finger position until the next touch. if (engaged) { setLiveDrag(0, false); emitHandle(false, false); } }; // setLiveDrag is stable; the ref args are stable. `snap`, `pinned`, // and `commit` are ref-mirrored. Only `disabled` needs to tear // listeners down — it's the sole effect dep. // eslint-disable-next-line react-hooks/exhaustive-deps }, [curtainRef, handleRef, bottomPinnedRef, scrollRef, setLiveDrag, disabled]); }