diff --git a/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts b/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts index 8bb2928a..b2cbe12d 100644 --- a/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts +++ b/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts @@ -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 diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx index 600b2a7a..b1956bf3 100644 --- a/src/app/components/stream-header/StreamHeader.tsx +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -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; // 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(null); + const curtainRef = useRef(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. */}
{/* 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 diff --git a/src/app/components/stream-header/geometry.ts b/src/app/components/stream-header/geometry.ts index 4df5085c..72a3c153 100644 --- a/src/app/components/stream-header/geometry.ts +++ b/src/app/components/stream-header/geometry.ts @@ -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 diff --git a/src/app/components/stream-header/useCurtainBodyGesture.ts b/src/app/components/stream-header/useCurtainBodyGesture.ts new file mode 100644 index 00000000..7abe0144 --- /dev/null +++ b/src/app/components/stream-header/useCurtainBodyGesture.ts @@ -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; + // 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; + // 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; + // 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(snap); + snapRef.current = snap; + const pinnedRef = useRef(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]); +} diff --git a/src/app/components/stream-header/useCurtainGesture.ts b/src/app/components/stream-header/useCurtainGesture.ts deleted file mode 100644 index abf0aac0..00000000 --- a/src/app/components/stream-header/useCurtainGesture.ts +++ /dev/null @@ -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; - // 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(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(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]); -} diff --git a/src/app/components/stream-header/useCurtainHandleGesture.ts b/src/app/components/stream-header/useCurtainHandleGesture.ts index 9f6a5907..e5d38732 100644 --- a/src/app/components/stream-header/useCurtainHandleGesture.ts +++ b/src/app/components/stream-header/useCurtainHandleGesture.ts @@ -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; // 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]); }