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

380 lines
16 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,
PEEK_TRAVEL_PX,
PIN_COMMIT_THRESHOLD,
PIN_TRAVEL_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, ChannelCreateRow, 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.
pinned: boolean;
setPinned: (next: boolean) => void;
// 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 / close-peek / form-close). Narrowed to the two
// non-form destinations the hook ever reaches. pin/unpin flips
// `pinned` instead.
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» / «push the curtain up to pin» gestures happen 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.
//
// 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 PIN_COMMIT_THRESHOLD ×
// PIN_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,
setPinned,
setLiveDrag,
commit,
disabled,
setHandleState,
}: Args): void {
const snapRef = useRef<CurtainSnap>(snap);
snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned;
const setPinnedRef = useRef(setPinned);
setPinnedRef.current = setPinned;
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, ChannelCreateRow). 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 — pinned+up,
// peek+down, form+down. Bail without preventDefault so any
// native default (overscroll bounce, etc.) can still play.
startX = null;
startY = null;
direction = 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':
// Rubber-banded free-range drag spanning pin↔closed↔peek
// in one motion. NO clamps either side — the curtain
// follows the finger off-screen upward and continuously
// into peek territory downward. Direction-aware atCommit
// shows the right commit feedback for whichever side the
// user is leaning into. Mirrors the handle's `closed-free`
// but with 0.65× displacement so the body drag reads as
// physically «heavier».
lastDelta = delta * RUBBER_BAND;
atCommit =
lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / PEEK_TRAVEL_PX >= 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.
lastDelta = delta * RUBBER_BAND;
atCommit = -lastDelta / PEEK_TRAVEL_PX >= 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.
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':
// Direction-aware commit, sign-exclusive: pin wins UP-side,
// peek wins DOWN-side, below both thresholds spring back to
// closed.
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true);
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else {
setLiveDrag(0, false);
}
break;
case 'close-peek':
if (-lastDelta / PEEK_TRAVEL_PX >= 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; 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`,
// `setPinned` 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]);
}