672 lines
31 KiB
TypeScript
672 lines
31 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 { 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<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 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<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,
|
||
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 <video> so the close effect can align the auto-close to
|
||
// a loop boundary (see below). Null while the still poster is shown.
|
||
const mascotVideoRef = useRef<HTMLVideoElement>(null);
|
||
const [refreshReady, setRefreshReady] = useState(false);
|
||
useEffect(() => {
|
||
if (!refreshing) {
|
||
setRefreshReady(false);
|
||
return undefined;
|
||
}
|
||
let synced = false;
|
||
let minElapsed = false;
|
||
const update = () => {
|
||
if (synced && minElapsed) setRefreshReady(true);
|
||
};
|
||
const state = mx.getSyncState();
|
||
if (state === SyncState.Syncing || state === SyncState.Prepared) {
|
||
// Already healthy — state is current; just run the minimum dance.
|
||
synced = true;
|
||
} else {
|
||
// Stuck/cold — force the next /sync and wait for recovery below.
|
||
mx.retryImmediately();
|
||
}
|
||
const onSync = (next: SyncState) => {
|
||
if (next === SyncState.Syncing || next === SyncState.Prepared) {
|
||
synced = true;
|
||
update();
|
||
}
|
||
};
|
||
mx.on(ClientEvent.Sync, onSync);
|
||
const minTimer = window.setTimeout(() => {
|
||
minElapsed = true;
|
||
update();
|
||
}, REFRESH_MIN_MS);
|
||
// Hard cap: force-ready even if /sync never recovers (offline client).
|
||
const maxTimer = window.setTimeout(() => setRefreshReady(true), REFRESH_MAX_MS);
|
||
return () => {
|
||
mx.removeListener(ClientEvent.Sync, onSync);
|
||
window.clearTimeout(minTimer);
|
||
window.clearTimeout(maxTimer);
|
||
};
|
||
}, [refreshing, mx]);
|
||
|
||
// Close once the dance is ready AND no drag is in flight — but only at a
|
||
// mascot LOOP BOUNDARY, so the curtain never retracts mid-dance. When the
|
||
// dance becomes ready we read its position and defer the close by the time
|
||
// left in the current loop (`(duration − currentTime) / playbackRate`); the
|
||
// clip is a seamless 2 s loop, so any wrap point is a clean stop. If there's
|
||
// no live video (reduced-motion poster, or it stalled / hasn't mounted) or
|
||
// it's essentially at the boundary already, close immediately. Re-runs when
|
||
// `isDragging` flips, so a close deferred during a mid-refresh retract
|
||
// re-arms the moment the finger releases (whether it sprang back or not).
|
||
const refreshDragging = curtain.isDragging;
|
||
useEffect(() => {
|
||
if (!(refreshing && refreshReady && !refreshDragging)) return undefined;
|
||
const video = mascotVideoRef.current;
|
||
if (!video || video.paused || !Number.isFinite(video.duration) || video.duration <= 0) {
|
||
close();
|
||
return undefined;
|
||
}
|
||
const remainingMs = ((video.duration - video.currentTime) / (video.playbackRate || 1)) * 1000;
|
||
if (remainingMs <= 30) {
|
||
close();
|
||
return undefined;
|
||
}
|
||
const timer = window.setTimeout(close, remainingMs);
|
||
return () => window.clearTimeout(timer);
|
||
}, [refreshing, refreshReady, refreshDragging, close]);
|
||
|
||
// 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,
|
||
closeForm: close,
|
||
isFormActive: isActive,
|
||
primaryAction: primaryAction ?? null,
|
||
}),
|
||
[openSearch, 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, refresh,
|
||
// 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.
|
||
//
|
||
// `platformOffset` is the web-only shift that lifts every non-pinned
|
||
// snap by the delta between native and web tabs-row heights. Tabs
|
||
// row on web is `WEB_TABS_ROW_PX` (= 54px, matching PageHeader on
|
||
// the right pane); `snapTopPx` is computed against `TABS_ROW_PX`
|
||
// (= 64px) which stays authoritative for native pin/refresh geometry.
|
||
// Subtracting the delta on web realigns the closed/form snaps with
|
||
// the smaller tabs row without touching the snap-state machine.
|
||
// Pinned (= 0) doesn't need the offset because the safe-top + native
|
||
// contract owns that case and pinned is native-only.
|
||
const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX;
|
||
const curtainTop = curtain.pinned
|
||
? 0 + curtain.liveDragPx
|
||
: snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + 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(() => {
|
||
// Desktop browsers / Electron have no soft keyboard, but their
|
||
// VisualViewport DOES shrink on aggressive page zoom (Ctrl+`+`).
|
||
// That used to slip past as a hidden quirk while the curtain
|
||
// rendered as a rounded card; once the web variant flattened the
|
||
// curtain it became a visible regression — the DirectSelfRow at
|
||
// the bottom would collapse to height: 0 under zoom and read as
|
||
// broken layout. Gate the listener to native so the probe only
|
||
// arms where a real soft keyboard can actually appear.
|
||
if (!isNativePlatform()) return undefined;
|
||
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} data-platform={isNativePlatform() ? undefined : 'web'}>
|
||
<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}>
|
||
{/* The default «new chat» Plus is gone — people are found
|
||
through search now. Only the tab-specific `primaryAction`
|
||
(Channels' create-channel / create-community) still
|
||
renders a Plus here; it opens a portal Modal dialog
|
||
outside this subtree, so there's no in-tree form region
|
||
to point `aria-controls` at. */}
|
||
{primaryAction && (
|
||
<IconButton
|
||
variant="SurfaceVariant"
|
||
fill="None"
|
||
size="400"
|
||
radii="Pill"
|
||
onClick={primaryAction.onClick}
|
||
aria-label={primaryAction.label}
|
||
aria-expanded={false}
|
||
aria-haspopup="dialog"
|
||
>
|
||
<Icon size="100" src={primaryAction.iconSrc} />
|
||
</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>
|
||
|
||
{/* ── Mascot-refresh card vs search form ─────────────────
|
||
Mutually exclusive. While the search form is mounted
|
||
(including the curtain's close-snap window before
|
||
`acknowledgeClosed`), the mascot card stays unrendered so the
|
||
form doesn't visually jump mid-animation. Otherwise the mascot
|
||
card sits in its fixed header position below the tabs row and
|
||
the curtain occludes it by z-stacking; dragging the curtain
|
||
down to `refresh` reveals it from underneath. */}
|
||
{curtain.activeForm ? (
|
||
<div
|
||
id={INLINE_FORM_ID}
|
||
role="region"
|
||
aria-label={t('Search.search')}
|
||
className={css.formArea}
|
||
style={{
|
||
height: toRem(curtain.formHeightPx ?? 0),
|
||
}}
|
||
>
|
||
<div ref={curtain.formMeasureRef} className={css.formInner}>
|
||
<InlineRoomSearch onClose={close} />
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// Dancing-mascot pull-to-refresh card. Rendered in the header
|
||
// below the tabs row; the curtain z-occludes it at `closed` and
|
||
// the drag-down reveals it at the `refresh` snap. The Search /
|
||
// Channels-create chips that used to live here are gone — search
|
||
// is reached via the tabs-row icon, Channels-create via its
|
||
// tabs-row Plus.
|
||
<RefreshMascot
|
||
caption={t('Direct.refreshing')}
|
||
revealing={isRefreshSnap(curtain.snap) || curtain.isDragging}
|
||
videoRef={mascotVideoRef}
|
||
/>
|
||
)}
|
||
</header>
|
||
|
||
{/* ── Radar-pulse overlay ────────────────────────────────
|
||
Concentric pink rings rippling across the light-blue surface behind
|
||
the curtain (z:1, the curtain at z:2 occludes whatever's below its
|
||
top edge). ONE <svg> of 10 <circle>s rather than 10 animated <span>s:
|
||
10 animated spans each become their own GPU layer and blow Chromium's
|
||
`cc` tile-memory budget on Android WebView ("some content may not
|
||
draw") → flicker. Chrome doesn't split an SVG into per-element layers,
|
||
so the rings share one layer. `pointerEvents: none` so taps fall
|
||
through to the tabs. See `stagePulseLayer`. */}
|
||
{showPulse && (
|
||
<div className={css.stagePulseLayer}>
|
||
<svg className={css.stagePulseSvg} viewBox="0 0 760 760" aria-hidden="true">
|
||
{[0, 0.4, 0.8, 1.2, 1.6, 2.0, 2.4, 2.8, 3.2, 3.6].map((delay) => (
|
||
<circle
|
||
key={delay}
|
||
className={css.stagePulseRing}
|
||
cx={380}
|
||
cy={380}
|
||
r={380}
|
||
style={{ animationDelay: `${delay}s` }}
|
||
/>
|
||
))}
|
||
</svg>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 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 / refresh / close-refresh / 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>
|
||
);
|
||
}
|