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:
parent
0751e99446
commit
2617eaf46e
6 changed files with 613 additions and 485 deletions
|
|
@ -34,9 +34,9 @@ type Args = {
|
|||
};
|
||||
|
||||
// Horizontal swipe driver for the mobile listing tab pager. Mirrors
|
||||
// the shape of `useCurtainGesture`: single listener bound to the
|
||||
// pager root, refs for the latest snap/index state, axis-resolve in
|
||||
// the dead-zone, rubber-band at boundaries, threshold-commit on
|
||||
// the shape of `useCurtainHandleGesture`: single listener bound to
|
||||
// the pager root, refs for the latest snap/index state, axis-resolve
|
||||
// in the dead-zone, rubber-band at boundaries, threshold-commit on
|
||||
// release.
|
||||
//
|
||||
// Conflict resolution with other gestures sharing the same surface
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ import * as css from './StreamHeader.css';
|
|||
import { Segment } from './Segment';
|
||||
import { Chip } from './Chip';
|
||||
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
|
||||
import { useCurtainGesture } from './useCurtainGesture';
|
||||
import { useCurtainHandleGesture } from './useCurtainHandleGesture';
|
||||
import { useCurtainBodyGesture } from './useCurtainBodyGesture';
|
||||
import { InlineNewChatForm } from './forms/InlineNewChatForm';
|
||||
import { InlineRoomSearch } from './forms/InlineRoomSearch';
|
||||
|
||||
|
|
@ -32,10 +32,14 @@ const INLINE_FORM_ID = 'stream-header-inline-form';
|
|||
|
||||
type StreamHeaderProps = {
|
||||
// Scroll viewport that hosts the chat list under the curtain. The
|
||||
// curtain's children (`children` prop) render inside an element that
|
||||
// receives `scrollRef` automatically — the parent doesn't need to
|
||||
// wire it. The ref is used by the touch gesture to recognise list
|
||||
// scrollTop=0 and engage the peek-reveal.
|
||||
// curtain BODY gesture (`useCurtainBodyGesture`) reads this ref's
|
||||
// `scrollHeight`/`clientHeight` to decide whether to engage: long
|
||||
// lists keep native scroll, short / empty lists drive the curtain
|
||||
// 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>;
|
||||
// Curtain contents — the chat list. The list is rendered inside an
|
||||
// `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);
|
||||
|
||||
// Suppress the curtain gesture whenever the user is interacting with
|
||||
// something else that would otherwise race the pin path:
|
||||
// Suppress every curtain gesture whenever the user is interacting
|
||||
// with something else that would otherwise race the pin path:
|
||||
//
|
||||
// * Settings sheet open (DirectSelfRow-originated bottom sheet) —
|
||||
// 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 gestureDisabled = settingsSheetOpen || workspaceSheetOpen || offscreenPagerPane;
|
||||
|
||||
useCurtainGesture({
|
||||
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.
|
||||
// Two parallel curtain-gesture surfaces:
|
||||
//
|
||||
// `handleVisual` mirrors the desktop `PageNavResizeHandle` state
|
||||
// machine: `dragging` lights up the grabber pill while the gesture
|
||||
// is active; `atCommit` further stretches + brightens it once the
|
||||
// user has crossed `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` so the
|
||||
// release-to-confirm moment is unambiguous. The hook dedupes so this
|
||||
// only re-renders when the visual state actually changes.
|
||||
// * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
|
||||
// at the top of the curtain. Crisp 1:1 finger ↔ curtain on
|
||||
// every transition (pin, unpin, peek, close-peek, form-close).
|
||||
// Engages regardless of whether the chat list is scrollable —
|
||||
// the handle is a distinct surface and never competes with list
|
||||
// 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 curtainRef = useRef<HTMLDivElement>(null);
|
||||
const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
|
||||
dragging: false,
|
||||
atCommit: false,
|
||||
|
|
@ -157,6 +164,19 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
|||
pinned: curtain.pinned,
|
||||
setPinned: curtain.setPinned,
|
||||
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,
|
||||
setHandleState: setHandleVisual,
|
||||
});
|
||||
|
|
@ -420,6 +440,7 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
|||
so the snap commit animates smoothly without an intermediate
|
||||
"snap back then animate forward" flash. */}
|
||||
<div
|
||||
ref={curtainRef}
|
||||
className={css.curtain}
|
||||
style={{
|
||||
top: toRem(curtainTop),
|
||||
|
|
@ -429,11 +450,12 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
|||
>
|
||||
{/* Drag handle (native-only behaviour, but rendered on all
|
||||
platforms so the layout stays identical — the gesture hook
|
||||
short-circuits off-native). Hosts the pin / unpin touch
|
||||
listener and gives the user a visible affordance at the top
|
||||
of the curtain. Stays mounted across snap transitions so
|
||||
the gesture surface is always reachable when there is one
|
||||
to make.
|
||||
short-circuits off-native). Hosts the entire curtain
|
||||
gesture surface — pin, unpin, peek, close-peek and
|
||||
form-close all bind here, leaving the chat list to native
|
||||
scroll. Stays mounted across snap transitions so the
|
||||
gesture surface is always reachable when there is one to
|
||||
make.
|
||||
|
||||
`data-dragging` / `data-at-commit` mirror the desktop
|
||||
`PageNavResizeHandle`: CSS selectors on `handleBar` light
|
||||
|
|
|
|||
|
|
@ -97,24 +97,27 @@ export const PIN_TRAVEL_PX = TABS_ROW_PX;
|
|||
// release for the snap to flip. Anything shorter reads as accidental
|
||||
// and springs back to the previous resting snap.
|
||||
//
|
||||
// With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin
|
||||
// — see `useCurtainGesture` and `useCurtainHandleGesture`), the
|
||||
// committing finger pull is `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈
|
||||
// 61 px — essentially «drag the curtain across the full tabs-row
|
||||
// height». The anti-accidental gate that previously came from
|
||||
// rubber-band amplification is now provided by the dedicated handle
|
||||
// hit-zone (intentional surface) plus the list-bound scroll-aware
|
||||
// bail (no list scroll = no scroll-up to confuse with pin).
|
||||
// With 1:1 finger ↔ curtain tracking (no rubber-band on pin / unpin —
|
||||
// see `useCurtainHandleGesture`), the committing finger pull is
|
||||
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag
|
||||
// the curtain across the full tabs-row height». The anti-accidental
|
||||
// gate is provided by the dedicated handle hit-zone (intentional
|
||||
// surface) — the chat list under the curtain is left to native
|
||||
// scroll and never engages a pin path, so there's no scroll-vs-pin
|
||||
// ambiguity to disambiguate.
|
||||
export const PIN_COMMIT_THRESHOLD = 0.95;
|
||||
|
||||
// Drag-handle hit-zone at the top of the curtain. Hosts the pin /
|
||||
// unpin gesture as a dedicated touch surface so it doesn't compete
|
||||
// with the chat list's vertical scroll. Drag on this handle tracks
|
||||
// the finger 1:1 (no rubber-band) — finger displacement equals
|
||||
// curtain displacement. The list-bound gesture in
|
||||
// `useCurtainGesture` still owns peek / form-close, plus the pin path
|
||||
// when the list has no scrollable content (so single-screen lists
|
||||
// keep the «drag-from-anywhere» pin behaviour).
|
||||
// Drag-handle hit-zone at the top of the curtain. The handle is the
|
||||
// AUTHORITATIVE gesture surface — pin, unpin, peek, close-peek and
|
||||
// form-close all bind here with 1:1 finger ↔ curtain tracking, no
|
||||
// matter whether the chat list inside the curtain is scrollable. See
|
||||
// `useCurtainHandleGesture` for the full state machine.
|
||||
//
|
||||
// A parallel `useCurtainBodyGesture` bound to the curtain's body
|
||||
// (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
|
||||
// a thumb (the visible grabber pill inside is much smaller, see
|
||||
|
|
|
|||
326
src/app/components/stream-header/useCurtainBodyGesture.ts
Normal file
326
src/app/components/stream-header/useCurtainBodyGesture.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -1,21 +1,29 @@
|
|||
import { MutableRefObject, useEffect, useRef } from 'react';
|
||||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import { DIRECTION_DEAD_ZONE_PX, PIN_COMMIT_THRESHOLD, PIN_TRAVEL_PX } from './geometry';
|
||||
import { CurtainSnap } from './useCurtainState';
|
||||
import {
|
||||
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 = {
|
||||
// Drag-handle element at the top of the curtain. Touch events bind
|
||||
// here; the chat list's scroll viewport is untouched so native
|
||||
// vertical scroll never races the pin gesture. Mounted as the first
|
||||
// flex child of the curtain in StreamHeader.tsx.
|
||||
// Drag-handle element at the top of the curtain. ALL curtain
|
||||
// gestures bind here — the chat list's scroll viewport is left to
|
||||
// native vertical scroll so finger-down inside the list never races
|
||||
// a pin / peek / form-close path. Mounted as the first flex child
|
||||
// of the curtain in StreamHeader.tsx.
|
||||
handleRef: MutableRefObject<HTMLDivElement | null>;
|
||||
// Current snap stop. Mirrored into a ref so the listener (bound
|
||||
// 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;
|
||||
// Per-pane pinned overlay. When true the handle's drag-down path
|
||||
// commits unpin; when false (and `snap === 'closed'`) drag-up
|
||||
// commits pin.
|
||||
// commits unpin; when false the snap drives which transition arms.
|
||||
pinned: boolean;
|
||||
// Setter for the pinned overlay; called on release once the user's
|
||||
// drag past the commit threshold qualifies the gesture.
|
||||
|
|
@ -24,47 +32,101 @@ type Args = {
|
|||
// `liveDragPx` from the parent state too, so React drives the
|
||||
// curtain's `top` re-render — no direct DOM writes.
|
||||
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
|
||||
// swipe pager. Mirrors `useCurtainGesture.disabled`.
|
||||
// swipe pager.
|
||||
disabled?: boolean;
|
||||
// Optional sink for handle-visual state — used by StreamHeader to
|
||||
// drive the grabber pill's «idle / dragging / threshold reached»
|
||||
// appearance via `data-dragging` and `data-at-commit` attributes
|
||||
// on the handle div. Called only when the state actually changes,
|
||||
// so the consumer doesn't pay a re-render on every touchmove.
|
||||
// Optional sink for handle-visual state — drives the grabber pill's
|
||||
// «idle / dragging / threshold reached» appearance via
|
||||
// `data-dragging` and `data-at-commit` on the handle div. Called
|
||||
// only when the state actually changes, so the consumer doesn't pay
|
||||
// a re-render on every touchmove.
|
||||
setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void;
|
||||
};
|
||||
|
||||
// Touch-gesture driver for the curtain's pin / unpin path on the
|
||||
// dedicated handle strip. Native-only: listeners aren't attached on
|
||||
// web / desktop.
|
||||
// Curtain transitions either gesture surface can resolve. Each one
|
||||
// has its own commit threshold and release destination (snap commit
|
||||
// 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
|
||||
// path on the chat list's scroll viewport, where drag-up at
|
||||
// `scrollTop === 0` collided with the user's natural «scroll list
|
||||
// down» gesture (preventDefault on touchmove blocked native scroll for
|
||||
// any pin attempt). Moving pin / unpin onto a small handle above the
|
||||
// list resolves the conflict — the list-bound hook now lets native
|
||||
// scroll handle vertical drags on the list, and the handle owns the
|
||||
// pin transitions.
|
||||
// Direction guards encoded here:
|
||||
// * pinned + UP → no-op (would push the curtain past safe-top).
|
||||
// * pinned + DOWN → unpin.
|
||||
// * closed + UP → pin.
|
||||
// * closed + DOWN → peek.
|
||||
// * peek + UP → close-peek (retreat to closed).
|
||||
// * peek + DOWN → no-op (nothing lower to reveal).
|
||||
// * 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
|
||||
// 1:1 (no rubber-band) clamped at -PIN_TRAVEL_PX. On release past
|
||||
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` (≈ 61 px finger pull) flips
|
||||
// `pinned = true`.
|
||||
// The handle is the «authoritative» gesture surface — it owns every
|
||||
// transition (pin, unpin, peek, close-peek, form-close) with crisp
|
||||
// 1:1 finger ↔ curtain tracking regardless of whether the chat list
|
||||
// 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
|
||||
// +PIN_TRAVEL_PX. Same commit threshold.
|
||||
// All five transitions track the finger 1:1, clamped at the relevant
|
||||
// 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
|
||||
// list-bound gesture continues to own those transitions.
|
||||
// Handle visual: emitHandle(true, atCommit) fires on every transition
|
||||
// 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({
|
||||
handleRef,
|
||||
snap,
|
||||
pinned,
|
||||
setPinned,
|
||||
setLiveDrag,
|
||||
commit,
|
||||
disabled,
|
||||
setHandleState,
|
||||
}: Args): void {
|
||||
|
|
@ -74,6 +136,8 @@ export function useCurtainHandleGesture({
|
|||
pinnedRef.current = pinned;
|
||||
const setPinnedRef = useRef(setPinned);
|
||||
setPinnedRef.current = setPinned;
|
||||
const commitRef = useRef(commit);
|
||||
commitRef.current = commit;
|
||||
const setHandleStateRef = useRef(setHandleState);
|
||||
setHandleStateRef.current = setHandleState;
|
||||
|
||||
|
|
@ -86,11 +150,12 @@ export function useCurtainHandleGesture({
|
|||
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;
|
||||
// Last visual state emitted to the consumer. We dedupe here so the
|
||||
// setter (likely a React useState) only re-renders when something
|
||||
// actually changed, not on every 60fps touchmove.
|
||||
// setter (a React useState) only re-renders when something actually
|
||||
// changed, not on every 60fps touchmove.
|
||||
let emittedDragging = false;
|
||||
let emittedAtCommit = false;
|
||||
const emitHandle = (dragging: boolean, atCommit: boolean) => {
|
||||
|
|
@ -102,14 +167,10 @@ export function useCurtainHandleGesture({
|
|||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
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;
|
||||
startY = e.touches[0].clientY;
|
||||
direction = null;
|
||||
transition = null;
|
||||
engaged = false;
|
||||
lastDelta = 0;
|
||||
};
|
||||
|
|
@ -120,6 +181,7 @@ export function useCurtainHandleGesture({
|
|||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
transition = null;
|
||||
if (engaged) setLiveDrag(0, false);
|
||||
engaged = false;
|
||||
lastDelta = 0;
|
||||
|
|
@ -130,7 +192,6 @@ export function useCurtainHandleGesture({
|
|||
|
||||
const delta = e.touches[0].clientY - startY;
|
||||
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
|
||||
const currentPinned = pinnedRef.current;
|
||||
|
||||
// Resolve a direction once the finger crosses the dead-zone.
|
||||
if (direction === null) {
|
||||
|
|
@ -145,18 +206,13 @@ export function useCurtainHandleGesture({
|
|||
return;
|
||||
}
|
||||
direction = delta > 0 ? 'down' : 'up';
|
||||
// Direction guards:
|
||||
// - !pinned ⇒ only UP (pin); DOWN has nowhere lower to go
|
||||
// from closed on this surface (peek is owned by the list).
|
||||
// - pinned ⇒ only DOWN (unpin); UP would push the curtain
|
||||
// into the system-tray safe-top zone.
|
||||
if (!currentPinned && direction === 'down') {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
return;
|
||||
}
|
||||
if (currentPinned && direction === 'up') {
|
||||
transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
|
||||
// (snap, pinned, direction) has no valid motion — pinned+up,
|
||||
// peek+down, form+down. Bail so the gesture can be re-armed on
|
||||
// the next touch sequence; no preventDefault is fired so the
|
||||
// browser keeps any default behaviour (it would be a no-op
|
||||
// here anyway — the handle has touchAction:none in CSS).
|
||||
if (transition === null) {
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
|
|
@ -167,24 +223,52 @@ export function useCurtainHandleGesture({
|
|||
engaged = true;
|
||||
e.preventDefault();
|
||||
|
||||
if (currentPinned) {
|
||||
// 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));
|
||||
} else {
|
||||
// Pin: 1:1 up, clamped so the curtain doesn't enter the
|
||||
// system-tray safe-top zone.
|
||||
lastDelta = Math.max(-PIN_TRAVEL_PX, Math.min(0, delta));
|
||||
// Clamp / rubber-band the raw finger delta into the live curtain
|
||||
// displacement (`lastDelta`). Stored separately because the
|
||||
// commit math on release needs the same value the curtain was
|
||||
// visually showing.
|
||||
let atCommit = false;
|
||||
switch (transition) {
|
||||
case 'pin':
|
||||
// 1:1 up, clamped so the curtain doesn't enter the
|
||||
// system-tray safe-top zone.
|
||||
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);
|
||||
// Emit handle visual state. Progress is the fraction of
|
||||
// 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);
|
||||
emitHandle(true, atCommit);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
|
|
@ -192,37 +276,70 @@ export function useCurtainHandleGesture({
|
|||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
transition = null;
|
||||
return;
|
||||
}
|
||||
const currentPinned = pinnedRef.current;
|
||||
// Progress is the fraction of PIN_TRAVEL_PX the curtain visually
|
||||
// covered. For pin (drag-up) `lastDelta` is negative; for unpin
|
||||
// (drag-down) it's positive — both flips use the same magnitude
|
||||
// threshold.
|
||||
const progress = currentPinned ? lastDelta / PIN_TRAVEL_PX : -lastDelta / PIN_TRAVEL_PX;
|
||||
if (progress >= PIN_COMMIT_THRESHOLD) {
|
||||
// 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(!currentPinned);
|
||||
} 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);
|
||||
// Commit decision per transition. setPinned() and commit() each
|
||||
// reset liveDragPx + isDragging in the same batched update —
|
||||
// React renders the curtain at the new resting top with the snap
|
||||
// transition re-enabled. Non-commit paths drop the live drag back
|
||||
// to 0 with transition active so the curtain springs back.
|
||||
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 = () => {
|
||||
// System cancel never commits — always snap back to current snap.
|
||||
if (engaged) setLiveDrag(0, false);
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = null;
|
||||
transition = null;
|
||||
engaged = false;
|
||||
lastDelta = 0;
|
||||
emitHandle(false, false);
|
||||
|
|
@ -239,10 +356,11 @@ export function useCurtainHandleGesture({
|
|||
handle.removeEventListener('touchcancel', onTouchCancel);
|
||||
};
|
||||
// setLiveDrag is a stable useCallback; handleRef 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.
|
||||
// `pinned`, `setPinned` and `commit` 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
|
||||
}, [handleRef, setLiveDrag, disabled]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue