380 lines
16 KiB
TypeScript
380 lines
16 KiB
TypeScript
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]);
|
||
}
|