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

249 lines
9.2 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
import {
CHIP_GAP_PX,
CHIP_ROW_PX,
CURTAIN_BREATHER_PX,
CURTAIN_SNAP_MS,
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 peek» mode flag — the snap encodes both.
export type CurtainSnap =
| 'closed' // curtain flush under tabs row; nothing peeking
| 'peek' // both action chips (search + new chat) revealed
| 'form-search' // full search form revealed
| 'form-chat'; // full new-chat form revealed
export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' | 'form-chat' =>
snap === 'form-search' || snap === 'form-chat';
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek';
// Form kind 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`.
export type ActiveForm = 'search' | 'chat' | 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 (peek 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-*'`. `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 a form. Sets `snap` and `activeForm` synchronously.
open: (form: 'search' | 'chat') => 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 — 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
// 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).
// Snap-top math mirrors the DOM layout of the chip stack so the
// curtain's resting edge always lands on the boundary of the next
// row (no «next chip pill peeking through» bug). Chip rows are 56px
// each; between them is `CHIP_GAP_PX` (chip-to-chip, tighter); after
// the last revealed chip is `CURTAIN_BREATHER_PX` (chip-to-curtain,
// wider). Forms use just the breather.
export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
switch (snap) {
case 'closed':
return TABS_ROW_PX;
case 'peek':
return TABS_ROW_PX + CHIP_ROW_PX + CHIP_GAP_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX;
case 'form-search':
case 'form-chat':
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX) + CURTAIN_BREATHER_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(
(form: 'search' | 'chat') => {
setActiveForm(form);
setSnap(form === 'search' ? 'form-search' : 'form-chat');
setLiveDragPx(0);
setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the
// visible openers (static pager header icons, in-pane chips on
// non-pager surfaces) are all 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: 'peek' | 'closed') => {
setSnap(next);
setLiveDragPx(0);
setIsDragging(false);
// `activeForm` is intentionally NOT cleared here — it stays set
// so the closing transition has form content beneath the curtain
// as it slides up. `acknowledgeClosed` clears it once the snap
// settles at `closed`.
}, []);
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,
]
);
}