refactor(stream-header): reset live drag on gesture teardown, drop dead pinned-local fallback, narrow commit() to peek|closed, add exhaustive transition guards and align stale comments

This commit is contained in:
heaven 2026-05-20 00:59:17 +03:00
parent 8fb885df1b
commit 240bb54c29
8 changed files with 153 additions and 84 deletions

View file

@ -143,8 +143,11 @@ export const strip = style({
// //
// No paddingTop here: the per-pane StreamHeader still renders its // No paddingTop here: the per-pane StreamHeader still renders its
// own tabs row (kept for the curtain's TABS_ROW_PX snap math, just // own tabs row (kept for the curtain's TABS_ROW_PX snap math, just
// painted invisible via visibility:hidden), and PageNav's inner // painted invisible via `opacity: 0` — load-bearing because
// column reserves the status-bar safe-area inset via its own // `visibility: hidden` would remove the row from hit-testing and
// the per-pane Segments need to capture taps at rest, see
// `StreamHeader.tsx` tabsRow rationale), and PageNav's inner column
// reserves the status-bar safe-area inset via its own
// `paddingTop: var(--vojo-safe-top)`. The static header overlay at // `paddingTop: var(--vojo-safe-top)`. The static header overlay at
// the pager root simply paints OVER the same screen zone, so the // the pager root simply paints OVER the same screen zone, so the
// underlying geometry stays identical to non-pager mode. // underlying geometry stays identical to non-pager mode.

View file

@ -182,20 +182,17 @@ export const handleBar = style({
// Wrapper around `bottomPinned` inside the curtain. Anchored to the // Wrapper around `bottomPinned` inside the curtain. Anchored to the
// curtain's flex-bottom by virtue of being the last child. The TSX // curtain's flex-bottom by virtue of being the last child. The TSX
// applies a `transform: translateY(keyboardH)` to this element when // collapses this slot to `{ height: 0, overflow: hidden }` when the
// the on-screen keyboard rises (via `VisualViewport.height` shrink) // on-screen keyboard rises (via `VisualViewport.height` shrink) so
// so the row stays at its ORIGINAL viewport-bottom position — under // the row neither paints nor claims flex space above the keyboard.
// the keyboard, clipped by the curtain's `overflow: hidden`. Without // Without this compensation, `interactive-widget=resizes-content`
// this compensation, `interactive-widget=resizes-content` (global // (global viewport meta — load-bearing for the room composer)
// meta — load-bearing for the room composer) shrinks the layout // shrinks the layout viewport, dragging every `bottom: 0` element
// viewport, dragging every `bottom: 0` element up over the inline // up over the inline form. The DirectSelfRow ending up immediately
// form. The DirectSelfRow ending up immediately above the keyboard // above the keyboard would block the user's view of the form they're
// blocks the user's view of the form they're typing into. // typing into.
export const bottomPinnedSlot = style({ export const bottomPinnedSlot = style({
flexShrink: 0, flexShrink: 0,
// Compositor hint — the transform is applied/cleared on every
// VisualViewport resize while a keyboard is open.
willChange: 'transform',
}); });
// Segment button (Direct / Channels / Bots). // Segment button (Direct / Channels / Bots).

View file

@ -51,12 +51,13 @@ type StreamHeaderProps = {
bottomPinned?: ReactNode; bottomPinned?: ReactNode;
// Stable identifier used to persist the curtain's pinned overlay // Stable identifier used to persist the curtain's pinned overlay
// across listing-pane remounts (the user taps into a Room and back, // across listing-pane remounts (the user taps into a Room and back,
// which unmounts the listing pane). When provided, pin state is // which unmounts the listing pane). Pin state is stored in
// stored in `curtainPinnedByTabAtom[pinKey]`; without it, pin lives // `curtainPinnedByTabAtom[pinKey]` so it outlives any individual
// in a local useState that resets on unmount. Listing surfaces // StreamHeader instance. Each listing tab (Direct/Channels/Bots)
// wired into the mobile pager (Direct / Channels / Bots) all pass // passes its own key; the Channels landing CTA and workspace
// a key; other consumers can omit it. // listing share `"channels"` so pin survives the toggle between
pinKey?: string; // empty state and a chosen workspace.
pinKey: string;
}; };
export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: StreamHeaderProps) { export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: StreamHeaderProps) {

View file

@ -106,10 +106,13 @@ export const PIN_TRAVEL_PX = TABS_ROW_PX;
// displacement is reached with a longer finger pull because the body // displacement is reached with a longer finger pull because the body
// path is rubber-banded (×0.65). // path is rubber-banded (×0.65).
// //
// Unpin is the one exception that keeps a hard ±PIN_TRAVEL_PX clamp: // Unpin's clamp is asymmetric — `pinned-free` lower-bounds the live
// the handle-only contract makes it a deliberate full-travel pull, // delta at 0 (no destination above pinned) but leaves the upper
// so we don't want the finger overshooting past closed into peek // direction unclamped so the same gesture can carry the curtain
// territory mid-gesture. // through closed into peek territory in one motion. The handle-only
// contract on unpin means the body never resolves to `pinned-free`,
// so the no-upper-clamp tolerance only applies on the dedicated
// drag-handle.
export const PIN_COMMIT_THRESHOLD = 0.95; export const PIN_COMMIT_THRESHOLD = 0.95;
// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the // Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the

View file

@ -10,7 +10,11 @@ import {
RUBBER_BAND, RUBBER_BAND,
} from './geometry'; } from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState'; import { CurtainSnap, isFormSnap } from './useCurtainState';
import { CurtainTransition, resolveCurtainTransition } from './useCurtainHandleGesture'; import {
assertNeverCurtainTransition,
CurtainTransition,
resolveCurtainTransition,
} from './useCurtainHandleGesture';
type Args = { type Args = {
// The curtain element. Touch listeners bind here so anywhere on the // The curtain element. Touch listeners bind here so anywhere on the
@ -54,9 +58,10 @@ type Args = {
// Live drag delta sink — feeds the curtain's `top` via React state, // Live drag delta sink — feeds the curtain's `top` via React state,
// no direct DOM writes. // no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
// Snap commit (peek / close-peek / form-close). pin/unpin flips // Snap commit (peek / close-peek / form-close). Narrowed to the two
// non-form destinations the hook ever reaches. pin/unpin flips
// `pinned` instead. // `pinned` instead.
commit: (next: CurtainSnap) => void; commit: (next: 'peek' | 'closed') => void;
// Suppress gesture binding entirely. Same conditions as the handle // Suppress gesture binding entirely. Same conditions as the handle
// hook — see StreamHeader's `gestureDisabled`. // hook — see StreamHeader's `gestureDisabled`.
disabled?: boolean; disabled?: boolean;
@ -261,13 +266,21 @@ export function useCurtainBodyGesture({
lastDelta = Math.min(0, delta * RUBBER_BAND); lastDelta = Math.min(0, delta * RUBBER_BAND);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX; atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break; break;
// `pinned-free` is intentionally absent — the pinned-bail case 'pinned-free':
// at touchstart prevents the body hook from ever resolving // Unreachable on the body — the pinned bail at touchstart
// to it. If a future change exposes pinned-free on the // prevents the hook from ever resolving this transition.
// body, add the dispatch alongside this default so the // Kept here so the `never` default below stays exhaustive
// linter keeps the switch exhaustive. // and a future opening of pinned-free on the body would
default: // need to wire the dispatch explicitly.
break; break;
case null:
// Unreachable: `engaged` is set only after `transition` is
// resolved non-null in the dead-zone block above.
break;
default: {
assertNeverCurtainTransition(transition);
break;
}
} }
setLiveDrag(lastDelta, true); setLiveDrag(lastDelta, true);
emitHandle(true, atCommit); emitHandle(true, atCommit);
@ -308,9 +321,18 @@ export function useCurtainBodyGesture({
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
default: case 'pinned-free':
case null:
// Both unreachable per the touchmove switch above; the
// setLiveDrag fallback preserves spring-back behaviour if a
// future change exposes either path here.
setLiveDrag(0, false); setLiveDrag(0, false);
break; break;
default: {
assertNeverCurtainTransition(transition);
setLiveDrag(0, false);
break;
}
} }
startX = null; startX = null;
startY = null; startY = null;
@ -341,6 +363,14 @@ export function useCurtainBodyGesture({
curtain.removeEventListener('touchmove', onTouchMove); curtain.removeEventListener('touchmove', onTouchMove);
curtain.removeEventListener('touchend', onTouchEnd); curtain.removeEventListener('touchend', onTouchEnd);
curtain.removeEventListener('touchcancel', onTouchCancel); curtain.removeEventListener('touchcancel', onTouchCancel);
// Same teardown contract as the handle hook — see its cleanup for
// the rationale. If `disabled` flips true while a body drag is in
// flight, the touchend never reaches us and the curtain would stay
// frozen at the finger position until the next touch.
if (engaged) {
setLiveDrag(0, false);
emitHandle(false, false);
}
}; };
// setLiveDrag is stable; the ref args are stable. `snap`, `pinned`, // setLiveDrag is stable; the ref args are stable. `snap`, `pinned`,
// `setPinned` and `commit` are ref-mirrored. Only `disabled` needs // `setPinned` and `commit` are ref-mirrored. Only `disabled` needs

View file

@ -33,9 +33,10 @@ type Args = {
// curtain's `top` re-render — no direct DOM writes. // curtain's `top` re-render — no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
// Snap commit. Called on release for peek / close-peek / form-close // Snap commit. Called on release for peek / close-peek / form-close
// (the pin / unpin paths flip `pinned` instead). Also resets // (the pin / unpin paths flip `pinned` instead). Narrowed to the
// two non-form destinations the hook ever reaches. Also resets
// liveDragPx + isDragging atomically inside the parent state. // liveDragPx + isDragging atomically inside the parent state.
commit: (next: CurtainSnap) => void; commit: (next: 'peek' | 'closed') => void;
// Suppress gesture binding entirely. Used to gate motion when a // Suppress gesture binding entirely. Used to gate motion when a
// bottom sheet is open or when this pane is inactive inside the // bottom sheet is open or when this pane is inactive inside the
// swipe pager. // swipe pager.
@ -79,6 +80,14 @@ type Args = {
// touchstart while pinned so unpin remains a deliberate handle pull. // touchstart while pinned so unpin remains a deliberate handle pull.
export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close'; export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close';
// Exhaustive-check helper. Used in the `default` branch of every
// switch over `CurtainTransition | null` so that adding a fifth
// variant to the union fails typecheck at every dispatch site
// rather than silently no-op'ing through default. The argument is
// prefixed with `_` so eslint's `argsIgnorePattern: '^_'` keeps the
// rule happy without us tagging it `// eslint-disable`.
export const assertNeverCurtainTransition = (_value: never): void => {};
// Decide which transition the gesture arms based on the snap state // Decide which transition the gesture arms based on the snap state
// at direction-resolution time and the finger direction. `null` means // at direction-resolution time and the finger direction. `null` means
// the (snap, pinned, direction) triple has no valid motion and the // the (snap, pinned, direction) triple has no valid motion and the
@ -128,11 +137,12 @@ export function resolveCurtainTransition(
// hijacked under their finger. The body is also fully inert while // hijacked under their finger. The body is also fully inert while
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate // pinned, so unpin (and unpin → peek overshoot) stays a deliberate
// handle pull. // handle pull.
// History note: an earlier `useCurtainGesture` bound the peek / //
// form-close paths to the list scroll viewport directly. That coupling // Design rationale: gestures used to bind to the chat list's scroll
// produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag- // viewport directly, which produced repeating «drag-at-scrollTop=0
// down at scrollTop=0 hijacks for peek» bugs and was removed when // hijacks for pin/peek» bugs. Moving every transition onto a
// pin / unpin moved here. // dedicated handle (plus an opt-in body surface that bails on
// scrollable lists) removes the scroll/gesture race entirely.
// //
// Per-transition dynamics — all track the finger 1:1, but the clamp // Per-transition dynamics — all track the finger 1:1, but the clamp
// shapes differ to keep on-screen motion sensible while preserving // shapes differ to keep on-screen motion sensible while preserving
@ -325,10 +335,19 @@ export function useCurtainHandleGesture({
lastDelta = Math.min(0, delta); lastDelta = Math.min(0, delta);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX; atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break; break;
default: case null:
// Unreachable — transition is non-null past the dead-zone // Unreachable: `engaged` is set only after `transition` is
// resolution above and is never cleared mid-gesture. // resolved non-null in the dead-zone block above; reaching
// this case would imply the gesture engaged without a
// transition, which the control flow above forbids.
break; break;
default: {
// Exhaustive guard. The `never` cast turns a future addition
// to `CurtainTransition` into a compile error here — adding
// a fifth member without wiring its dispatch fails typecheck.
assertNeverCurtainTransition(transition);
break;
}
} }
setLiveDrag(lastDelta, true); setLiveDrag(lastDelta, true);
emitHandle(true, atCommit); emitHandle(true, atCommit);
@ -398,9 +417,19 @@ export function useCurtainHandleGesture({
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
default: case null:
// Unreachable: `engaged` is set only after `transition` is
// resolved non-null. Mirrors the touchmove switch.
setLiveDrag(0, false); setLiveDrag(0, false);
break; break;
default: {
// Exhaustive guard — see the touchmove switch for the same
// pattern. setLiveDrag fallback preserves spring-back if a
// future transition lands here unhandled at runtime.
assertNeverCurtainTransition(transition);
setLiveDrag(0, false);
break;
}
} }
startX = null; startX = null;
startY = null; startY = null;
@ -432,6 +461,16 @@ export function useCurtainHandleGesture({
handle.removeEventListener('touchmove', onTouchMove); handle.removeEventListener('touchmove', onTouchMove);
handle.removeEventListener('touchend', onTouchEnd); handle.removeEventListener('touchend', onTouchEnd);
handle.removeEventListener('touchcancel', onTouchCancel); handle.removeEventListener('touchcancel', onTouchCancel);
// If `disabled` flips true while a drag is in flight, the touchend
// we'd normally rely on for snap-back never reaches us (the listener
// is gone). Without an explicit reset the curtain stays frozen at
// the finger position with `transition: none` and the grabber pill
// stuck Primary-blue until the user starts a new touch — visible as
// a half-open curtain after, say, a sheet opens mid-drag.
if (engaged) {
setLiveDrag(0, false);
emitHandle(false, false);
}
}; };
// setLiveDrag is a stable useCallback; handleRef is stable. `snap`, // setLiveDrag is a stable useCallback; handleRef is stable. `snap`,
// `pinned`, `setPinned` and `commit` are mirrored via the refs // `pinned`, `setPinned` and `commit` are mirrored via the refs

View file

@ -35,9 +35,7 @@ export type CurtainState = {
// the consumer-supplied `pinKey` so the lock survives the route- // the consumer-supplied `pinKey` so the lock survives the route-
// driven listing-pane unmount when the user taps into a Room and // driven listing-pane unmount when the user taps into a Room and
// back. Each tab keeps its own pin (Direct/Channels/Bots are // back. Each tab keeps its own pin (Direct/Channels/Bots are
// independent). If no `pinKey` is provided, the pin lives in a // independent).
// local useState that resets on unmount — fine for non-listing
// surfaces where pinning isn't expected anyway.
pinned: boolean; pinned: boolean;
// Setter for the pinned overlay. Called by the gesture hook on // Setter for the pinned overlay. Called by the gesture hook on
// commit (drag-up-from-closed past threshold sets true; drag-down- // commit (drag-up-from-closed past threshold sets true; drag-down-
@ -69,7 +67,10 @@ export type CurtainState = {
close: () => void; close: () => void;
// Commit a snap stop directly. Used by the touch gesture on release. // Commit a snap stop directly. Used by the touch gesture on release.
// Also resets `liveDragPx` and `isDragging` in one batched update. // Also resets `liveDragPx` and `isDragging` in one batched update.
commit: (next: CurtainSnap) => void; // Narrowed to the two non-form destinations the gesture hooks ever
// reach — peek-reveal and close. Form snaps are entered through
// `open()` which sets `activeForm` synchronously alongside the snap.
commit: (next: 'peek' | 'closed') => void;
// Setter for the live drag delta — called from the touch gesture on // Setter for the live drag delta — called from the touch gesture on
// every touchmove. Updates are batched by React inside event handlers. // every touchmove. Updates are batched by React inside event handlers.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
@ -101,35 +102,30 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
} }
} }
export function useCurtainState(pinKey?: string): CurtainState { export function useCurtainState(pinKey: string): CurtainState {
const [snap, setSnap] = useState<CurtainSnap>('closed'); const [snap, setSnap] = useState<CurtainSnap>('closed');
const [activeForm, setActiveForm] = useState<ActiveForm>(null); const [activeForm, setActiveForm] = useState<ActiveForm>(null);
const [formHeightPx, setFormHeightPx] = useState<number | null>(null); const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
const [liveDragPx, setLiveDragPx] = useState(0); const [liveDragPx, setLiveDragPx] = useState(0);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// Pin storage split: atom-backed when `pinKey` is supplied (survives // Per-tab pin lives in `curtainPinnedByTabAtom` so the lock survives
// listing-pane remount on Room navigate-back), local useState // the route-driven listing-pane unmount that happens when the user
// fallback when no key is supplied (web/non-listing mounts where // taps into a Room and back. The atom outlives any individual
// pinning isn't expected). // StreamHeader instance.
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom); const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
const [pinnedLocal, setPinnedLocal] = useState(false); const pinned = !!pinnedMap[pinKey];
const pinned = pinKey ? !!pinnedMap[pinKey] : pinnedLocal;
const formMeasureRef = useRef<HTMLDivElement>(null); const formMeasureRef = useRef<HTMLDivElement>(null);
const setPinned = useCallback( const setPinned = useCallback(
(next: boolean) => { (next: boolean) => {
if (pinKey) { setPinnedMap((prev) => {
setPinnedMap((prev) => { // Compare-and-skip so we don't allocate a fresh object (and
// Compare-and-skip so we don't allocate a fresh object (and // re-render every other subscriber of the atom) when nothing
// re-render every other subscriber of the atom) when nothing // actually changes.
// actually changes. if (!!prev[pinKey] === next) return prev;
if (!!prev[pinKey] === next) return prev; return { ...prev, [pinKey]: next };
return { ...prev, [pinKey]: next }; });
});
} else {
setPinnedLocal(next);
}
// Drop any in-flight live drag on commit so the curtain renders // Drop any in-flight live drag on commit so the curtain renders
// at the new pinned-derived top without a residual finger offset. // at the new pinned-derived top without a residual finger offset.
setLiveDragPx(0); setLiveDragPx(0);
@ -145,12 +141,12 @@ export function useCurtainState(pinKey?: string): CurtainState {
setLiveDragPx(0); setLiveDragPx(0);
setIsDragging(false); setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the // Safety net: clear pin so the form is visible. In practice the
// static pager header's icons (the only call site of open()) are // visible openers (static pager header icons, in-pane chips on
// covered by the curtain when pinned, so the user can't trigger // non-pager surfaces) are all covered by the curtain when pinned,
// this directly — but a future programmatic open() or a per-pane // so the user can't trigger this directly — but a future
// tabsRow that escapes the pager-mode visibility:hidden gate // programmatic open() would otherwise mount the form behind the
// would otherwise mount the form behind the still-pinned curtain // still-pinned curtain at curtainTop=0 and present an invisible
// at curtainTop=0 and the user would see an invisible form. // form.
setPinned(false); setPinned(false);
}, },
[setPinned] [setPinned]
@ -162,17 +158,14 @@ export function useCurtainState(pinKey?: string): CurtainState {
setIsDragging(false); setIsDragging(false);
}, []); }, []);
const commit = useCallback((next: CurtainSnap) => { const commit = useCallback((next: 'peek' | 'closed') => {
setSnap(next); setSnap(next);
setLiveDragPx(0); setLiveDragPx(0);
setIsDragging(false); setIsDragging(false);
if (isFormSnap(next)) { // `activeForm` is intentionally NOT cleared here — it stays set
setActiveForm(next === 'form-search' ? 'search' : 'chat'); // so the closing transition has form content beneath the curtain
} // as it slides up. `acknowledgeClosed` clears it once the snap
// Note: when committing to a non-form snap (peek*/closed) we do // settles at `closed`.
// NOT clear `activeForm` here — it stays set so the closing
// transition has form content beneath. `acknowledgeClosed` clears
// it once the curtain settles at `closed`.
}, []); }, []);
const setLiveDrag = useCallback((px: number, dragging: boolean) => { const setLiveDrag = useCallback((px: number, dragging: boolean) => {

View file

@ -21,9 +21,12 @@ function BotRow({ preset }: { preset: BotPreset }) {
export function Bots() { export function Bots() {
const bots = useBotPresets(); const bots = useBotPresets();
// `scrollRef` is passed to the header so the touch gesture (native // `scrollRef` is forwarded so the curtain body gesture can check
// only) can recognise list scrollTop=0 and engage the curtain peek. // whether the list is scrollable and bail to native scroll on long
// Icons + click flows work on every platform regardless. // lists. Short / empty lists let the curtain body itself drive the
// gesture. The dedicated 32 px drag-handle on the curtain works
// regardless of this ref. Native-only — desktop / Electron have
// no curtain gestures.
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
// Skip PageNav surface in pager mode — see Direct.tsx for the // Skip PageNav surface in pager mode — see Direct.tsx for the
// rationale; the static header behind the strip owns the visible // rationale; the static header behind the strip owns the visible