vojo/src/app/components/stream-header/useCurtainState.ts

251 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
import { CURTAIN_SNAP_MS, MASCOT_CARD_PX, SEARCH_FORM_BASE_PX, TABS_ROW_PX } from './geometry';
// Discrete snap stops for the curtain. The curtain's resting `top`
// is derived from this value plus the live finger drag delta. There
// is no separate «active vs reveal» mode flag — the snap encodes both.
export type CurtainSnap =
| 'closed' // curtain flush under tabs row; nothing revealed
| 'refresh' // dancing-mascot pull-to-refresh card revealed (momentary)
| 'form-search'; // full search form revealed
export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' => snap === 'form-search';
export const isRefreshSnap = (snap: CurtainSnap): snap is 'refresh' => snap === 'refresh';
// Whether a form is currently rendered in the header. Stays set during
// the curtain's close transition so the form has content to slide
// behind; cleared by `acknowledgeClosed` after the snap settles at
// `closed`. There is only one form (search) — new-chat was folded
// into search's People section — so this is a simple on/off flag.
export type ActiveForm = 'search' | null;
export type CurtainState = {
snap: CurtainSnap;
// Per-tab pin overlay. Stored in `curtainPinnedByTabAtom` keyed by
// the consumer-supplied `pinKey` so the lock survives the route-
// driven listing-pane unmount when the user taps into a Room and
// back. Each tab keeps its own pin (Direct/Channels/Bots are
// independent).
pinned: boolean;
// Setter for the pinned overlay. Called by the gesture hook on
// commit (drag-up-from-closed past threshold sets true; drag-down-
// while-pinned past threshold sets false).
setPinned: (next: boolean) => void;
activeForm: ActiveForm;
// Live finger delta in px. Added to the snap-derived resting top to
// compute the curtain's visible top. Stays at 0 when no gesture is
// in flight. Positive = finger pulled down (refresh reveal); negative
// = finger pulled up (close gesture).
liveDragPx: number;
// True while a touch gesture is in flight. Controls the curtain's
// `top` transition: disabled while dragging (curtain tracks finger
// 1:1), restored on release so the snap commit animates smoothly.
isDragging: boolean;
// Live measured height of the active form's outer; used to compute
// the curtain's resting `top` when `snap === 'form-search'`. `null`
// while no form is mounted.
formHeightPx: number | null;
// Ref pointing at the rendered form's outer — a ResizeObserver
// watches this to feed `formHeightPx`. Consumer attaches it to the
// form's wrapping element.
formMeasureRef: React.RefObject<HTMLDivElement>;
// Open the search form. Sets `snap` and `activeForm` synchronously.
open: () => void;
// Close the curtain (raise it back to `closed`). Keeps `activeForm`
// set until the snap transition lands so the form stays mounted
// during the slide-up.
close: () => void;
// Commit a snap stop directly. Used by the touch gesture on release.
// Also resets `liveDragPx` and `isDragging` in one batched update.
// Narrowed to the two non-form destinations the gesture hooks ever
// reach — refresh-reveal and close. Form snaps are entered through
// `open()` which sets `activeForm` synchronously alongside the snap.
commit: (next: 'refresh' | 'closed') => void;
// Setter for the live drag delta — called from the touch gesture on
// every touchmove. Updates are batched by React inside event handlers.
setLiveDrag: (px: number, dragging: boolean) => void;
// Notify the hook that the curtain reached `closed` so any lingering
// form can be unmounted (called from the curtain element's
// `onTransitionEnd`).
acknowledgeClosed: () => void;
};
// Resting `top` (px) of the curtain for a given snap stop and the
// currently measured form height (null falls back to the base).
//
// The refresh snap rests exactly `MASCOT_CARD_PX` below the tabs row so
// the dancing-mascot card is fully revealed below the boundary.
//
// The form rests at exactly `TABS_ROW_PX + formHeight` — the search
// results are a scroll list, and any strip between the last visible
// result and the curtain's top reads as «content cut off early» rather
// than as breathing room. So the form's measured height already reaches
// the curtain top: the list clips flush at the curtain's edge.
// `SEARCH_FORM_BASE_PX` is the pre-measure fallback and is sized (= the
// form's full outer height) so it equals the measured `formH`, avoiding
// any open-time top jump.
export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
switch (snap) {
case 'closed':
return TABS_ROW_PX;
case 'refresh':
return TABS_ROW_PX + MASCOT_CARD_PX;
case 'form-search':
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX);
default:
return TABS_ROW_PX;
}
}
export function useCurtainState(pinKey: string): CurtainState {
const [snap, setSnap] = useState<CurtainSnap>('closed');
const [activeForm, setActiveForm] = useState<ActiveForm>(null);
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
const [liveDragPx, setLiveDragPx] = useState(0);
const [isDragging, setIsDragging] = useState(false);
// Per-tab pin lives in `curtainPinnedByTabAtom` so the lock survives
// the route-driven listing-pane unmount that happens when the user
// taps into a Room and back. The atom outlives any individual
// StreamHeader instance.
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
const pinned = !!pinnedMap[pinKey];
const formMeasureRef = useRef<HTMLDivElement>(null);
const setPinned = useCallback(
(next: boolean) => {
setPinnedMap((prev) => {
// Compare-and-skip so we don't allocate a fresh object (and
// re-render every other subscriber of the atom) when nothing
// actually changes.
if (!!prev[pinKey] === next) return prev;
return { ...prev, [pinKey]: next };
});
// Drop any in-flight live drag on commit so the curtain renders
// at the new pinned-derived top without a residual finger offset.
setLiveDragPx(0);
setIsDragging(false);
},
[pinKey, setPinnedMap]
);
const open = useCallback(() => {
setActiveForm('search');
setSnap('form-search');
setLiveDragPx(0);
setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the
// visible opener (the tabs-row Search icon, via the per-pane header
// or the static pager header) is covered by the curtain when pinned,
// so the user can't trigger this directly — but a future
// programmatic open() would otherwise mount the form behind the
// still-pinned curtain at curtainTop=0 and present an invisible
// form.
setPinned(false);
}, [setPinned]);
const close = useCallback(() => {
setSnap('closed');
setLiveDragPx(0);
setIsDragging(false);
}, []);
const commit = useCallback((next: 'refresh' | 'closed') => {
setSnap(next);
setLiveDragPx(0);
setIsDragging(false);
// A 'refresh' commit reveals the mascot, so drop any lingering form.
// Without this, a fast close→pull-to-refresh landed inside the form's
// ~280480ms close-slide window (before `acknowledgeClosed`/the safety
// timer clear `activeForm`) would leave `activeForm` set, and the
// header's `activeForm ? form : mascot` gate would render the closing
// search form in place of the mascot during the dance.
//
// The 'closed' destination intentionally KEEPS `activeForm` set so the
// closing transition has form content beneath the curtain as it slides
// up — `acknowledgeClosed` clears it once the snap settles at `closed`.
if (next === 'refresh') setActiveForm(null);
}, []);
const setLiveDrag = useCallback((px: number, dragging: boolean) => {
setLiveDragPx(px);
setIsDragging(dragging);
}, []);
const acknowledgeClosed = useCallback(() => {
if (snap === 'closed') setActiveForm(null);
}, [snap]);
// Measure the form's outer height while it's mounted. We do NOT
// overwrite the measured height to null on unmount — keeping the
// last-known height stable lets the close transition target the
// same `top` value as the open transition (no jump at SNAP_MS-end).
useLayoutEffect(() => {
if (!activeForm) return undefined;
const el = formMeasureRef.current;
if (!el) return undefined;
const measure = () => setFormHeightPx(el.offsetHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [activeForm]);
// Safety-net for missed `transitionend` (route unmount mid-anim,
// browser quirks). Once snap settles at `closed`, force-drop the
// form after a generous window past the snap duration.
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (snap !== 'closed') {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
return undefined;
}
timerRef.current = window.setTimeout(() => {
setActiveForm(null);
}, CURTAIN_SNAP_MS + 200);
return () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [snap]);
return useMemo(
() => ({
snap,
pinned,
setPinned,
activeForm,
liveDragPx,
isDragging,
formHeightPx,
formMeasureRef,
open,
close,
commit,
setLiveDrag,
acknowledgeClosed,
}),
[
snap,
pinned,
setPinned,
activeForm,
liveDragPx,
isDragging,
formHeightPx,
open,
close,
commit,
setLiveDrag,
acknowledgeClosed,
]
);
}