vojo/src/app/components/stream-header/StreamHeader.tsx

530 lines
23 KiB
TypeScript

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 { 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,
StreamHeaderPrimaryAction,
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 { useCurtainHandleGesture } from './useCurtainHandleGesture';
import { useCurtainBodyGesture } from './useCurtainBodyGesture';
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 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<HTMLDivElement | null>;
// 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 override for the Plus button. When omitted the header
// renders the default «new chat» action that opens InlineNewChatForm
// via the curtain. Channels overrides this with «create channel» /
// «create community» so the same Plus slot launches a contextual
// action instead of the DM-creation form.
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);
// 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↔
// peek in one motion (`closed-free`); other snaps drive single-
// destination transitions (unpin / close-peek / 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<HTMLDivElement>(null);
const curtainRef = useRef<HTMLDivElement>(null);
const bottomPinnedRef = useRef<HTMLDivElement>(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,
setPinned: curtain.setPinned,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
disabled: gestureDisabled,
setHandleState: setHandleVisual,
});
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<MobilePagerCurtainControls>(
() => ({
openSearch,
openChat,
closeForm: close,
isFormActive: isActive,
primaryAction: primaryAction ?? null,
}),
[openSearch, openChat, close, isActive, primaryAction]
);
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<HTMLDivElement>) => {
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 (
<div className={css.stage}>
<header className={css.header}>
{/* ── 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. */}
<div
className={css.tabsRow}
style={inPagerMode ? { opacity: 0 } : undefined}
aria-hidden={inPagerMode || undefined}
>
<div className={css.tabsCluster}>
<Segment
active={!!directMatch}
label={t('Direct.segment_dm')}
onClick={onSegmentDirect}
/>
<Segment
active={!!channelsMatch}
label={t('Direct.segment_channels')}
onClick={onSegmentChannels}
/>
{showBotsSegment && (
<Segment
active={!!botsMatch}
label={t('Direct.segment_bots')}
onClick={onSegmentBots}
/>
)}
</div>
<Box grow="Yes" />
{isActive ? (
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={close}
aria-label={t('Direct.close')}
aria-controls={INLINE_FORM_ID}
aria-expanded
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
) : (
<div className={css.iconsCluster}>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
// `aria-controls` points at the curtain-mounted form
// region — drop it when `primaryAction` opens a portal
// dialog (`Modal` lives outside this subtree, so there
// is nothing to control here). `aria-haspopup="dialog"`
// + `aria-expanded={false}` stay accurate for both
// branches: the override opens a true Modal dialog.
aria-controls={primaryAction ? undefined : INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
>
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={openSearch}
aria-label={t('Search.search')}
aria-controls={INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
>
<Icon size="100" src={Icons.Search} />
</IconButton>
</div>
)}
</div>
{/* ── 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 ? (
<div
id={INLINE_FORM_ID}
role="region"
aria-label={
curtain.activeForm === 'search'
? t('Search.search')
: t('Direct.create_chat_subtitle')
}
className={css.formArea}
style={{
height: toRem(curtain.formHeightPx ?? 0),
}}
>
<div ref={curtain.formMeasureRef} className={css.formInner}>
{curtain.activeForm === 'search' && <InlineRoomSearch onClose={close} />}
{curtain.activeForm === 'chat' && <InlineNewChatForm onClose={close} />}
</div>
</div>
) : (
<>
<div className={css.chipRow}>
<Chip
iconSrc={Icons.Search}
label={t('Search.search')}
onClick={openSearch}
hidden={curtain.snap !== 'peek'}
/>
</div>
<div className={css.chipRow}>
<Chip
iconSrc={primaryAction ? primaryAction.iconSrc : Icons.Plus}
label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
onClick={primaryAction ? primaryAction.onClick : openChat}
hidden={curtain.snap !== 'peek'}
/>
</div>
</>
)}
</header>
{/* ── 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. */}
<div
ref={curtainRef}
className={css.curtain}
style={{
top: toRem(curtainTop),
transition: curtain.isDragging ? 'none' : undefined,
}}
onTransitionEnd={onCurtainTransitionEnd}
>
{/* Drag handle — native-only. On web (desktop browsers,
Electron) the curtain has no interactive snap states, so
the handle would be pure decoration with no behaviour
behind it; rendering it conditionally drops the 32 px
grabber strip on those surfaces and lets the chat list
sit flush against the curtain's rounded top.
On native the handle hosts the authoritative curtain
gesture (pin / unpin / peek / close-peek / form-close)
and stays mounted across snap transitions so the gesture
surface is always reachable when there is one to make.
`data-dragging` / `data-at-commit` mirror the desktop
`PageNavResizeHandle`: CSS selectors on `handleBar` light
the pill up Primary-blue + stretch it when these flip.
Both attrs are emitted/cleared only via React state set by
the gesture hook (dedup'd), so the handle visual updates
without slamming the DOM on every touchmove. */}
{isNativePlatform() && (
<div
ref={handleRef}
className={css.handle}
data-dragging={handleVisual.dragging || undefined}
data-at-commit={handleVisual.atCommit || undefined}
aria-hidden
>
<div className={css.handleBar} />
</div>
)}
{children}
{/* `bottomPinned` (DirectSelfRow, WorkspaceFooter) 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 && (
<div
ref={bottomPinnedRef}
className={css.bottomPinnedSlot}
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
>
{bottomPinned}
</div>
)}
</div>
</div>
);
}