import React, { MutableRefObject, ReactNode, TransitionEvent, useCallback, useEffect, useMemo, useState, } from 'react'; import { useTranslation } from 'react-i18next'; import { useMatch, useNavigate } from 'react-router-dom'; import { useAtomValue, useSetAtom } from 'jotai'; import { Box, Icon, IconButton, Icons, toRem } from 'folds'; import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths'; import { isNativePlatform } from '../../utils/capacitor'; import { useBotPresets } from '../../features/bots/catalog'; import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext'; import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader'; import { settingsSheetAtom } from '../../state/settingsSheet'; import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet'; import * as css from './StreamHeader.css'; import { Segment } from './Segment'; import { Chip } from './Chip'; import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState'; import { useCurtainGesture } from './useCurtainGesture'; import { InlineNewChatForm } from './forms/InlineNewChatForm'; 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's children (`children` prop) render inside an element that // receives `scrollRef` automatically — the parent doesn't need to // wire it. The ref is used by the touch gesture to recognise list // scrollTop=0 and engage the peek-reveal. 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, // ChannelCreateRow, 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). When provided, pin state is // stored in `curtainPinnedByTabAtom[pinKey]`; without it, pin lives // in a local useState that resets on unmount. Listing surfaces // wired into the mobile pager (Direct / Channels / Bots) all pass // a key; other consumers can omit it. pinKey?: string; }; export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: 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); // Suppress the 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; useCurtainGesture({ scrollRef, snap: curtain.snap, pinned: curtain.pinned, setPinned: curtain.setPinned, setLiveDrag: curtain.setLiveDrag, commit: curtain.commit, disabled: gestureDisabled, }); const isActive = isFormSnap(curtain.snap); const openSearch = useCallback(() => curtain.open('search'), [curtain]); const openChat = useCallback(() => curtain.open('chat'), [curtain]); const { close } = curtain; // Memoised controls object so the cleanup's identity check (atom // compare-and-clear) is meaningful — without useMemo a fresh object // would be created on every render and the cleanup of an earlier // render would never match the atom's current contents. const pagerControls = useMemo( () => ({ openSearch, openChat, closeForm: close, isFormActive: isActive, }), [openSearch, openChat, close, isActive] ); const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom); useEffect(() => { if (!isActivePagerPane) return undefined; setPagerCurtain(pagerControls); // Compare-and-clear cleanup: only wipe the atom if it still holds // OUR controls. If another pane became active between this render // and the cleanup (rapid tab switch), it has already overwritten // the atom with its own controls — we must not clobber that. return () => { setPagerCurtain((prev) => (prev === pagerControls ? null : prev)); }; }, [isActivePagerPane, pagerControls, setPagerCurtain]); // Curtain's `top` is the resting snap position plus the live drag // delta. React-driven (no inline DOM writes), so finger-tracking and // commit happen in the same render pipeline and there's no // intermediate "snap back, then animate" flash on release. // // When `pinned` is true the local snap (kept at {closed, peek, // form-*}) is overridden — the curtain rests at y = 0 inside the // stage (= y = safe-top in viewport), covering the tabs row. The // global pinned atom shares this state across every listing tab so // swiping between Direct / Channels / Bots preserves the lock. const curtainTop = curtain.pinned ? 0 + curtain.liveDragPx : snapTopPx(curtain.snap, curtain.formHeightPx) + curtain.liveDragPx; // After the curtain settles at `closed`, unmount any lingering form. // Guarded so unrelated transitionend events (e.g. children's own // transitions bubbling up) don't drop the form mid-animation. const onCurtainTransitionEnd = useCallback( (evt: TransitionEvent) => { if (evt.target !== evt.currentTarget) return; if (evt.propertyName !== 'top') return; curtain.acknowledgeClosed(); }, [curtain] ); // On-screen keyboard detection via VisualViewport API. Global // viewport-meta is `interactive-widget=resizes-content` (load- // bearing for the room composer's keyboard-follow behaviour), which // shrinks the layout viewport when a soft keyboard appears. Any // `bottom: 0` child — including DirectSelfRow inside the curtain — // rises with the shrunken viewport and ends up sitting RIGHT ABOVE // the keyboard, blocking the inline form the user is typing into. // // Fix: when the keyboard is up, collapse the `bottomPinned` slot // to zero height so it neither claims flex space at the curtain // bottom nor renders above the keyboard. The user perceives the // keyboard as overlaying everything below the form (matching their // mental model: "клавиатура рисуется поверх кнопок и чатов, кнопка // настройки остаётся прибитой снизу"). The row reappears the moment // the keyboard retracts. // // The reference-height tracking mirrors `AuthLayout.tsx`: bump the // reference upward on every grow (so rotation / keyboard-close // events stay self-correcting) and treat a meaningful shrink (>= // KEYBOARD_PROBE_PX) as «keyboard is up». The probe avoids // spurious flips on small browser-chrome animations. const [keyboardOpen, setKeyboardOpen] = useState(false); useEffect(() => { const vv = window.visualViewport; if (!vv) return undefined; const KEYBOARD_PROBE_PX = 100; let referenceH = vv.height; let rafId: number | null = null; const apply = () => { rafId = null; if (vv.height > referenceH) referenceH = vv.height; setKeyboardOpen(referenceH - vv.height >= KEYBOARD_PROBE_PX); }; const onResize = () => { if (rafId === null) rafId = requestAnimationFrame(apply); }; apply(); vv.addEventListener('resize', onResize); return () => { if (rafId !== null) cancelAnimationFrame(rafId); vv.removeEventListener('resize', onResize); }; }, []); return (
{/* ── Tabs row + action icons (always visible) ─────────── In pager mode the row stays mounted (curtain snap math depends on its TABS_ROW_PX height) but is painted invisible via `opacity: 0` because the shared static tabs row at the pager root owns the visible chrome. Critically opacity (not `visibility: hidden`) keeps the row HIT-TESTABLE while invisible — the strip's stacking context paints ON TOP of the static pager header at rest (the curtain-rises-over- header contract), so without per-pane taps the segments would be unreachable. Both rows wire onClick to the same navigate() destination, so whichever row captures the tap the user-visible result is identical. The static header z- elevates only while a horseshoe sheet is active and the active pane isn't pinned — in that window the static header sits above the strip and captures taps directly; the rest of the time taps land on this opacity-0 row and resolve through its own per-pane onClick handlers. `aria-hidden` removes the duplicate (per-pane) segments and icons from the accessibility tree so screen readers don't announce three sets of "Direct / Channels / Bots" plus the single visible set from the shared static header. */}
{showBotsSegment && ( )}
{isActive ? ( ) : (
)}
{/* ── Chips vs form ────────────────────────────────────── Mutually exclusive. While a form is mounted (including the curtain's close-snap window before `acknowledgeClosed`), the chips stay unrendered so the form doesn't visually jump from y = TABS_ROW_PX to y = TABS_ROW_PX + 2·CHIP_ROW_PX mid-animation. When chips are rendered: always present in their fixed header positions; the curtain occludes them by z-stacking. As the user drags the curtain down, the chips reveal from underneath naturally. `Chip.hidden` only controls keyboard focus (the chip paints normally; the curtain's z-index does the visual hiding). */} {curtain.activeForm ? (
{curtain.activeForm === 'search' && } {curtain.activeForm === 'chat' && }
) : ( <>
)}
{/* ── Curtain layer ───────────────────────────────────── Renders ABOVE the header (z-index higher). `top` combines the snap-derived resting position with the live finger drag — one React-controlled inline style, no ref-based DOM writes. The transition is disabled during the drag and restored on commit so the snap commit animates smoothly without an intermediate "snap back then animate forward" flash. */}
{children} {/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is kept mounted across snaps so the curtain reads as a self- contained "screen" with its bottom row always pinned to the stage bottom. While the on-screen keyboard is up the slot collapses to `height: 0` so it neither paints nor claims flex space above the keyboard (see the `keyboardOpen` effect above for the rationale). */} {bottomPinned && (
{bottomPinned}
)}
); }