418 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|