vojo/src/app/components/stream-header/useCurtainBodyGesture.ts

427 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLDivElement | null>;
// 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<HTMLDivElement | null>;
// 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<HTMLDivElement | null>;
// 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<HTMLDivElement | null>;
// 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<CurtainSnap>(snap);
snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned;
const peekTravelPxRef = useRef<number>(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]);
}