251 lines
9.8 KiB
TypeScript
251 lines
9.8 KiB
TypeScript
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
|
||
// ~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<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,
|
||
]
|
||
);
|
||
}
|