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; // 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('closed'); const [activeForm, setActiveForm] = useState(null); const [formHeightPx, setFormHeightPx] = useState(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(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(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, ] ); }