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

418 lines
18 KiB
TypeScript

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<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,
// 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<MobilePagerCurtainControls>(
() => ({
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<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={openChat}
aria-label={t('Direct.create_chat')}
aria-controls={INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
>
<Icon size="100" src={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={Icons.Plus}
label={t('Direct.create_chat')}
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
className={css.curtain}
style={{
top: toRem(curtainTop),
transition: curtain.isDragging ? 'none' : undefined,
}}
onTransitionEnd={onCurtainTransitionEnd}
>
{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 && (
<div
className={css.bottomPinnedSlot}
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
>
{bottomPinned}
</div>
)}
</div>
</div>
);
}