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; // 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('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(() => { 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 // ~280–480ms 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(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, ] ); }