import React, { MutableRefObject, ReactNode, TransitionEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; import { useMatch, useNavigate } from 'react-router-dom'; import { useAtomValue, useSetAtom } from 'jotai'; import { ClientEvent, SyncState } from 'matrix-js-sdk'; import { Box, Icon, IconButton, Icons, toRem } from 'folds'; import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths'; import { isNativePlatform } from '../../utils/capacitor'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useBotPresets } from '../../features/bots/catalog'; import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext'; import { MobilePagerCurtainControls, StreamHeaderPrimaryAction, mobilePagerCurtainAtom, } from '../../state/mobilePagerHeader'; import { REFRESH_MAX_MS, REFRESH_MIN_MS, TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry'; import { settingsSheetAtom } from '../../state/settingsSheet'; import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet'; import * as css from './StreamHeader.css'; import { Segment } from './Segment'; import { RefreshMascot } from './RefreshMascot'; import { isFormSnap, isRefreshSnap, snapTopPx, useCurtainState } from './useCurtainState'; import { useCurtainHandleGesture } from './useCurtainHandleGesture'; import { useCurtainBodyGesture } from './useCurtainBodyGesture'; import { InlineRoomSearch } from './forms/InlineRoomSearch'; const INLINE_FORM_ID = 'stream-header-inline-form'; type StreamHeaderProps = { // Scroll viewport that hosts the chat list under the curtain. The // curtain BODY gesture (`useCurtainBodyGesture`) reads this ref's // `scrollHeight`/`clientHeight` to decide whether to engage: long // lists keep native scroll, short / empty lists drive the curtain // via body drag. May be a ref whose `.current` is null on listing // surfaces that render their empty state directly as a curtain // child without wrapping it in `PageNavContent` (Direct's // `DirectEmpty`, ChannelsRootNav's `ChannelsLanding`) — the body // gesture treats null as «not scrollable» and engages. scrollRef: MutableRefObject; // Curtain contents — the chat list. The list is rendered inside an // `overflow: auto` div that the gesture hook listens to. children: ReactNode; // Optional row(s) pinned to the bottom of the curtain (DirectSelfRow, // WorkspaceFooter). Hidden while a form is active so the on-screen // keyboard's viewport resize doesn't push them up over the form // (see commit 14ed080). bottomPinned?: ReactNode; // Stable identifier used to persist the curtain's pinned overlay // across listing-pane remounts (the user taps into a Room and back, // which unmounts the listing pane). Pin state is stored in // `curtainPinnedByTabAtom[pinKey]` so it outlives any individual // StreamHeader instance. Each listing tab (Direct/Channels/Bots) // passes its own key; the Channels landing CTA and workspace // listing share `"channels"` so pin survives the toggle between // empty state and a chosen workspace. pinKey: string; // Optional contextual create action (a Plus button in the tabs row). // When omitted (Direct) the header shows no Plus at all — starting a chat // is folded into search, which finds people via the homeserver user // directory. Channels sets this to «create channel» / «create // community» so its Plus launches that flow. primaryAction?: StreamHeaderPrimaryAction; }; export function StreamHeader({ scrollRef, children, bottomPinned, pinKey, primaryAction, }: StreamHeaderProps) { const { t } = useTranslation(); const navigate = useNavigate(); const bots = useBotPresets(); const navOpts = useMemo(() => ({ replace: isNativePlatform() }), []); const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false }); const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false }); const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false }); const showBotsSegment = bots.length > 0 || !!botsMatch; // Pager mode wiring. When this StreamHeader is mounted inside // MobileTabsPager, the shared static tabs row at the pager root // owns the visible segments + action icons; our local tabs row is // kept in DOM (preserving the curtain's TABS_ROW_PX-based snap // geometry) but rendered with `opacity: 0` (still tap-able). Only // the currently active pane writes its curtain controls to // `mobilePagerCurtainAtom` so the shared icons drive THIS curtain. // // `selectTabInstant` is the pager's tap-commit entrypoint: when our // invisible per-pane Segments capture a tap (because the static // header sits behind the strip in z-stack at rest), routing through // this callback runs the same commit path as the static header's // taps and suppresses the swipe-finish slide animation so tab // switches feel snappy. Falls back to a plain `navigate(...)` on // surfaces outside pager mode (desktop, non-listing routes). const pagerPane = useMobilePagerPane(); const inPagerMode = pagerPane !== null; const isActivePagerPane = pagerPane?.isActive ?? false; const selectTabInstant = pagerPane?.selectTabInstant ?? null; const onSegmentDirect = useCallback(() => { if (selectTabInstant) selectTabInstant('direct'); else navigate(DIRECT_PATH, navOpts); }, [selectTabInstant, navigate, navOpts]); const onSegmentChannels = useCallback(() => { if (selectTabInstant) selectTabInstant('channels'); else navigate(CHANNELS_PATH, navOpts); }, [selectTabInstant, navigate, navOpts]); const onSegmentBots = useCallback(() => { if (selectTabInstant) selectTabInstant('bots'); else navigate(BOTS_PATH, navOpts); }, [selectTabInstant, navigate, navOpts]); const curtain = useCurtainState(pinKey); const mx = useMatrixClient(); // Suppress every curtain gesture whenever the user is interacting // with something else that would otherwise race the pin path: // // * Settings sheet open (DirectSelfRow-originated bottom sheet) — // a drag-up on the still-visible list above the sheet would // mutate the pin atom underneath the sheet and the user would // see an unexpected pinned curtain on dismissal. // * Workspace switcher sheet open — same shape, on the Channels // workspace surface. // * Inactive pager pane — the strip clips offscreen panes so they // shouldn't receive touches in practice, but bind defense-in- // depth so a stray pointer event on a translateX'd pane never // pins someone else's tab. // // Mirrors `MobileTabsPager.gestureDisabled` which suppresses the // pager's OWN horizontal-swipe gesture under the same conditions. const settingsSheetOpen = !!useAtomValue(settingsSheetAtom); const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom); const offscreenPagerPane = inPagerMode && !isActivePagerPane; const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || offscreenPagerPane; // Two parallel curtain-gesture surfaces: // // * `useCurtainHandleGesture` — the dedicated 32 px drag-handle // at the top of the curtain. Crisp 1:1 finger ↔ curtain. From // closed the gesture is a free-range drag spanning pin↔closed↔ // refresh in one motion (`closed-free`); other snaps drive single- // destination transitions (unpin / close-refresh / form-close). // Engages regardless of whether the chat list is scrollable — // the handle is a distinct surface and never competes with list // scroll. Only rendered on native (`isNativePlatform()`). // // * `useCurtainBodyGesture` — anywhere on the curtain body // OUTSIDE the handle (chat list, empty-state placeholder). // Rubber-banded (0.65) for all transitions, so the body drag // reads as physically «heavier» than the handle's crisp pull. // Engages only when the chat list has no scrollable content; // additionally bails on touches that start inside the bottom- // pinned slot (DirectSelfRow / WorkspaceFooter have their own // drag-to-open bottom sheets) and on touches that start while // pinned (unpin is HANDLE-only — the user has to grab the // dedicated affordance to release the lock). // // Both hooks share `handleVisual` (mirrors desktop // `PageNavResizeHandle`: `dragging` lights up the grabber pill; // `atCommit` stretches + brightens it once the user crosses the // per-transition commit threshold). The two surfaces are mutually // exclusive on each touch (handle's listener short-circuits when // the touch starts on the handle; body's listener does the same // when it ISN'T on the handle), so they never fight over the // visual. const handleRef = useRef(null); const curtainRef = useRef(null); const bottomPinnedRef = useRef(null); const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({ dragging: false, atCommit: false, }); useCurtainHandleGesture({ handleRef, snap: curtain.snap, pinned: curtain.pinned, setPinned: curtain.setPinned, setLiveDrag: curtain.setLiveDrag, commit: curtain.commit, disabled: gestureDisabled, setHandleState: setHandleVisual, }); useCurtainBodyGesture({ curtainRef, handleRef, bottomPinnedRef, scrollRef, snap: curtain.snap, pinned: curtain.pinned, setLiveDrag: curtain.setLiveDrag, commit: curtain.commit, disabled: gestureDisabled, setHandleState: setHandleVisual, }); const isActive = isFormSnap(curtain.snap); const openSearch = useCallback(() => curtain.open(), [curtain]); const { close } = curtain; // Radar-pulse overlay gate. Native-only (the gesture is), silent under // reduced-motion, and shown whenever the refresh card is revealed — at // the `refresh` snap OR while a drag is in flight (`isDragging`). // // We gate on `isDragging` (a stable boolean held true for the whole drag) // NOT on `liveDragPx > 0`: a held finger micro-jitters around the drag // origin, so `liveDragPx` dithers across 0 every frame. With the old // `liveDragPx > 0` term that flipped `showPulse` true/false each frame, // remounting the pulse layer (and the mascot video) → the rings restarted // over and over ("circle spam") and the curtain rattled. `isDragging` // doesn't flip on the drag delta's sign, so the layer stays mounted. const reducedMotion = useMemo( () => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches, [] ); const showPulse = isNativePlatform() && !reducedMotion && (isRefreshSnap(curtain.snap) || curtain.isDragging); // Pull-to-refresh side-effect. When the curtain commits to the // `refresh` snap, keep the mascot dancing for at least REFRESH_MIN_MS // (so a near-instant result doesn't flash), capped at REFRESH_MAX_MS // (so an offline client can't strand the curtain open), then auto-close. // // Matrix /sync is a continuous long-poll, so on a HEALTHY client there's // nothing to wait for — the state is already current and the next // `Syncing` event may be up to a full timeout away. So we branch on the // current sync state: healthy (Syncing/Prepared) → the pull is a ~1.2s // delight, state already fresh; otherwise (Error/Reconnecting/Catchup/ // cold) → `mx.retryImmediately()` forces the next /sync now and we wait // for the recovery `Syncing`/`Prepared` event (same poke ClientRoot uses // on the `online` event). An early up-drag dismiss (`close-refresh`) // flips `refreshing` false and the cleanup cancels the pending timers. // // The "ready to close" signal (`refreshReady`) is split from the actual // close so the close can DEFER while a drag is in flight. Closing during // a drag would zero `liveDragPx` under the finger while the gesture hook // keeps its own `lastDelta`, snapping the curtain ~MASCOT_CARD_PX on the // next touchmove. The separate close effect below gates on // `!curtain.isDragging`, so a deferred close fires the instant a // mid-refresh retract drag releases (spring-back included — no strand). const refreshing = curtain.snap === 'refresh'; // Ref to the dance