249 lines
9.2 KiB
TypeScript
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,
|
|
]
|
|
);
|
|
}
|