refactor(stream-header): unify curtain gestures onto dual handle+body surfaces with 1:1 handle and rubber-band body, scroll-aware bail

This commit is contained in:
heaven 2026-05-19 23:27:19 +03:00
parent e866cd3830
commit ab283e9788
6 changed files with 613 additions and 485 deletions

View file

@ -34,9 +34,9 @@ type Args = {
}; };
// Horizontal swipe driver for the mobile listing tab pager. Mirrors // Horizontal swipe driver for the mobile listing tab pager. Mirrors
// the shape of `useCurtainGesture`: single listener bound to the // the shape of `useCurtainHandleGesture`: single listener bound to
// pager root, refs for the latest snap/index state, axis-resolve in // the pager root, refs for the latest snap/index state, axis-resolve
// the dead-zone, rubber-band at boundaries, threshold-commit on // in the dead-zone, rubber-band at boundaries, threshold-commit on
// release. // release.
// //
// Conflict resolution with other gestures sharing the same surface // Conflict resolution with other gestures sharing the same surface

View file

@ -23,8 +23,8 @@ import * as css from './StreamHeader.css';
import { Segment } from './Segment'; import { Segment } from './Segment';
import { Chip } from './Chip'; import { Chip } from './Chip';
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState'; import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
import { useCurtainGesture } from './useCurtainGesture';
import { useCurtainHandleGesture } from './useCurtainHandleGesture'; import { useCurtainHandleGesture } from './useCurtainHandleGesture';
import { useCurtainBodyGesture } from './useCurtainBodyGesture';
import { InlineNewChatForm } from './forms/InlineNewChatForm'; import { InlineNewChatForm } from './forms/InlineNewChatForm';
import { InlineRoomSearch } from './forms/InlineRoomSearch'; import { InlineRoomSearch } from './forms/InlineRoomSearch';
@ -32,10 +32,14 @@ const INLINE_FORM_ID = 'stream-header-inline-form';
type StreamHeaderProps = { type StreamHeaderProps = {
// Scroll viewport that hosts the chat list under the curtain. The // Scroll viewport that hosts the chat list under the curtain. The
// curtain's children (`children` prop) render inside an element that // curtain BODY gesture (`useCurtainBodyGesture`) reads this ref's
// receives `scrollRef` automatically — the parent doesn't need to // `scrollHeight`/`clientHeight` to decide whether to engage: long
// wire it. The ref is used by the touch gesture to recognise list // lists keep native scroll, short / empty lists drive the curtain
// scrollTop=0 and engage the peek-reveal. // via body drag. May be a ref whose `.current` is null on listing
// surfaces that render their empty state directly as a curtain
// child without wrapping it in `PageNavContent` (Direct's
// `DirectEmpty`, ChannelsRootNav's `ChannelsLanding`) — the body
// gesture treats null as «not scrollable» and engages.
scrollRef: MutableRefObject<HTMLDivElement | null>; scrollRef: MutableRefObject<HTMLDivElement | null>;
// Curtain contents — the chat list. The list is rendered inside an // Curtain contents — the chat list. The list is rendered inside an
// `overflow: auto` div that the gesture hook listens to. // `overflow: auto` div that the gesture hook listens to.
@ -101,8 +105,8 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
const curtain = useCurtainState(pinKey); const curtain = useCurtainState(pinKey);
// Suppress the curtain gesture whenever the user is interacting with // Suppress every curtain gesture whenever the user is interacting
// something else that would otherwise race the pin path: // with something else that would otherwise race the pin path:
// //
// * Settings sheet open (DirectSelfRow-originated bottom sheet) — // * Settings sheet open (DirectSelfRow-originated bottom sheet) —
// a drag-up on the still-visible list above the sheet would // a drag-up on the still-visible list above the sheet would
@ -122,31 +126,34 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
const offscreenPagerPane = inPagerMode && !isActivePagerPane; const offscreenPagerPane = inPagerMode && !isActivePagerPane;
const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || offscreenPagerPane; const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || offscreenPagerPane;
useCurtainGesture({ // Two parallel curtain-gesture surfaces:
scrollRef,
snap: curtain.snap,
pinned: curtain.pinned,
setPinned: curtain.setPinned,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
disabled: gestureDisabled,
});
// Dedicated pin / unpin gesture on the drag handle at the top of
// the curtain. The handle exists specifically because the list-bound
// pin path conflicts with vertical list scroll at `scrollTop === 0`
// — see `useCurtainHandleGesture` and `useCurtainGesture`'s list-
// scroll guard for the full rationale. Same `disabled` gating as the
// list gesture so a bottom sheet or offscreen pager pane can't
// accidentally pin/unpin.
// //
// `handleVisual` mirrors the desktop `PageNavResizeHandle` state // * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
// machine: `dragging` lights up the grabber pill while the gesture // at the top of the curtain. Crisp 1:1 finger ↔ curtain on
// is active; `atCommit` further stretches + brightens it once the // every transition (pin, unpin, peek, close-peek, form-close).
// user has crossed `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` so the // Engages regardless of whether the chat list is scrollable —
// release-to-confirm moment is unambiguous. The hook dedupes so this // the handle is a distinct surface and never competes with list
// only re-renders when the visual state actually changes. // scroll.
//
// * `useCurtainBodyGesture` — anywhere on the curtain body
// OUTSIDE the handle (chat list, empty-state placeholder,
// bottom-pinned row). Rubber-banded (0.65) on every transition
// so the body drag reads as physically «heavier» than the
// handle's crisp pull. Engages ONLY when the chat list has no
// scrollable content — long lists keep native vertical scroll;
// short / empty lists let the user pull the curtain «from
// anywhere».
//
// Both hooks share `handleVisual` (mirrors desktop
// `PageNavResizeHandle`: `dragging` lights up the grabber pill;
// `atCommit` stretches + brightens it once the user crosses the
// per-transition commit threshold). The two surfaces are mutually
// exclusive on each touch (handle's listener short-circuits when
// the touch starts on the handle; body's listener does the same
// when it ISN'T on the handle), so they never fight over the
// visual.
const handleRef = useRef<HTMLDivElement>(null); const handleRef = useRef<HTMLDivElement>(null);
const curtainRef = useRef<HTMLDivElement>(null);
const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({ const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
dragging: false, dragging: false,
atCommit: false, atCommit: false,
@ -157,6 +164,19 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
pinned: curtain.pinned, pinned: curtain.pinned,
setPinned: curtain.setPinned, setPinned: curtain.setPinned,
setLiveDrag: curtain.setLiveDrag, setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
disabled: gestureDisabled,
setHandleState: setHandleVisual,
});
useCurtainBodyGesture({
curtainRef,
handleRef,
scrollRef,
snap: curtain.snap,
pinned: curtain.pinned,
setPinned: curtain.setPinned,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
disabled: gestureDisabled, disabled: gestureDisabled,
setHandleState: setHandleVisual, setHandleState: setHandleVisual,
}); });
@ -420,6 +440,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
so the snap commit animates smoothly without an intermediate so the snap commit animates smoothly without an intermediate
"snap back then animate forward" flash. */} "snap back then animate forward" flash. */}
<div <div
ref={curtainRef}
className={css.curtain} className={css.curtain}
style={{ style={{
top: toRem(curtainTop), top: toRem(curtainTop),
@ -429,11 +450,12 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
> >
{/* Drag handle (native-only behaviour, but rendered on all {/* Drag handle (native-only behaviour, but rendered on all
platforms so the layout stays identical the gesture hook platforms so the layout stays identical the gesture hook
short-circuits off-native). Hosts the pin / unpin touch short-circuits off-native). Hosts the entire curtain
listener and gives the user a visible affordance at the top gesture surface pin, unpin, peek, close-peek and
of the curtain. Stays mounted across snap transitions so form-close all bind here, leaving the chat list to native
the gesture surface is always reachable when there is one scroll. Stays mounted across snap transitions so the
to make. gesture surface is always reachable when there is one to
make.
`data-dragging` / `data-at-commit` mirror the desktop `data-dragging` / `data-at-commit` mirror the desktop
`PageNavResizeHandle`: CSS selectors on `handleBar` light `PageNavResizeHandle`: CSS selectors on `handleBar` light

View file

@ -97,24 +97,27 @@ export const PIN_TRAVEL_PX = TABS_ROW_PX;
// release for the snap to flip. Anything shorter reads as accidental // release for the snap to flip. Anything shorter reads as accidental
// and springs back to the previous resting snap. // and springs back to the previous resting snap.
// //
// With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin // With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin
// see `useCurtainGesture` and `useCurtainHandleGesture`), the // see `useCurtainHandleGesture`), the committing finger pull is
// committing finger pull is `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ // `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag
// 61 px — essentially «drag the curtain across the full tabs-row // the curtain across the full tabs-row height». The anti-accidental
// height». The anti-accidental gate that previously came from // gate is provided by the dedicated handle hit-zone (intentional
// rubber-band amplification is now provided by the dedicated handle // surface) — the chat list under the curtain is left to native
// hit-zone (intentional surface) plus the list-bound scroll-aware // scroll and never engages a pin path, so there's no scroll-vs-pin
// bail (no list scroll = no scroll-up to confuse with pin). // ambiguity to disambiguate.
export const PIN_COMMIT_THRESHOLD = 0.95; export const PIN_COMMIT_THRESHOLD = 0.95;
// Drag-handle hit-zone at the top of the curtain. Hosts the pin / // Drag-handle hit-zone at the top of the curtain. The handle is the
// unpin gesture as a dedicated touch surface so it doesn't compete // AUTHORITATIVE gesture surface — pin, unpin, peek, close-peek and
// with the chat list's vertical scroll. Drag on this handle tracks // form-close all bind here with 1:1 finger ↔ curtain tracking, no
// the finger 1:1 (no rubber-band) — finger displacement equals // matter whether the chat list inside the curtain is scrollable. See
// curtain displacement. The list-bound gesture in // `useCurtainHandleGesture` for the full state machine.
// `useCurtainGesture` still owns peek / form-close, plus the pin path //
// when the list has no scrollable content (so single-screen lists // A parallel `useCurtainBodyGesture` bound to the curtain's body
// keep the «drag-from-anywhere» pin behaviour). // (everything below the handle) handles drag from anywhere on the
// card, but only when the inner chat list has no scrollable content
// — its dynamics are rubber-banded so the body drag reads as
// physically «heavier» than the handle's crisp pull.
// //
// Size: 32 px tall — enough touch target to land on comfortably with // Size: 32 px tall — enough touch target to land on comfortably with
// a thumb (the visible grabber pill inside is much smaller, see // a thumb (the visible grabber pill inside is much smaller, see

View file

@ -0,0 +1,326 @@
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 { 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>;
// 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). pin/unpin flips
// `pinned` instead.
commit: (next: CurtainSnap) => 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).
export function useCurtainBodyGesture({
curtainRef,
handleRef,
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;
// 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;
// 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 'pin':
// Rubber-banded up, clamped at the safe-top edge.
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta * RUBBER_BAND));
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'unpin':
// Rubber-banded down, clamped at the closed-resting edge.
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta * RUBBER_BAND));
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'peek':
// Rubber-banded down. Bounds come from the direction guard
// above plus the snap clamp on touchend, so no extra clamp —
// matches the original list-bound peek feel.
lastDelta = delta * RUBBER_BAND;
atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'close-peek':
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;
default:
break;
}
setLiveDrag(lastDelta, true);
emitHandle(true, atCommit);
};
const onTouchEnd = () => {
if (!engaged) {
startX = null;
startY = null;
direction = null;
transition = null;
return;
}
switch (transition) {
case 'pin':
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true);
} else {
setLiveDrag(0, false);
}
break;
case 'unpin':
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'peek':
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;
default:
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);
};
// 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, scrollRef, setLiveDrag, disabled]);
}

View file

@ -1,341 +0,0 @@
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<HTMLDivElement | null>;
// 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<CurtainSnap>(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<boolean>(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]);
}

View file

@ -1,21 +1,29 @@
import { MutableRefObject, useEffect, useRef } from 'react'; import { MutableRefObject, useEffect, useRef } from 'react';
import { isNativePlatform } from '../../utils/capacitor'; import { isNativePlatform } from '../../utils/capacitor';
import { DIRECTION_DEAD_ZONE_PX, PIN_COMMIT_THRESHOLD, PIN_TRAVEL_PX } from './geometry'; import {
import { CurtainSnap } from './useCurtainState'; ACTIVE_CLOSE_THRESHOLD_PX,
COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX,
PEEK_TRAVEL_PX,
PIN_COMMIT_THRESHOLD,
PIN_TRAVEL_PX,
} from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState';
type Args = { type Args = {
// Drag-handle element at the top of the curtain. Touch events bind // Drag-handle element at the top of the curtain. ALL curtain
// here; the chat list's scroll viewport is untouched so native // gestures bind here — the chat list's scroll viewport is left to
// vertical scroll never races the pin gesture. Mounted as the first // native vertical scroll so finger-down inside the list never races
// flex child of the curtain in StreamHeader.tsx. // a pin / peek / form-close path. Mounted as the first flex child
// of the curtain in StreamHeader.tsx.
handleRef: MutableRefObject<HTMLDivElement | null>; handleRef: MutableRefObject<HTMLDivElement | null>;
// Current snap stop. Mirrored into a ref so the listener (bound // Current snap stop. Mirrored into a ref so the listener (bound
// once per `disabled` flip) reads fresh values without re-attaching. // once per `disabled` flip) reads fresh values without re-attaching.
// Only `closed` (→ pin) and the pinned overlay (→ unpin) engage. // Every snap participates: closed → pin/peek, peek → close-peek,
// form-* → form-close, pinned-overlay → unpin.
snap: CurtainSnap; snap: CurtainSnap;
// Per-pane pinned overlay. When true the handle's drag-down path // Per-pane pinned overlay. When true the handle's drag-down path
// commits unpin; when false (and `snap === 'closed'`) drag-up // commits unpin; when false the snap drives which transition arms.
// commits pin.
pinned: boolean; pinned: boolean;
// Setter for the pinned overlay; called on release once the user's // Setter for the pinned overlay; called on release once the user's
// drag past the commit threshold qualifies the gesture. // drag past the commit threshold qualifies the gesture.
@ -24,47 +32,101 @@ type Args = {
// `liveDragPx` from the parent state too, so React drives the // `liveDragPx` from the parent state too, so React drives the
// curtain's `top` re-render — no direct DOM writes. // curtain's `top` re-render — no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
// Suppress gesture binding entirely. Used to gate pinning when a // Snap commit. Called on release for peek / close-peek / form-close
// (the pin / unpin paths flip `pinned` instead). Also resets
// liveDragPx + isDragging atomically inside the parent state.
commit: (next: CurtainSnap) => void;
// Suppress gesture binding entirely. Used to gate motion when a
// bottom sheet is open or when this pane is inactive inside the // bottom sheet is open or when this pane is inactive inside the
// swipe pager. Mirrors `useCurtainGesture.disabled`. // swipe pager.
disabled?: boolean; disabled?: boolean;
// Optional sink for handle-visual state — used by StreamHeader to // Optional sink for handle-visual state — drives the grabber pill's
// drive the grabber pill's «idle / dragging / threshold reached» // «idle / dragging / threshold reached» appearance via
// appearance via `data-dragging` and `data-at-commit` attributes // `data-dragging` and `data-at-commit` on the handle div. Called
// on the handle div. Called only when the state actually changes, // only when the state actually changes, so the consumer doesn't pay
// so the consumer doesn't pay a re-render on every touchmove. // a re-render on every touchmove.
setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void; setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void;
}; };
// Touch-gesture driver for the curtain's pin / unpin path on the // Curtain transitions either gesture surface can resolve. Each one
// dedicated handle strip. Native-only: listeners aren't attached on // has its own commit threshold and release destination (snap commit
// web / desktop. // vs pin flip); per-surface dynamics (1:1 on the handle, rubber-band
// on the curtain body) decide how raw finger displacement translates
// into curtain motion — see `onTouchMove` here for the 1:1 branches
// and `useCurtainBodyGesture` for the rubber-banded equivalents.
export type CurtainTransition = 'pin' | 'unpin' | 'peek' | 'close-peek' | 'form-close';
// Decide which transition the gesture arms based on the snap state
// at direction-resolution time and the finger direction. `null` means
// the (snap, pinned, direction) triple has no valid motion and the
// gesture must bail so native scroll / pager swipe / nothing-at-all
// owns the touch.
// //
// Why a separate hook? The list-bound `useCurtainGesture` had its pin // Direction guards encoded here:
// path on the chat list's scroll viewport, where drag-up at // * pinned + UP → no-op (would push the curtain past safe-top).
// `scrollTop === 0` collided with the user's natural «scroll list // * pinned + DOWN → unpin.
// down» gesture (preventDefault on touchmove blocked native scroll for // * closed + UP → pin.
// any pin attempt). Moving pin / unpin onto a small handle above the // * closed + DOWN → peek.
// list resolves the conflict — the list-bound hook now lets native // * peek + UP → close-peek (retreat to closed).
// scroll handle vertical drags on the list, and the handle owns the // * peek + DOWN → no-op (nothing lower to reveal).
// pin transitions. // * form-* + UP → form-close.
// * form-* + DOWN → no-op (form is already the lowest snap).
export function resolveCurtainTransition(
snap: CurtainSnap,
pinned: boolean,
direction: 'up' | 'down'
): CurtainTransition | null {
if (pinned) return direction === 'down' ? 'unpin' : null;
if (snap === 'closed') return direction === 'up' ? 'pin' : 'peek';
if (snap === 'peek') return direction === 'up' ? 'close-peek' : null;
if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null;
return null;
}
// Touch-gesture driver for the dedicated 32 px drag-handle at the top
// of the curtain. Native-only: listeners aren't attached on web /
// desktop.
// //
// Pin path: from `closed` (and not pinned), drag UP tracks the finger // The handle is the «authoritative» gesture surface — it owns every
// 1:1 (no rubber-band) clamped at -PIN_TRAVEL_PX. On release past // transition (pin, unpin, peek, close-peek, form-close) with crisp
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` (≈ 61 px finger pull) flips // 1:1 finger ↔ curtain tracking regardless of whether the chat list
// `pinned = true`. // inside the curtain is scrollable. The curtain BODY has a parallel
// gesture (`useCurtainBodyGesture`) with rubber-banded dynamics that
// only engages when the body's chat list has no scrollable content —
// so the user can pull the curtain «from anywhere» on empty / short
// lists but a real list-scroll is never hijacked under their finger.
// History note: an earlier `useCurtainGesture` bound the peek /
// form-close paths to the list scroll viewport directly. That coupling
// produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag-
// down at scrollTop=0 hijacks for peek» bugs and was removed when
// pin / unpin moved here.
// //
// Unpin path: from pinned, drag DOWN tracks 1:1 clamped at // All five transitions track the finger 1:1, clamped at the relevant
// +PIN_TRAVEL_PX. Same commit threshold. // snap edge so jitter past the destination doesn't visually overshoot:
// * pin / unpin — clamp ±PIN_TRAVEL_PX, commit at
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX
// («дотянул прям до самого верха»).
// * peek / close-peek — clamp ±PEEK_TRAVEL_PX, commit at
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX.
// * form-close — capped at 0 so a downward jitter can't push
// the curtain below its form-snap position.
// Commit at ACTIVE_CLOSE_THRESHOLD_PX
// (absolute distance, not a fraction).
// //
// Other snap states (peek, form-*) are no-ops here — the existing // Handle visual: emitHandle(true, atCommit) fires on every transition
// list-bound gesture continues to own those transitions. // during touchmove so the grabber pill animates Primary-blue +
// stretches as the user crosses the commit threshold, no matter which
// motion is in flight. The dedupe keeps consumer re-renders bounded
// to actual state flips. The body hook shares the same setHandleState
// sink — only one of the two surfaces is engaged at any moment, so
// they never fight over the visual.
export function useCurtainHandleGesture({ export function useCurtainHandleGesture({
handleRef, handleRef,
snap, snap,
pinned, pinned,
setPinned, setPinned,
setLiveDrag, setLiveDrag,
commit,
disabled, disabled,
setHandleState, setHandleState,
}: Args): void { }: Args): void {
@ -74,6 +136,8 @@ export function useCurtainHandleGesture({
pinnedRef.current = pinned; pinnedRef.current = pinned;
const setPinnedRef = useRef(setPinned); const setPinnedRef = useRef(setPinned);
setPinnedRef.current = setPinned; setPinnedRef.current = setPinned;
const commitRef = useRef(commit);
commitRef.current = commit;
const setHandleStateRef = useRef(setHandleState); const setHandleStateRef = useRef(setHandleState);
setHandleStateRef.current = setHandleState; setHandleStateRef.current = setHandleState;
@ -86,11 +150,12 @@ export function useCurtainHandleGesture({
let startX: number | null = null; let startX: number | null = null;
let startY: number | null = null; let startY: number | null = null;
let direction: 'up' | 'down' | null = null; let direction: 'up' | 'down' | null = null;
let transition: CurtainTransition | null = null;
let engaged = false; let engaged = false;
let lastDelta = 0; let lastDelta = 0;
// Last visual state emitted to the consumer. We dedupe here so the // Last visual state emitted to the consumer. We dedupe here so the
// setter (likely a React useState) only re-renders when something // setter (a React useState) only re-renders when something actually
// actually changed, not on every 60fps touchmove. // changed, not on every 60fps touchmove.
let emittedDragging = false; let emittedDragging = false;
let emittedAtCommit = false; let emittedAtCommit = false;
const emitHandle = (dragging: boolean, atCommit: boolean) => { const emitHandle = (dragging: boolean, atCommit: boolean) => {
@ -102,14 +167,10 @@ export function useCurtainHandleGesture({
const onTouchStart = (e: TouchEvent) => { const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return; if (e.touches.length !== 1) return;
// Engage only when the gesture has somewhere to go:
// - not pinned + closed ⇒ pin path armed
// - pinned (any snap) ⇒ unpin path armed
// Peek and form snaps are owned by the list-bound gesture.
if (!pinnedRef.current && snapRef.current !== 'closed') return;
startX = e.touches[0].clientX; startX = e.touches[0].clientX;
startY = e.touches[0].clientY; startY = e.touches[0].clientY;
direction = null; direction = null;
transition = null;
engaged = false; engaged = false;
lastDelta = 0; lastDelta = 0;
}; };
@ -120,6 +181,7 @@ export function useCurtainHandleGesture({
startX = null; startX = null;
startY = null; startY = null;
direction = null; direction = null;
transition = null;
if (engaged) setLiveDrag(0, false); if (engaged) setLiveDrag(0, false);
engaged = false; engaged = false;
lastDelta = 0; lastDelta = 0;
@ -130,7 +192,6 @@ export function useCurtainHandleGesture({
const delta = e.touches[0].clientY - startY; const delta = e.touches[0].clientY - startY;
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0; const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
const currentPinned = pinnedRef.current;
// Resolve a direction once the finger crosses the dead-zone. // Resolve a direction once the finger crosses the dead-zone.
if (direction === null) { if (direction === null) {
@ -145,18 +206,13 @@ export function useCurtainHandleGesture({
return; return;
} }
direction = delta > 0 ? 'down' : 'up'; direction = delta > 0 ? 'down' : 'up';
// Direction guards: transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
// - !pinned ⇒ only UP (pin); DOWN has nowhere lower to go // (snap, pinned, direction) has no valid motion — pinned+up,
// from closed on this surface (peek is owned by the list). // peek+down, form+down. Bail so the gesture can be re-armed on
// - pinned ⇒ only DOWN (unpin); UP would push the curtain // the next touch sequence; no preventDefault is fired so the
// into the system-tray safe-top zone. // browser keeps any default behaviour (it would be a no-op
if (!currentPinned && direction === 'down') { // here anyway — the handle has touchAction:none in CSS).
startX = null; if (transition === null) {
startY = null;
direction = null;
return;
}
if (currentPinned && direction === 'up') {
startX = null; startX = null;
startY = null; startY = null;
direction = null; direction = null;
@ -167,24 +223,52 @@ export function useCurtainHandleGesture({
engaged = true; engaged = true;
e.preventDefault(); e.preventDefault();
if (currentPinned) { // Clamp / rubber-band the raw finger delta into the live curtain
// Unpin: 1:1 down, clamped so the curtain doesn't descend // displacement (`lastDelta`). Stored separately because the
// past its `closed` resting top during the drag. // commit math on release needs the same value the curtain was
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta)); // visually showing.
} else { let atCommit = false;
// Pin: 1:1 up, clamped so the curtain doesn't enter the switch (transition) {
case 'pin':
// 1:1 up, clamped so the curtain doesn't enter the
// system-tray safe-top zone. // system-tray safe-top zone.
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta)); lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta));
atCommit = -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'unpin':
// 1:1 down, clamped so the curtain doesn't descend past its
// `closed` resting top during the drag.
lastDelta = Math.max(0, Math.min(PIN_TRAVEL_PX, delta));
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'peek':
// 1:1 down, clamped at +PEEK_TRAVEL_PX so a long pull past
// the peek snap doesn't visually overshoot. Math.max(0,…)
// guards against a momentary direction reversal nudging the
// curtain above the closed origin while transition is still
// armed for «down».
lastDelta = Math.max(0, Math.min(PEEK_TRAVEL_PX, delta));
atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'close-peek':
// 1:1 up; delta is negative. Symmetric clamp to peek above.
lastDelta = Math.min(0, Math.max(-PEEK_TRAVEL_PX, delta));
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'form-close':
// 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 form-snap position.
lastDelta = Math.min(0, delta);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break;
default:
// Unreachable — transition is non-null past the dead-zone
// resolution above and is never cleared mid-gesture.
break;
} }
setLiveDrag(lastDelta, true); setLiveDrag(lastDelta, true);
// Emit handle visual state. Progress is the fraction of emitHandle(true, atCommit);
// PIN_TRAVEL_PX the curtain has covered (positive for unpin,
// sign-flipped for pin so both directions read «how close to
// committing»). atCommit flips once the clamp threshold is
// reached so the grabber pill stretches + brightens to signal
// «release here to confirm».
const progress = currentPinned ? lastDelta / PIN_TRAVEL_PX : -lastDelta / PIN_TRAVEL_PX;
emitHandle(true, progress >= PIN_COMMIT_THRESHOLD);
}; };
const onTouchEnd = () => { const onTouchEnd = () => {
@ -192,37 +276,70 @@ export function useCurtainHandleGesture({
startX = null; startX = null;
startY = null; startY = null;
direction = null; direction = null;
transition = null;
return; return;
} }
const currentPinned = pinnedRef.current; // Commit decision per transition. setPinned() and commit() each
// Progress is the fraction of PIN_TRAVEL_PX the curtain visually // reset liveDragPx + isDragging in the same batched update —
// covered. For pin (drag-up) `lastDelta` is negative; for unpin // React renders the curtain at the new resting top with the snap
// (drag-down) it's positive — both flips use the same magnitude // transition re-enabled. Non-commit paths drop the live drag back
// threshold. // to 0 with transition active so the curtain springs back.
const progress = currentPinned ? lastDelta / PIN_TRAVEL_PX : -lastDelta / PIN_TRAVEL_PX; switch (transition) {
if (progress >= PIN_COMMIT_THRESHOLD) { case 'pin':
// setPinned() resets liveDragPx + isDragging in the same if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
// batched update — React renders the curtain at the new setPinnedRef.current(true);
// pinned-derived top with the snap transition re-enabled.
setPinnedRef.current(!currentPinned);
} else { } 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); setLiveDrag(0, false);
} }
break;
case 'unpin':
if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'peek':
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;
default:
setLiveDrag(0, false);
break;
}
startX = null; startX = null;
startY = null; startY = null;
direction = null; direction = null;
transition = null;
engaged = false; engaged = false;
lastDelta = 0; lastDelta = 0;
emitHandle(false, false); emitHandle(false, false);
}; };
const onTouchCancel = () => { const onTouchCancel = () => {
// System cancel never commits — always snap back to current snap.
if (engaged) setLiveDrag(0, false); if (engaged) setLiveDrag(0, false);
startX = null; startX = null;
startY = null; startY = null;
direction = null; direction = null;
transition = null;
engaged = false; engaged = false;
lastDelta = 0; lastDelta = 0;
emitHandle(false, false); emitHandle(false, false);
@ -239,10 +356,11 @@ export function useCurtainHandleGesture({
handle.removeEventListener('touchcancel', onTouchCancel); handle.removeEventListener('touchcancel', onTouchCancel);
}; };
// setLiveDrag is a stable useCallback; handleRef is stable. `snap`, // setLiveDrag is a stable useCallback; handleRef is stable. `snap`,
// `pinned` and `setPinned` are mirrored via the refs above so the // `pinned`, `setPinned` and `commit` are mirrored via the refs
// listener (bound once per `disabled` flip) reads fresh values // above so the listener (bound once per `disabled` flip) reads
// without re-attaching every render. `disabled` is the only signal // fresh values without re-attaching every render. `disabled` is
// that needs to tear the listeners down — it goes into the deps. // the only signal that needs to tear the listeners down — it goes
// into the deps.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleRef, setLiveDrag, disabled]); }, [handleRef, setLiveDrag, disabled]);
} }