feat(refresh): host the mascot chrome as a strip singleton and draw the radar rings on one canvas, zeroing WebView tile-memory warnings
This commit is contained in:
parent
7bf177751b
commit
5c67c4d78e
16 changed files with 1133 additions and 216 deletions
|
|
@ -202,7 +202,7 @@ Slate-based. `Editor.tsx` (Slate root — preserve), `Editor.preview.tsx`, `Elem
|
|||
### Navigation / list (mostly NEW dirs)
|
||||
|
||||
- `nav/` (**NEW**, heavily used) — generic list-row primitives (`NavCategory`, `NavCategoryHeader`, `NavItem`/`NavLink`, `NavItemContent`, `NavItemOptions`, `NavEmptyLayout`). The lower-level layer beneath `features/room-nav/`.
|
||||
- `stream-header/` (**NEW**) — the **tab curtain header** for the Direct/Channels/Bots listing tabs (`StreamHeader` + `Chip`/`Segment` + `useCurtain*` gestures + `forms/InlineRoomSearch`). **Not** the room header — don't confuse with `RoomViewHeaderDm`. The Plus/new-chat action is **gone on Direct** (people are found in search); the Plus only renders where a tab supplies a `primaryAction` (Channels' create-channel/community). The curtain has a single form (`form-search`); its peek geometry is **chip-count-aware** — `peekTravelPx(chipRows)` (1 on Direct, 2 on Channels) is threaded into `snapTopPx` + both gesture hooks so rest position and commit scale stay in lockstep.
|
||||
- `stream-header/` (**NEW**) — the **tab curtain header** for the Direct/Channels/Bots listing tabs (`StreamHeader` + `Segment` + `useCurtain*` gestures + `RefreshMascot`/`RadarPulse` + `forms/InlineRoomSearch`). **Not** the room header — don't confuse with `RoomViewHeaderDm`. The Plus/new-chat action is **gone on Direct** (people are found in search); the Plus only renders where a tab supplies a `primaryAction` (Channels' create-channel/community). The curtain is a snap machine (`useCurtainState`: `closed` / momentary `refresh` / `form-search`, plus a per-tab `pinned` overlay in `curtainPinnedByTabAtom`); the old chip-peek geometry (`Chip`, `peekTravelPx`) is **gone** (commit 901bec2e) — pulling down past `REFRESH_COMMIT_PX` commits a pull-to-refresh that reveals the dancing-mascot card + radar-pulse rings (`MASCOT_CARD_PX` below the tabs row) and auto-closes after /sync recovers, aligned to the dance-loop boundary. The rings are ONE transparent 2D `<canvas>` redrawn per rAF (`RadarPulse.tsx` — an untiled TextureLayer; the previous CSS-animated SVG circles each became their own peak-scale cc layer and blew the WebView tile budget — corrected root-cause postmortem in that file). In pager mode the mascot/rings render ONCE in a strip-hosted singleton (`mobile-tabs-pager/PagerRefreshSingleton`, counter-translated against the strip via the inline `counterTx` prop, compositor-transitioned in lockstep with the strip) — one screen-static instance for all tabs, shown while ANY tab reveals (`curtainRefreshRevealByTabAtom`), hidden behind active forms/horseshoes; it MUST stay inside the strip subtree — see the tile-memory postmortem on `pagerRefreshSingleton` in `mobile-tabs-pager/style.css.ts`.
|
||||
- `mobile-tabs-pager/` (**NEW**) — the mobile swipe pager (see Routing).
|
||||
- `sidebar/` — `Sidebar` (66px wrapper), `SidebarItem`, `SidebarStack`, `SidebarStackSeparator`, `SidebarContent` (currently mounted only via dead `SidebarNav`).
|
||||
- `virtualizer/` (`VirtualTile`), `scroll-top-container/`.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { settingsSheetAtom } from '../../state/settingsSheet';
|
|||
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
|
||||
import { MobilePagerPaneProvider, MobilePagerTab } from './MobilePagerPaneContext';
|
||||
import { MobileTabsPagerHeader } from './MobileTabsPagerHeader';
|
||||
import { PagerRefreshSingleton } from './PagerRefreshSingleton';
|
||||
import { useMobileTabsPagerGesture } from './useMobileTabsPagerGesture';
|
||||
import { PAGER_EASING, PAGER_TRANSITION_MS, PANE_GAP_PX } from './geometry';
|
||||
import * as css from './style.css';
|
||||
|
|
@ -365,24 +366,67 @@ export function MobileTabsPager({ asBackdrop = false }: MobileTabsPagerProps) {
|
|||
// Memoised so the inline object identity is stable when dragPx
|
||||
// doesn't change — avoids extra child re-renders when other state
|
||||
// updates (e.g. atom subscription) tick the parent.
|
||||
//
|
||||
// `counterTx` is the exact negation of the strip's X — handed to the
|
||||
// strip-hosted refresh singleton as a PROP so its counter-transform
|
||||
// is a direct inline `transform` on a composited element, transitioned
|
||||
// with the IDENTICAL timing string below. Both transitions then run on
|
||||
// the COMPOSITOR in lockstep. (The previous design rode a registered
|
||||
// `--vojo-strip-tx` @property transition — custom-property
|
||||
// interpolation runs on the MAIN thread, and the `navigate()` commit
|
||||
// at slide start reliably blocks it for the first frames of every
|
||||
// swipe commit: the mascot visibly rode the strip, then snapped back.
|
||||
// Same-commit inline styles + compositor transitions remove that
|
||||
// failure mode entirely.)
|
||||
const stripTx = `calc(${-visualIdx * 100}vw - ${visualIdx * PANE_GAP_PX}px + ${visualDragPx}px)`;
|
||||
const counterTx = `calc(${visualIdx * 100}vw + ${visualIdx * PANE_GAP_PX}px - ${visualDragPx}px)`;
|
||||
// Transition suppressed while a finger drag is in flight (the strip
|
||||
// follows the finger 1:1) AND for the single frame after a tap-driven
|
||||
// commit (`instantSwitch`) so segment taps snap to the target tab
|
||||
// without the 280ms slide. Swipe commits leave `instantSwitch` false
|
||||
// so the slide-to-target animation plays. Shared verbatim with the
|
||||
// refresh singleton so the two transforms can never desync.
|
||||
const stripTransition =
|
||||
dragging || instantSwitch ? 'none' : `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`;
|
||||
const stripStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
width: `calc(${tabs.length * 100}vw + ${(tabs.length - 1) * PANE_GAP_PX}px)`,
|
||||
transform: `translate3d(calc(${-visualIdx * 100}vw - ${
|
||||
visualIdx * PANE_GAP_PX
|
||||
}px + ${visualDragPx}px), 0, 0)`,
|
||||
// Transition suppressed while a finger drag is in flight (the
|
||||
// strip follows the finger 1:1) AND for the single frame after
|
||||
// a tap-driven commit (`instantSwitch`) so segment taps snap to
|
||||
// the target tab without the 280ms slide. Swipe commits leave
|
||||
// `instantSwitch` false so the slide-to-target animation plays.
|
||||
transition:
|
||||
dragging || instantSwitch ? 'none' : `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`,
|
||||
transform: `translate3d(${stripTx}, 0, 0)`,
|
||||
transition: stripTransition,
|
||||
gap: `${PANE_GAP_PX}px`,
|
||||
}),
|
||||
[tabs.length, visualIdx, visualDragPx, dragging, instantSwitch]
|
||||
[tabs.length, stripTx, stripTransition]
|
||||
);
|
||||
|
||||
// True from the moment a finger drag RELEASES (commit OR spring-back)
|
||||
// until the strip's ~PAGER_TRANSITION_MS slide has settled. Feeds the
|
||||
// refresh singleton's `pagerAnimating` gate: `pendingTargetTab` clears
|
||||
// as soon as the URL catches up (~1 frame after navigate), and a
|
||||
// spring-back release never sets it at all — yet both play a ≈280ms
|
||||
// slide during which a lingering mascot-card sliver could sweep past
|
||||
// the transparent inter-pane gap. Timer-based (not transitionend) so a
|
||||
// missed event can't strand the flag. Tap commits (instantSwitch) jump
|
||||
// without sliding and never pass through `dragging`, so they need no
|
||||
// hold.
|
||||
const [stripSettling, setStripSettling] = useState(false);
|
||||
const wasDraggingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (dragging) {
|
||||
wasDraggingRef.current = true;
|
||||
setStripSettling(false);
|
||||
return undefined;
|
||||
}
|
||||
if (!wasDraggingRef.current) return undefined;
|
||||
wasDraggingRef.current = false;
|
||||
setStripSettling(true);
|
||||
const id = window.setTimeout(() => setStripSettling(false), PAGER_TRANSITION_MS + 100);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [dragging]);
|
||||
|
||||
// Strip-in-motion signal for the refresh singleton (gates only its
|
||||
// post-close card linger — see `pagerAnimating` on the component).
|
||||
const stripAnimating = dragging || pendingTargetTab !== null || stripSettling;
|
||||
|
||||
// Per-pane context values memoised separately so each pane's
|
||||
// `useMobilePagerPane()` consumer (the inner StreamHeader) only
|
||||
// re-runs when ITS isActive flag toggles, not every time the parent
|
||||
|
|
@ -410,6 +454,31 @@ export function MobileTabsPager({ asBackdrop = false }: MobileTabsPagerProps) {
|
|||
// active. Map it back to a Tab name and pass down.
|
||||
const activeTab: Tab = tabs[urlActiveIdx] ?? 'direct';
|
||||
|
||||
// Memoised PANE ELEMENTS — referentially stable children let React
|
||||
// skip reconciling the three listing subtrees on every parent
|
||||
// re-render. Load-bearing for swipe smoothness: the drag updates
|
||||
// `dragPx` state on every touchmove, re-rendering this component at
|
||||
// input frequency (60–120 Hz on Android), and without stable
|
||||
// children each frame would also re-render Direct + Channels + Bots
|
||||
// (virtualized DM list included) in the same frame that has to
|
||||
// commit the strip transform.
|
||||
const directPane = useMemo(() => <Direct />, []);
|
||||
const channelsPane = useMemo(
|
||||
() => (
|
||||
<ChannelsModeProvider value>
|
||||
{activeSpace ? (
|
||||
<SpaceProvider key={activeSpace.roomId} value={activeSpace}>
|
||||
<Channels />
|
||||
</SpaceProvider>
|
||||
) : (
|
||||
<ChannelsRootNav />
|
||||
)}
|
||||
</ChannelsModeProvider>
|
||||
),
|
||||
[activeSpace]
|
||||
);
|
||||
const botsPane = useMemo(() => <Bots />, []);
|
||||
|
||||
// Invalid URL space — defer to the existing route tree which
|
||||
// handles unjoined / unknown spaces via JoinBeforeNavigate. All
|
||||
// hooks above must run unconditionally for rules-of-hooks
|
||||
|
|
@ -441,29 +510,32 @@ export function MobileTabsPager({ asBackdrop = false }: MobileTabsPagerProps) {
|
|||
pixel the curtain isn't covering. See `pagerStaticHeader` in
|
||||
style.css.ts for the full overlay contract. */}
|
||||
<div className={css.strip} style={stripStyle} data-pager-pane="true">
|
||||
{/* ONE screen-static pull-to-refresh chrome for the whole app —
|
||||
mascot card + radar rings — hosted INSIDE the strip's
|
||||
transform subtree (tile-memory contract) and counter-
|
||||
translated by `counterTx` (the strip X negated, same-commit
|
||||
inline style + identical compositor transition) so it never
|
||||
rides a pane. DOM-FIRST child: as the first positioned z:auto
|
||||
participant of the strip's stacking context it paints below
|
||||
every pane's positioned content — curtains and sheets cover
|
||||
it with no z-index machinery. See
|
||||
style.css.ts::pagerRefreshSingleton for the full contract.
|
||||
`pagerAnimating` gates only the post-close card linger (the
|
||||
gap sweep), never the live reveal. */}
|
||||
<PagerRefreshSingleton
|
||||
pagerAnimating={stripAnimating}
|
||||
counterTx={counterTx}
|
||||
transition={stripTransition}
|
||||
/>
|
||||
<MobilePagerPaneProvider value={directPaneInfo}>
|
||||
<PaneSlot isActive={directPaneInfo.isActive}>
|
||||
<Direct />
|
||||
</PaneSlot>
|
||||
<PaneSlot isActive={directPaneInfo.isActive}>{directPane}</PaneSlot>
|
||||
</MobilePagerPaneProvider>
|
||||
<MobilePagerPaneProvider value={channelsPaneInfo}>
|
||||
<PaneSlot isActive={channelsPaneInfo.isActive}>
|
||||
<ChannelsModeProvider value>
|
||||
{activeSpace ? (
|
||||
<SpaceProvider key={activeSpace.roomId} value={activeSpace}>
|
||||
<Channels />
|
||||
</SpaceProvider>
|
||||
) : (
|
||||
<ChannelsRootNav />
|
||||
)}
|
||||
</ChannelsModeProvider>
|
||||
</PaneSlot>
|
||||
<PaneSlot isActive={channelsPaneInfo.isActive}>{channelsPane}</PaneSlot>
|
||||
</MobilePagerPaneProvider>
|
||||
{showBots && (
|
||||
<MobilePagerPaneProvider value={botsPaneInfo}>
|
||||
<PaneSlot isActive={botsPaneInfo.isActive}>
|
||||
<Bots />
|
||||
</PaneSlot>
|
||||
<PaneSlot isActive={botsPaneInfo.isActive}>{botsPane}</PaneSlot>
|
||||
</MobilePagerPaneProvider>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import {
|
|||
mobilePagerCurtainAtom,
|
||||
} from '../../state/mobilePagerHeader';
|
||||
import { Segment } from '../stream-header/Segment';
|
||||
import { INLINE_FORM_ID } from '../stream-header/StreamHeader';
|
||||
import * as streamHeaderCss from '../stream-header/StreamHeader.css';
|
||||
import type { MobilePagerTab } from './MobilePagerPaneContext';
|
||||
import * as css from './style.css';
|
||||
|
||||
// Positive z-index applied to the static pager header when any
|
||||
|
|
@ -18,15 +20,10 @@ import * as css from './style.css';
|
|||
// just makes the intent explicit at the call site.
|
||||
const PAGER_HEADER_ELEVATED_Z = 1;
|
||||
|
||||
type Tab = 'direct' | 'channels' | 'bots';
|
||||
|
||||
// Must match the `INLINE_FORM_ID` local constant in
|
||||
// `StreamHeader.tsx`. The shared static header's action icons are
|
||||
// `aria-controls`-linked to the form region that the active pane's
|
||||
// StreamHeader renders inside its curtain — mirroring the original
|
||||
// in-pane buttons' ARIA semantics so assistive tech still announces
|
||||
// the relationship correctly. Keep the two literals in lockstep.
|
||||
const INLINE_FORM_ID = 'stream-header-inline-form';
|
||||
// The pager's tab union — imported, not re-declared, so the
|
||||
// `pinnedByTab[activeTab]` read below stays type-aligned with the
|
||||
// per-tab atoms keyed by StreamHeader's `pinKey`.
|
||||
type Tab = MobilePagerTab;
|
||||
|
||||
type MobileTabsPagerHeaderProps = {
|
||||
showBots: boolean;
|
||||
|
|
|
|||
192
src/app/components/mobile-tabs-pager/PagerRefreshSingleton.tsx
Normal file
192
src/app/components/mobile-tabs-pager/PagerRefreshSingleton.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import {
|
||||
MobilePagerMascotApi,
|
||||
mobileHorseshoeActiveAtom,
|
||||
mobilePagerAnyRevealAtom,
|
||||
mobilePagerFormMountedAtom,
|
||||
mobilePagerMascotApiAtom,
|
||||
} from '../../state/mobilePagerHeader';
|
||||
import { RefreshMascot } from '../stream-header/RefreshMascot';
|
||||
import { RadarPulse, useEchoes } from '../stream-header/RadarPulse';
|
||||
import { CURTAIN_SNAP_MS } from '../stream-header/geometry';
|
||||
import * as css from './style.css';
|
||||
|
||||
type PagerRefreshSingletonProps = {
|
||||
// True while the strip is moving horizontally — a finger drag, the
|
||||
// commit-slide, or the spring-back settle. Used ONLY to suppress the
|
||||
// post-close card linger (see `cardLinger` below): the lingering card
|
||||
// would otherwise sweep past the transparent inter-pane gap as a
|
||||
// visible sliver. While a tab IS revealing, the card intentionally
|
||||
// stays through a swipe — that's the whole «mascot doesn't ride the
|
||||
// panes» point.
|
||||
pagerAnimating: boolean;
|
||||
// The strip's live X, NEGATED (computed by MobileTabsPager next to
|
||||
// the strip transform). Applied as a direct inline `transform` on the
|
||||
// promoted host so the counter-translation is committed in the SAME
|
||||
// React style flush as the strip's transform and — during the 280ms
|
||||
// commit-slide — transitions on the COMPOSITOR in lockstep with it.
|
||||
// (A registered-@property var was tried first: custom-property
|
||||
// interpolation runs on the main thread, and the navigate() commit at
|
||||
// slide start froze it for the first frames of every swipe commit —
|
||||
// the mascot visibly rode the strip, then snapped back.)
|
||||
counterTx: string;
|
||||
// The strip's transition string, verbatim (`none` while dragging /
|
||||
// instant-switching, the 280ms slide otherwise) — single source so
|
||||
// the two transforms can never desync.
|
||||
transition: string;
|
||||
};
|
||||
|
||||
// The pull-to-refresh chrome singleton — renders the dancing mascot +
|
||||
// radar-pulse rings ONCE as the strip's first child, counter-translated
|
||||
// against the strip's live translation so it stays SCREEN-static while
|
||||
// the panes slide (see `style.css.ts::pagerRefreshSingleton` for the
|
||||
// hosting / tile-memory / paint-order contract). The pull-to-refresh is
|
||||
// an app-wide /sync poke, and the user reads the mascot as belonging to
|
||||
// the app, not to one tab. The per-pane StreamHeaders render no mascot
|
||||
// of their own in pager mode; each pane publishes its reveal state into
|
||||
// `curtainRefreshRevealByTabAtom` and this singleton paints while ANY
|
||||
// tab is revealing (an inactive tab's curtain legitimately rests at
|
||||
// `refresh` through its auto-close dwell — keeping the chrome alive for
|
||||
// it makes a swipe-away AND a return swipe mid-refresh seamless, no
|
||||
// dance restart).
|
||||
//
|
||||
// Memoised: the parent pager re-renders on every horizontal-drag frame;
|
||||
// the single primitive prop plus boolean-derived atoms keep this
|
||||
// component to actual flips.
|
||||
export const PagerRefreshSingleton = React.memo(
|
||||
({ pagerAnimating, counterTx, transition }: PagerRefreshSingletonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const anyRevealing = useAtomValue(mobilePagerAnyRevealAtom);
|
||||
|
||||
// Hide while the ACTIVE pane's inline search form is MOUNTED (not
|
||||
// just while its snap is a form snap): this singleton paints behind
|
||||
// the panes' content, and the form band has no opaque backdrop in
|
||||
// pager mode — a mascot kept alive by ANOTHER tab's in-flight
|
||||
// refresh (or by the linger) would show through the still-visible
|
||||
// CLOSING form during its 280–480ms slide-up window.
|
||||
const formMounted = useAtomValue(mobilePagerFormMountedAtom);
|
||||
|
||||
// Hide while a horseshoe sheet is geometrically active. The sheet
|
||||
// flips its pane-covering appBody OPAQUE on the first frame of drag
|
||||
// (void-containment contract — see MobileSettingsHorseshoe), and
|
||||
// appBody is a positioned later-in-tree sibling inside the pane,
|
||||
// i.e. it paints over this singleton anyway. Unmounting on the same
|
||||
// signal makes the hand-off explicit and stops the dance <video>
|
||||
// decoding behind an opaque surface. Pre-singleton, the in-pane
|
||||
// card stayed visible above appBody during a sheet drag; that
|
||||
// nicety is consciously traded away — the overlap (sheet opened
|
||||
// during the ≤8s refresh dwell) is rare, cosmetic, and
|
||||
// self-corrects when the refresh auto-closes under the sheet.
|
||||
const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom);
|
||||
|
||||
const revealing = anyRevealing && !formMounted && !horseshoeActive;
|
||||
|
||||
// The dance <video> while mounted (null while the reduced-motion /
|
||||
// resting poster is shown). Handed to the pane's StreamHeader
|
||||
// through the api atom so its auto-close can align to a dance-loop
|
||||
// boundary — same contract as the in-stage `mascotVideoRef`.
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Echo state lives here (not in the per-pane StreamHeaders) so the
|
||||
// one-shot rings share the singleton's canvas. NB an echo spawned
|
||||
// just before close survives ~4.1s in this state; on a quick
|
||||
// re-reveal it RESUMES mid-pass (echo phases are absolute
|
||||
// `performance.now()` timestamps — see `EchoPulse`), it doesn't
|
||||
// replay from zero like the old CSS-animation circles did.
|
||||
const { echoes, spawnEcho } = useEchoes();
|
||||
|
||||
// Read once at mount, mirroring `RefreshMascot` / `StreamHeader` —
|
||||
// this singleton is native-only + aria-hidden decoration, so a
|
||||
// mid-session OS toggle not re-evaluating here is acceptable.
|
||||
const reducedMotion = useMemo(
|
||||
() =>
|
||||
typeof window !== 'undefined' &&
|
||||
!!window.matchMedia &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
[]
|
||||
);
|
||||
|
||||
// Publish the imperative channel (stable members → stable object, so
|
||||
// the StreamHeader effects that consume it don't re-run spuriously).
|
||||
// Compare-and-clear on unmount, mirroring `mobilePagerCurtainAtom`.
|
||||
const api = useMemo<MobilePagerMascotApi>(
|
||||
() => ({ getVideo: () => videoRef.current, spawnEcho }),
|
||||
[spawnEcho]
|
||||
);
|
||||
const setApi = useSetAtom(mobilePagerMascotApiAtom);
|
||||
useEffect(() => {
|
||||
setApi(api);
|
||||
return () => {
|
||||
setApi((prev) => (prev === api ? null : prev));
|
||||
};
|
||||
}, [api, setApi]);
|
||||
|
||||
// Keep the mascot CARD mounted through the curtain's close animation.
|
||||
// `revealing` flips false the instant the snap commits to `closed`,
|
||||
// but the curtain takes CURTAIN_SNAP_MS to slide up over the band —
|
||||
// unmounting the card immediately would visibly vanish the mascot
|
||||
// before the curtain covers it. The in-stage variant never had this
|
||||
// problem (its card is permanently mounted in the header); here the
|
||||
// card must NOT be permanently visible, or it would peek through the
|
||||
// inter-pane gap during plain tab swipes at rest. For the same
|
||||
// reason the linger itself is suppressed (a) while the strip is
|
||||
// moving (`pagerAnimating` — the gap would sweep a sliver of the
|
||||
// card across the screen) and (b) under the form / horseshoe gates
|
||||
// above, where `revealing` is forced false while the band isn't
|
||||
// actually closing. The rings don't linger — parity with the
|
||||
// in-stage layer, which unmounts the pulse at close-commit. During
|
||||
// the linger the card renders `revealing=false`, i.e. the still
|
||||
// poster.
|
||||
const [cardLinger, setCardLinger] = useState(false);
|
||||
useEffect(() => {
|
||||
if (revealing) {
|
||||
setCardLinger(true);
|
||||
return undefined;
|
||||
}
|
||||
if (!cardLinger) return undefined;
|
||||
const timer = window.setTimeout(() => setCardLinger(false), CURTAIN_SNAP_MS + 120);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [revealing, cardLinger]);
|
||||
const showCard =
|
||||
revealing || (cardLinger && !pagerAnimating && !formMounted && !horseshoeActive);
|
||||
const showRings = revealing && !reducedMotion;
|
||||
|
||||
// The pager only mounts on mobile + native, so this is belt-and-
|
||||
// suspenders (and keeps the hooks above unconditional).
|
||||
if (!isNativePlatform()) return null;
|
||||
|
||||
// Zero DOM / zero layers during plain swipes — nothing can peek
|
||||
// through the transparent inter-pane gap.
|
||||
if (!showCard && !showRings) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.pagerRefreshSingleton}
|
||||
// `translateZ(0)` promotes the host so the transition above runs
|
||||
// compositor-side; the layer itself is cheap (the host paints
|
||||
// nothing — tiles materialise only for the card band; the rings
|
||||
// canvas is its own untiled TextureLayer, see RadarPulse.tsx).
|
||||
style={{ transform: `translateX(${counterTx}) translateZ(0)`, transition }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{showCard && (
|
||||
<div className={css.pagerMascotSlot}>
|
||||
<RefreshMascot
|
||||
caption={t('Direct.refreshing')}
|
||||
revealing={revealing}
|
||||
videoRef={videoRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Rings paint AFTER (= over) the mascot card, matching the
|
||||
in-stage stacking where the pulse layer sits above the header
|
||||
that hosts the card. */}
|
||||
{showRings && <RadarPulse echoes={echoes} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
PagerRefreshSingleton.displayName = 'PagerRefreshSingleton';
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color } from 'folds';
|
||||
import { color, toRem } from 'folds';
|
||||
import { TABS_ROW_PX } from '../stream-header/geometry';
|
||||
|
||||
// Pager root. Sits inside the authed shell's row-flex slot
|
||||
// (ClientLayout → Box grow=Yes), so `flex: 1 1 0` fills the slot
|
||||
|
|
@ -11,10 +12,20 @@ import { color } from 'folds';
|
|||
// listener calls preventDefault, and only after axis-resolve commits
|
||||
// to "horizontal".
|
||||
//
|
||||
// `SurfaceVariant.Container` backdrop intentionally shows through
|
||||
// (a) the inter-pane gap during a swipe — the gap colour the user
|
||||
// asked for is "light blue same as the header", which IS this
|
||||
// SurfaceVariant tone — and (b) any sub-pixel rounding seam at rest.
|
||||
// `SurfaceVariant.Container` backdrop — THE single static light-blue
|
||||
// canvas of the owner's composition model (his words, Jun 2026):
|
||||
// «есть чисто статический бекграунд светлосиний (#181a20 в тёмной
|
||||
// теме), растянут поверх всего экрана; в шапке статичные вкладки —
|
||||
// просто надписи на этом фоне; ниже — тёмная (#0d0e11) шторка чатов
|
||||
// со скруглением вверху; шторки листаются как картинки в галерее,
|
||||
// между ними ничего — пустой прозрачный геп, виден этот бекграунд;
|
||||
// оттянул шторку до низа — виден этот же бекграунд». So this one
|
||||
// backdrop intentionally shows through (a) the inter-pane gap during
|
||||
// a swipe, (b) the deep-pull void when the curtain is dragged far
|
||||
// down, and (c) any sub-pixel seam at rest. DO NOT darken it «to
|
||||
// match the curtain» — that was tried and rejected on device: a dark
|
||||
// gap reads as «чёрные полосы» between sheets, and the deep-pull
|
||||
// void merges with the curtain into one undifferentiated dark slab.
|
||||
//
|
||||
// `color: Background.OnContainer` mirrors what the route-level
|
||||
// `<PageRoot>` would have set via its `ContainerColor({ variant:
|
||||
|
|
@ -123,16 +134,137 @@ export const pagerStaticHeader = style({
|
|||
|
||||
// Horizontal strip carrying all three panes side-by-side. Width &
|
||||
// transform are computed inline in the JSX (they depend on tabs.length
|
||||
// and visualIdx + visualDragPx, and the gap math couples to them).
|
||||
// and visualIdx + visualDragPx, and the gap math couples to them), as
|
||||
// is `counterTx` — the negated strip X handed to the strip-hosted
|
||||
// refresh singleton for its counter-translation (see
|
||||
// `pagerRefreshSingleton` below for the full contract, including why
|
||||
// the chrome must stay INSIDE the strip).
|
||||
//
|
||||
// `gap: PANE_GAP_PX` is what makes the inter-pane void visible during
|
||||
// a swipe — the pagerRoot's SurfaceVariant.Container colour shows
|
||||
// through the gap, matching the static header tone exactly.
|
||||
// NO `will-change: transform` here — deliberately (Jun 2026). The strip
|
||||
// is ALREADY composited by its always-on inline `translate3d(...)` (a 3D
|
||||
// transform is a direct compositing reason), so will-change added
|
||||
// nothing for promotion — but it DID set cc's raster-even-if-not-drawn
|
||||
// on the strip's content (cc TransformNode::will_change_transform),
|
||||
// forcing the WebView to keep tiles for the entire ~3.2-screen-wide
|
||||
// strip, including off-screen panes. With all three curtains pulled to
|
||||
// the refresh snap and the user shuttling between tabs, that standing
|
||||
// cost blew the tile budget (489 «tile memory limits exceeded» warnings
|
||||
// in one 14s device session) — dropped curtain tiles read as flicker
|
||||
// and the rings «просвечивали» through. Without the flag, off-screen
|
||||
// tiles demote and evict under pressure; the commit-slide transition
|
||||
// still runs on the compositor because the layer exists either way.
|
||||
export const strip = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: '100%',
|
||||
willChange: 'transform',
|
||||
});
|
||||
|
||||
// ── The pull-to-refresh chrome singleton's host ─────────────────────
|
||||
//
|
||||
// WHAT: the screen-static pull-to-refresh chrome (mascot card + radar
|
||||
// rings), mounted ONCE as the strip's FIRST child
|
||||
// (`PagerRefreshSingleton` in MobileTabsPager.tsx) and counter-
|
||||
// translated against the strip's live translation. The strip's
|
||||
// always-on inline `translate3d` makes it the containing block for
|
||||
// absolute descendants, so `left: 0` is the STRIP origin — negating the
|
||||
// strip's X lands the host at the screen origin for every active tab,
|
||||
// with no per-pane offset needed.
|
||||
//
|
||||
// The counter-transform itself is INLINE (PagerRefreshSingleton.tsx):
|
||||
// `translateX(counterTx) translateZ(0)` + the strip's transition string
|
||||
// verbatim, both passed as props from MobileTabsPager so the two
|
||||
// transforms are committed in the same React style flush and — during
|
||||
// the 280ms commit-slide — both transition on the COMPOSITOR in
|
||||
// lockstep. History (device-verified, Jun 2026): a registered
|
||||
// `--vojo-strip-tx` @property transition was tried first; custom-
|
||||
// property interpolation runs on the MAIN thread, and the `navigate()`
|
||||
// route commit at slide start reliably froze it for the first frames of
|
||||
// every swipe commit — the mascot rode the strip, then snapped back
|
||||
// («дёргает в сторону свайпа»), and the per-frame main-thread re-raster
|
||||
// pressured the tile budget under a double-dwell shuttle until curtain
|
||||
// tiles dropped (rings «просвечивали» through). The translateZ(0)
|
||||
// promotion is part of the fix: it moves the host's transform onto the
|
||||
// compositor and stops the strip-layer re-raster; the layer is cheap —
|
||||
// the host paints nothing, tiles materialise only for the card band
|
||||
// (and the rings canvas is its own untiled TextureLayer — see
|
||||
// RadarPulse.tsx).
|
||||
//
|
||||
// WHY INSIDE THE STRIP (the hoist postmortem — empirically established
|
||||
// on device, Jun 2026): hosted OUTSIDE the strip (at the pager root)
|
||||
// the WebView tile-memory budget blew at ~80 «tile memory limits
|
||||
// exceeded» warnings/second while the rings animated — dropped tiles,
|
||||
// curtain flicker, stuttering rings. Hosted inside: 0 warnings.
|
||||
// In-strip hosting keeps the feature's promoted-layer set down to ONE
|
||||
// rings canvas (an untiled TextureLayer — its ~7MB×2-3 buffers never
|
||||
// enter the tile budget at all; see the RadarPulse.tsx postmortem),
|
||||
// ONE video, and this host's empty container layer — instead of
|
||||
// stacking un-cullable full-screen layers at the pager root. (Do NOT over-claim occlusion culling of the
|
||||
// rings by the curtains — whole-layer contents-opaque is likely
|
||||
// defeated by the curtain's rounded corners; the budget argument is
|
||||
// parity with the empirically proven arrangement. See also the strip
|
||||
// comment above: `will-change: transform` was removed from the strip
|
||||
// because it forced raster-even-if-not-drawn across all ~3.2 screens of
|
||||
// pane content and blew the budget when every curtain was parked at the
|
||||
// refresh snap during a shuttle swipe.)
|
||||
//
|
||||
// PAINT ORDER: this host (absolute + transform ⇒ a z:auto stacking
|
||||
// context) is the FIRST positioned z:auto participant of the strip's
|
||||
// stacking context in tree order; every pane surface that must cover
|
||||
// it is positioned-later-in-tree (the `pane` class below is
|
||||
// position:relative; the horseshoe container is relative, its appBody
|
||||
// is absolute with an always-on clip-path stacking context, the
|
||||
// silhouette is absolute; the StreamHeader stage is relative) or
|
||||
// positive-z (header z:1, curtain z:2). NOT plain «DOM order»: a
|
||||
// non-positioned flex item would paint in the earlier in-flow phase
|
||||
// BELOW this positioned host regardless of order. INVARIANT: any
|
||||
// opaque paint inside a pane between backdrop and curtain must be
|
||||
// positioned or a stacking context, or it will paint UNDER the rings.
|
||||
//
|
||||
// HARD RULES: never give this host a positive z-index (it would lift
|
||||
// the chrome above the curtains), and never use `will-change` here
|
||||
// (unlike plain translateZ it forces raster-even-if-not-drawn — the
|
||||
// chrome would keep rastering behind the closed curtains).
|
||||
//
|
||||
// GEOMETRY: `top: 0` — NOT stagePulseLayer's `-safe-top` offset (that
|
||||
// one is STAGE-relative; the stage starts at y = safe-top). This strip
|
||||
// child's y-origin is already the screen top, since pagerRoot fills
|
||||
// from y = 0. `stagePulseCanvas`'s safe-top-relative HEIGHT therefore
|
||||
// yields the identical ring centre as in-stage, and the rings still
|
||||
// spill into the status-bar zone.
|
||||
//
|
||||
// P1c DETERMINATION (recorded so it isn't re-litigated): the thin
|
||||
// 0.26-opacity rings paint OVER the static tab glyphs. Putting the
|
||||
// labels above the rings would require z-elevating the static header
|
||||
// exactly while revealing — but the reveal flag is also true during
|
||||
// every curtain drag, including the pin gesture, and an elevated
|
||||
// header there paints tabs OVER the rising curtain (the documented
|
||||
// pin-contract regression); a dwell-only elevation causes visible
|
||||
// z-pops. Keep rings-over-labels.
|
||||
export const pagerRefreshSingleton = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
// Positions the shared mascot card inside `pagerRefreshSingleton` at
|
||||
// the exact screen Y the per-pane card occupied: below the safe-top
|
||||
// inset plus the tabs row. The card itself (`stream-header/
|
||||
// StreamHeader.css.ts::mascotCard`) brings its own height + centring +
|
||||
// opaque backdrop. No promotion needed here: the HOST is the composited
|
||||
// layer (see above) and repositions on the compositor; the card rasters
|
||||
// once into the host's layer.
|
||||
export const pagerMascotSlot = style({
|
||||
position: 'absolute',
|
||||
top: `calc(var(--vojo-safe-top, 0px) + ${toRem(TABS_ROW_PX)})`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
});
|
||||
|
||||
// Each pane is exactly one viewport wide. CRITICALLY `display: flex;
|
||||
|
|
@ -151,7 +283,17 @@ export const strip = style({
|
|||
// `paddingTop: var(--vojo-safe-top)`. The static header overlay at
|
||||
// the pager root simply paints OVER the same screen zone, so the
|
||||
// underlying geometry stays identical to non-pager mode.
|
||||
//
|
||||
// `position: relative` (z-index:auto — deliberately NO z, so no
|
||||
// stacking context and no layer): makes each pane a tree-order
|
||||
// participant of the strip's positioned paint phase, painting AFTER
|
||||
// the DOM-first refresh singleton. This hardens the singleton-below-
|
||||
// panes ordering against any future non-positioned stacking-context
|
||||
// wrapper (opacity/filter/contain) inside a pane, which would
|
||||
// otherwise drop the pane's whole subtree — curtain included — into
|
||||
// the in-flow phase below the host. See `pagerRefreshSingleton`.
|
||||
export const pane = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexShrink: 0,
|
||||
|
|
|
|||
|
|
@ -41,9 +41,13 @@ type Args = {
|
|||
//
|
||||
// Conflict resolution with other gestures sharing the same surface
|
||||
// (curtain, MobileSettingsHorseshoe, ChannelsWorkspaceHorseshoe) is
|
||||
// cooperative: every gesture-owner resolves axis at the same dead-
|
||||
// zone (12px) and bails when its own axis doesn't dominate. The pager
|
||||
// wins horizontal; the others win vertical.
|
||||
// cooperative: every gesture-owner resolves axis inside a dead-zone
|
||||
// and bails when its own axis doesn't dominate. The pager and the
|
||||
// horseshoes resolve at 12px; the CURTAIN hooks resolve at 10px
|
||||
// (`stream-header/geometry.ts::DIRECTION_DEAD_ZONE_PX`) — i.e. in the
|
||||
// 10–12px window the curtain decides first, by design (its vertical
|
||||
// pull is the most frequent gesture and should feel the most eager).
|
||||
// The pager wins horizontal; the others win vertical.
|
||||
export function useMobileTabsPagerGesture({
|
||||
rootRef,
|
||||
activeIdx,
|
||||
|
|
|
|||
241
src/app/components/stream-header/RadarPulse.tsx
Normal file
241
src/app/components/stream-header/RadarPulse.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { PULSE_EDGE_PX, PULSE_SPAN_PX } from './geometry';
|
||||
import * as css from './StreamHeader.css';
|
||||
|
||||
// ─── The approved ring field, byte-for-byte ──────────────────────────
|
||||
// 10 rings; each lives one 4s ease-out pass: scale 0.05 → 1 of a
|
||||
// 760²-box circle (r 380, stroke 2 SCALING with the ring — 0.1px when
|
||||
// tiny, a crisp 2px at full size), opacity 0 → 0.26 (at 10%) → 0.13
|
||||
// (at 65%) → 0, spawn stagger 0.4s. DO NOT RETUNE — see geometry.ts::
|
||||
// PULSE_SPAN_PX for why the box size is also load-bearing.
|
||||
const LOOP_MS = 4000;
|
||||
const RING_COUNT = 10;
|
||||
const STAGGER_MS = 400;
|
||||
const RING_RADIUS_PX = PULSE_SPAN_PX / 2;
|
||||
const STROKE_PX = 2;
|
||||
const STROKE_STYLE = 'rgb(248, 80, 160)';
|
||||
const MIN_SCALE = 0.05;
|
||||
|
||||
// Lifetime of a one-shot echo ring — the 4s pulse plus a small buffer
|
||||
// before its entry is pruned from React state.
|
||||
const ECHO_LIFETIME_MS = 4100;
|
||||
|
||||
// CSS `ease-out` = cubic-bezier(0, 0, 0.58, 1). x(u) is strictly
|
||||
// monotonic on [0, 1], so a fixed-depth bisection inverts time → u and
|
||||
// the eased progress is y(u). 20 iterations ≈ 1e-6 — and a frame does
|
||||
// ~2 solves per ring, noise next to the raster cost of the frame.
|
||||
function easeOut(t: number): number {
|
||||
if (t <= 0) return 0;
|
||||
if (t >= 1) return 1;
|
||||
let lo = 0;
|
||||
let hi = 1;
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const u = (lo + hi) / 2;
|
||||
const x = 3 * (1 - u) * u * u * 0.58 + u * u * u;
|
||||
if (x < t) lo = u;
|
||||
else hi = u;
|
||||
}
|
||||
const u = (lo + hi) / 2;
|
||||
return 3 * (1 - u) * u * u + u * u * u;
|
||||
}
|
||||
|
||||
// The approved opacity keyframes. Per CSS keyframe semantics the
|
||||
// timing function applies PER SEGMENT (between consecutive keyframes
|
||||
// that specify the property), so each span below gets its own ease-out
|
||||
// of the local progress — exactly what the old `ringKeyframes` did.
|
||||
const OPACITY_STOPS: ReadonlyArray<readonly [number, number]> = [
|
||||
[0, 0],
|
||||
[0.1, 0.26],
|
||||
[0.65, 0.13],
|
||||
[1, 0],
|
||||
];
|
||||
function ringAlpha(phase: number): number {
|
||||
for (let i = 1; i < OPACITY_STOPS.length; i += 1) {
|
||||
const [k1, a1] = OPACITY_STOPS[i];
|
||||
if (phase <= k1) {
|
||||
const [k0, a0] = OPACITY_STOPS[i - 1];
|
||||
return a0 + (a1 - a0) * easeOut((phase - k0) / (k1 - k0));
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One-shot echo ring spawned by a mascot tap. `startedAt` is a
|
||||
// performance.now() timestamp — the same timebase as the rAF clock the
|
||||
// field draws from, so an echo's phase is absolute: a host remount
|
||||
// mid-flight RESUMES the echo where it was instead of replaying it
|
||||
// (the old CSS-animation circles restarted from 0 on remount; resuming
|
||||
// is the closer match to «one pass per tap»).
|
||||
export type EchoPulse = { id: number; startedAt: number };
|
||||
|
||||
// Tap-to-poke echo state. Each `spawnEcho` call appends a one-shot
|
||||
// ring; a timer prunes it once its pulse has faded. Hosted by the
|
||||
// component that renders the `RadarPulse` (StreamHeader, or
|
||||
// PagerRefreshSingleton in pager mode) so the echoes live exactly as
|
||||
// long as their host's reveal window.
|
||||
export function useEchoes(): { echoes: EchoPulse[]; spawnEcho: () => void } {
|
||||
const [echoes, setEchoes] = useState<EchoPulse[]>([]);
|
||||
const echoIdRef = useRef(0);
|
||||
const spawnEcho = useCallback(() => {
|
||||
const id = echoIdRef.current;
|
||||
echoIdRef.current += 1;
|
||||
setEchoes((prev) => [...prev, { id, startedAt: performance.now() }]);
|
||||
window.setTimeout(() => setEchoes((prev) => prev.filter((x) => x.id !== id)), ECHO_LIFETIME_MS);
|
||||
}, []);
|
||||
return { echoes, spawnEcho };
|
||||
}
|
||||
|
||||
type RadarPulseProps = {
|
||||
// One-shot echo ring ids + spawn timestamps (see `useEchoes`). Each
|
||||
// renders a single non-looping pulse identical to an ambient ring.
|
||||
echoes: EchoPulse[];
|
||||
};
|
||||
|
||||
// The radar-pulse ring field — pink concentric rings rippling out from
|
||||
// the mascot centre. ONE transparent 2D <canvas>, fully redrawn every
|
||||
// rAF (10 stroked arcs + echoes).
|
||||
//
|
||||
// WHY A CANVAS (tile-memory postmortem, Jun 2026 — device-measured and
|
||||
// verified against Chromium sources at tags 120/130/137):
|
||||
//
|
||||
// The previous live CSS-animated SVG circles blew the WebView tile
|
||||
// budget. A running compositor animation is a DIRECT compositing
|
||||
// reason on the animated element itself — SVG children included
|
||||
// (`compositing_reason_finder.cc::DirectReasonsForSVGChildPaint
|
||||
// Properties`, kActiveTransformAnimation; opacity keyframes alike via
|
||||
// kActiveOpacityAnimation; on by default since M89). So EACH <circle>
|
||||
// got its own cc layer, rastered at the animation's peak to-screen
|
||||
// scale (scale 1 × dpr 3 ⇒ 2286² device px each; `picture_layer_impl
|
||||
// .cc::AdjustRasterScaleForTransformAnimation` — and the viewport-area
|
||||
// raster clamp sits at max(vw,vh)² = 2340², which 2286² legally fits
|
||||
// under). cc's solid-colour analysis cannot cull a single tile of such
|
||||
// a layer: the analyzer bails on ANY DrawOval/DrawPath op it sees
|
||||
// (`solid_color_analyzer.cc`), and the big ring's op bbox covers the
|
||||
// whole box — so all on-screen tiles (544×608×4B GPU-raster tiles on
|
||||
// this device, NOT 256²) held real textures: ≈10–11 MB × 10 rings ≈
|
||||
// +110 MB against WebView's budget of exactly 20 × 4B × viewport px
|
||||
// rounded up to 5 MiB ≈ 204.5 MB (`browser_view_renderer.cc`).
|
||||
// Shuttle-swiping with 1–3 curtains parked at the refresh snap then
|
||||
// evicted CURTAIN tiles first («tile memory limits exceeded» ×100–600
|
||||
// per 14s session; curtains flashed backdrop, rings showed through).
|
||||
//
|
||||
// The canvas collapses all of that to ONE cc TextureLayer: its buffers
|
||||
// (~7 MB × 2–3 in flight, copy-on-write SharedImages) are client
|
||||
// resources that NEVER enter the tile-manager budget, and the GL total
|
||||
// is ~5–7× below the SVG field. Researched-and-rejected alternatives:
|
||||
// animating `r`/`stroke-width`/`stroke-opacity` (keeps one layer but
|
||||
// re-rasters the field's ~20 tiles every frame on the pending tree —
|
||||
// 37–53 MB while animating); inflating the box to trip cc's raster
|
||||
// clamp (mathematically can't help — the clamp scales DOWN onto the
|
||||
// 2340² threshold, ≥ today's raster, and any will-change ancestor
|
||||
// restores native scale outright); `animation-play-state: paused` and
|
||||
// pre-baked video (owner-vetoed on device). The svg root's own
|
||||
// translateZ(0) was always irrelevant — promotion was per-circle.
|
||||
//
|
||||
// CONTRACT — every rule below guards a verified failure mode:
|
||||
// • context options exactly `{alpha: true, willReadFrequently:
|
||||
// false}`; NEVER add `desynchronized: true` — on WebView it swaps
|
||||
// the TextureLayer for a SurfaceLayerBridge whose surface may
|
||||
// never activate (the historical «opaque white canvas over
|
||||
// everything»), and WebView has no low-latency overlay path anyway
|
||||
// (kWebViewSurfaceControl off).
|
||||
// • never call getImageData/putImageData/toDataURL on this canvas —
|
||||
// two readbacks silently demote it to CPU raster permanently.
|
||||
// • the backing store is device-pixel exact (css size × dpr), set
|
||||
// only from the ResizeObserver — a CSS-px backing would GPU-
|
||||
// upscale 3× and visibly blur the 2px strokes.
|
||||
// • the rAF loop lives on the main thread: a long route-commit task
|
||||
// freezes the field for the task's duration, then it catches up to
|
||||
// wall clock (rings advance ~2.5% of their loop per 100ms, so
|
||||
// commit-length stalls read as continuous; the multi-second
|
||||
// freezes the owner vetoed came from `animation-play-state:
|
||||
// paused`, a different class). If stalls ever grow past ~150ms,
|
||||
// the escalation path is moving THIS SAME draw loop into a worker
|
||||
// via OffscreenCanvas — not back to DOM animations.
|
||||
//
|
||||
// Geometry contract lives on `stagePulseCanvas` (StreamHeader.css.ts):
|
||||
// the canvas spans the host's width from the screen top down to the
|
||||
// field's bottom edge, and the draw centre is recovered from the box
|
||||
// itself — cx = width/2, cy = height − (PULSE_SPAN_PX/2 +
|
||||
// PULSE_EDGE_PX) ⇒ exactly safe-top + PULSE_CENTER_Y_PX, the same
|
||||
// centre the old svg box had in both hosts.
|
||||
export function RadarPulse({ echoes }: RadarPulseProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Latest echoes for the draw loop without restarting it — the loop
|
||||
// effect deliberately has no deps.
|
||||
const echoesRef = useRef<EchoPulse[]>(echoes);
|
||||
echoesRef.current = echoes;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
const ctx = canvas.getContext('2d', { alpha: true, willReadFrequently: false });
|
||||
if (!ctx) return undefined;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
let cssWidth = 0;
|
||||
let cssHeight = 0;
|
||||
const resize = () => {
|
||||
const { clientWidth, clientHeight } = canvas;
|
||||
if (!clientWidth || !clientHeight) return;
|
||||
cssWidth = clientWidth;
|
||||
cssHeight = clientHeight;
|
||||
canvas.width = Math.round(clientWidth * dpr);
|
||||
canvas.height = Math.round(clientHeight * dpr);
|
||||
// Resizing resets all context state — restore the css-px
|
||||
// coordinate space and the stroke colour.
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.strokeStyle = STROKE_STYLE;
|
||||
};
|
||||
// Sized once synchronously (the host mounts this visible, never
|
||||
// display:none) + re-sized on rotation/viewport changes.
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(canvas);
|
||||
|
||||
const drawRing = (cx: number, cy: number, phase: number) => {
|
||||
const s = MIN_SCALE + (1 - MIN_SCALE) * easeOut(phase);
|
||||
ctx.globalAlpha = ringAlpha(phase);
|
||||
ctx.lineWidth = STROKE_PX * s;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, RING_RADIUS_PX * s, 0, Math.PI * 2);
|
||||
// A full-sweep arc() is an OPEN contour in Blink (two 180° arcs
|
||||
// with coincident butt caps) — closePath() joins it so the cap
|
||||
// edges can't double-blend into a seam at the start angle.
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
let raf = 0;
|
||||
let mountTs: number | null = null;
|
||||
const frame = (now: number) => {
|
||||
raf = window.requestAnimationFrame(frame);
|
||||
if (!cssWidth || !cssHeight) return;
|
||||
// Ambient ring k starts STAGGER_MS·k after the first frame, then
|
||||
// loops — the same field the old per-circle animation-delays
|
||||
// produced on mount.
|
||||
if (mountTs === null) mountTs = now;
|
||||
const t = now - mountTs;
|
||||
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
||||
const cx = cssWidth / 2;
|
||||
const cy = cssHeight - (RING_RADIUS_PX + PULSE_EDGE_PX);
|
||||
for (let k = 0; k < RING_COUNT; k += 1) {
|
||||
const local = t - k * STAGGER_MS;
|
||||
if (local >= 0) drawRing(cx, cy, (local % LOOP_MS) / LOOP_MS);
|
||||
}
|
||||
echoesRef.current.forEach(({ startedAt }) => {
|
||||
const local = now - startedAt;
|
||||
if (local >= 0 && local < LOOP_MS) drawRing(cx, cy, local / LOOP_MS);
|
||||
});
|
||||
};
|
||||
raf = window.requestAnimationFrame(frame);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <canvas ref={canvasRef} className={css.stagePulseCanvas} aria-hidden="true" />;
|
||||
}
|
||||
|
|
@ -41,6 +41,12 @@ type RefreshMascotProps = {
|
|||
// caption — the animation is the whole affordance (Claude-mobile-app
|
||||
// style).
|
||||
//
|
||||
// Two hosts: the in-stage header slot on NON-pager native surfaces, and
|
||||
// the strip-hosted PagerRefreshSingleton in pager mode, where the card
|
||||
// stays put while the panes slide and `onPoke` is omitted (the singleton
|
||||
// is pointer-events-none; pokes arrive via the per-pane tap-catcher
|
||||
// through `mobilePagerMascotApiAtom`).
|
||||
//
|
||||
// We render a plain `<video>` — NOT the auth `mascotSingleton`
|
||||
// currentTime-preservation machinery, which solves a cold-boot remount
|
||||
// problem this surface doesn't have; the two mascot surfaces stay
|
||||
|
|
@ -80,9 +86,10 @@ export function RefreshMascot({ caption, revealing, videoRef, onPoke }: RefreshM
|
|||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
onClick={onPoke}
|
||||
>
|
||||
{/* The radar-pulse rings live at the header level (StreamHeader), not
|
||||
here — they break out of this «formal» card and ripple across the
|
||||
whole light-blue chrome. This card holds just the mascot + caption. */}
|
||||
{/* The radar-pulse rings live at the host level (StreamHeader's stage
|
||||
layer, or the strip-hosted singleton), not here — they break out of
|
||||
this «formal» card and ripple across the whole chrome. This card
|
||||
holds just the mascot + caption. */}
|
||||
{showVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { recipe } from '@vanilla-extract/recipes';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import {
|
||||
|
|
@ -8,6 +8,9 @@ import {
|
|||
HANDLE_HEIGHT_PX,
|
||||
MASCOT_CARD_PX,
|
||||
MASCOT_VIDEO_PX,
|
||||
PULSE_CENTER_Y_PX,
|
||||
PULSE_EDGE_PX,
|
||||
PULSE_SPAN_PX,
|
||||
TABS_ROW_PX,
|
||||
WEB_TABS_ROW_PX,
|
||||
} from './geometry';
|
||||
|
|
@ -159,7 +162,13 @@ export const curtain = style({
|
|||
borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX),
|
||||
borderTopRightRadius: toRem(CURTAIN_RADIUS_PX),
|
||||
transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`,
|
||||
// Hint the compositor while the curtain is moving.
|
||||
// NB: `top` is a LAYOUT property — its animation runs on the main
|
||||
// thread regardless of this hint (will-change of a non-compositable
|
||||
// property does not promote a layer). Kept as a mild
|
||||
// style-invalidation hint; the curtain being layout-driven (not
|
||||
// transform-driven) is itself deliberate — `bottomPinned` must stay
|
||||
// glued to the viewport bottom while the top edge moves, which a
|
||||
// translateY of the whole curtain would break.
|
||||
willChange: 'top',
|
||||
selectors: {
|
||||
'[data-platform="web"] &': {
|
||||
|
|
@ -300,6 +309,15 @@ export const segmentDot = recipe({
|
|||
// height equals `MASCOT_CARD_PX`, which the curtain's refresh rest-top
|
||||
// (`TABS_ROW_PX + MASCOT_CARD_PX`) lands exactly on — so the card edge
|
||||
// sits at the curtain's rounded top with the content centred above it.
|
||||
// Own opaque backdrop so the refresh reveal stays solid in pager mode,
|
||||
// where the `header` bg collapses to transparent (the transparent
|
||||
// mascot video would otherwise show the static pager header through it).
|
||||
//
|
||||
// Two hosts: the in-stage header slot on NON-pager native surfaces, and
|
||||
// the strip-hosted PagerRefreshSingleton in pager mode
|
||||
// (mobile-tabs-pager/style.css.ts::pagerMascotSlot positions it at the
|
||||
// same screen Y), where it stays put while the panes slide. The opaque
|
||||
// SurfaceVariant backdrop is correct in both.
|
||||
export const mascotCard = style({
|
||||
flexShrink: 0,
|
||||
height: toRem(MASCOT_CARD_PX),
|
||||
|
|
@ -308,32 +326,56 @@ export const mascotCard = style({
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: toRem(2),
|
||||
// Own opaque backdrop so the refresh reveal stays solid in pager mode,
|
||||
// where the `header` bg collapses to transparent (the transparent
|
||||
// mascot video would otherwise show the static pager header through it).
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
});
|
||||
|
||||
// Invisible stand-in for the mascot card in PAGER mode. The visible
|
||||
// mascot lives in the strip-hosted singleton (PagerRefreshSingleton),
|
||||
// but that singleton is `pointer-events: none` behind the panes'
|
||||
// hit-test stack, so the tap-to-poke easter egg would die on the pane's
|
||||
// transparent header band. This catcher occupies the exact slot the
|
||||
// in-stage card would (same flex position + height) and proxies taps to
|
||||
// the singleton via `mobilePagerMascotApiAtom`. At the closed snap it's
|
||||
// z-covered by the curtain exactly like the card, so it's only
|
||||
// reachable while the refresh band is actually revealed.
|
||||
export const mascotPokeCatcher = style({
|
||||
flexShrink: 0,
|
||||
height: toRem(MASCOT_CARD_PX),
|
||||
});
|
||||
|
||||
// Radar-pulse overlay — pink concentric rings rippling across the light-blue
|
||||
// surface behind the curtain. Rendered as ONE <svg> of 10 <circle>s, NOT 10
|
||||
// CSS-animated <span>s: each animated span is promoted to its own GPU layer,
|
||||
// and ten such layers blow Chromium's `cc` tile-memory budget on Android
|
||||
// WebView ("tile memory limits exceeded, some content may not draw") → the
|
||||
// curtain flickers (device logcat: that warning fired 572× only while the
|
||||
// rings animated). Chrome does NOT split an SVG into per-element GPU layers,
|
||||
// so all ten rings share ONE layer and the tile budget holds.
|
||||
// surface behind the curtain. Rendered as ONE transparent 2D <canvas>
|
||||
// (RadarPulse.tsx) — NOT CSS-animated DOM rings. History of this surface,
|
||||
// each step device-measured: 10 animated <span>s blew the WebView tile
|
||||
// budget (572 «tile memory limits exceeded» warnings); one <svg> of 10
|
||||
// animated <circle>s did NOT fix it — a running transform/opacity animation
|
||||
// is a direct compositing reason on each SVG CHILD, so every circle still
|
||||
// got its own cc layer rastered at peak scale (~110 MB; full corrected
|
||||
// root-cause in RadarPulse.tsx). The canvas is one untiled TextureLayer
|
||||
// whose buffers never touch the tile budget at all.
|
||||
//
|
||||
// (A <canvas> also collapses to one layer and likewise zeroed the tile
|
||||
// warning — but this WebView composites a full-screen, every-frame 2D canvas
|
||||
// as an OPAQUE surface: solid white over the mascot. SVG paints in the normal
|
||||
// tree like the old spans did, so it stays transparent.)
|
||||
// (The historical «full-screen 2D canvas composites as an OPAQUE WHITE
|
||||
// surface» incident that long banned canvases here is explained and
|
||||
// avoided: that's the `desynchronized: true` / SurfaceLayerBridge path.
|
||||
// A plain `{alpha: true}` canvas composites as a normal transparent
|
||||
// TextureLayer in the page's paint order — see the context contract in
|
||||
// RadarPulse.tsx.)
|
||||
//
|
||||
// This clip layer spans the visible surface — up into the safe-top (negative
|
||||
// `top`) and down to the stage bottom — and clips the rings at the status-bar
|
||||
// edge. `zIndex: 1` is over the header chrome but under the curtain
|
||||
// (`zIndex: 2`), which occludes whatever's below its top edge. `pointerEvents:
|
||||
// none` lets taps fall through. Rendered only while revealed + animated
|
||||
// (StreamHeader `showPulse`).
|
||||
// This layer spans the visible surface — up into the safe-top (negative
|
||||
// `top`, so its origin is the SCREEN top like the pager singleton host's)
|
||||
// and down to the stage bottom. `zIndex: 1` is over the header chrome but
|
||||
// under the curtain (`zIndex: 2`), which occludes whatever's below its top
|
||||
// edge. `pointerEvents: none` lets taps fall through. Rendered only while
|
||||
// revealed + animated (StreamHeader `showPulse`).
|
||||
//
|
||||
// Rendered only on NON-pager native surfaces (StreamHeader gates
|
||||
// `showPulse` on `!inPagerMode`); in pager mode the rings render inside
|
||||
// the strip-hosted PagerRefreshSingleton instead — see
|
||||
// mobile-tabs-pager/style.css.ts::pagerRefreshSingleton for the full
|
||||
// hosting / tile-memory contract. NB the `-safe-top` top offset here is
|
||||
// STAGE-relative (the stage starts at y = safe-top) and must NOT be
|
||||
// reused for the singleton host, whose y-origin is already the screen
|
||||
// top.
|
||||
export const stagePulseLayer = style({
|
||||
position: 'absolute',
|
||||
top: 'calc(-1 * var(--vojo-safe-top, 0px))',
|
||||
|
|
@ -345,62 +387,29 @@ export const stagePulseLayer = style({
|
|||
zIndex: 1,
|
||||
});
|
||||
|
||||
// One ring's scale + fade — the old span keyframes, byte-for-byte: scale
|
||||
// 0.05→1 (the 2px stroke scales WITH it → 0.1px when tiny, a crisp 2px at
|
||||
// full size — the approved look), opacity 0 → 0.26 → 0.13 → 0.
|
||||
const ringKeyframes = keyframes({
|
||||
'0%': { transform: 'scale(0.05)', opacity: 0 },
|
||||
'10%': { opacity: 0.26 },
|
||||
'65%': { opacity: 0.13 },
|
||||
'100%': { transform: 'scale(1)', opacity: 0 },
|
||||
});
|
||||
|
||||
// The 760² ring SVG, centred on ≈ the mascot centre (safe-top + tabs row +
|
||||
// the card's top pad + half the video box). `translateZ(0)` keeps the rings'
|
||||
// per-frame raster isolated to this one small layer rather than repainting
|
||||
// the whole stage; `overflow: visible` so the full-scale stroke isn't clipped.
|
||||
export const stagePulseSvg = style({
|
||||
// The ring-field canvas. Spans the host's full width from the screen top
|
||||
// down to the field's bottom edge: safe-top + PULSE_CENTER_Y_PX (ring
|
||||
// centre) + PULSE_SPAN_PX/2 (largest ring) + PULSE_EDGE_PX (stroke + AA
|
||||
// headroom). RadarPulse recovers the draw centre from the box itself —
|
||||
// cx = width/2, cy = height − (PULSE_SPAN_PX/2 + PULSE_EDGE_PX) — which
|
||||
// lands at exactly safe-top + PULSE_CENTER_Y_PX from the screen top, the
|
||||
// same centre the old 760² svg box had. Both hosting layers
|
||||
// (`stagePulseLayer` here, the pager singleton host) start at the top of
|
||||
// the SCREEN, so this one safe-top-relative height works in either.
|
||||
// Rings wider than the screen simply draw past the canvas edges — the
|
||||
// clipping the old svg needed its host's overflow:hidden for is now
|
||||
// inherent to the backing store. Keep the canvas VIEWPORT-width (not
|
||||
// 760px wide): the backing buffers are ~7 MB × 2–3 in flight at dpr 3,
|
||||
// and width is the lever that keeps them there.
|
||||
export const stagePulseCanvas = style({
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: `calc(var(--vojo-safe-top, 0px) + ${toRem(TABS_ROW_PX + 53)})`,
|
||||
width: toRem(760),
|
||||
height: toRem(760),
|
||||
overflow: 'visible',
|
||||
transform: 'translate(-50%, -50%) translateZ(0)',
|
||||
});
|
||||
|
||||
// Each concentric ring. `transformBox: fill-box` + `transformOrigin: center`
|
||||
// so `scale()` grows it about its own centre. NO `non-scaling-stroke` — the
|
||||
// stroke scales with the circle, matching the approved span look.
|
||||
export const stagePulseRing = style({
|
||||
fill: 'none',
|
||||
stroke: 'rgb(248, 80, 160)',
|
||||
strokeWidth: 2,
|
||||
transformBox: 'fill-box',
|
||||
transformOrigin: 'center',
|
||||
opacity: 0,
|
||||
animationName: ringKeyframes,
|
||||
animationDuration: '4s',
|
||||
animationTimingFunction: 'ease-out',
|
||||
animationIterationCount: 'infinite',
|
||||
});
|
||||
|
||||
// One-shot "echo" ring spawned when the user taps the mascot — IDENTICAL to an
|
||||
// ambient ring (same `ringKeyframes`, 4s, same 0.26 peak), just a single pulse
|
||||
// instead of looping. StreamHeader adds one <circle> per tap to `echoes` and
|
||||
// drops it from the DOM by a timer once it's faded.
|
||||
export const stagePulseEcho = style({
|
||||
fill: 'none',
|
||||
stroke: 'rgb(248, 80, 160)',
|
||||
strokeWidth: 2,
|
||||
transformBox: 'fill-box',
|
||||
transformOrigin: 'center',
|
||||
opacity: 0,
|
||||
animationName: ringKeyframes,
|
||||
animationDuration: '4s',
|
||||
animationTimingFunction: 'ease-out',
|
||||
animationIterationCount: 1,
|
||||
animationFillMode: 'forwards',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `calc(var(--vojo-safe-top, 0px) + ${toRem(
|
||||
PULSE_CENTER_Y_PX + PULSE_SPAN_PX / 2 + PULSE_EDGE_PX
|
||||
)})`,
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
// The looping mascot video (or its still poster under reduced-motion),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import React, {
|
|||
TransitionEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
|
|
@ -18,10 +19,13 @@ import { isNativePlatform } from '../../utils/capacitor';
|
|||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useBotPresets } from '../../features/bots/catalog';
|
||||
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import type { MobilePagerTab } from '../mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import {
|
||||
MobilePagerCurtainControls,
|
||||
StreamHeaderPrimaryAction,
|
||||
curtainRefreshRevealByTabAtom,
|
||||
mobilePagerCurtainAtom,
|
||||
mobilePagerMascotApiAtom,
|
||||
} from '../../state/mobilePagerHeader';
|
||||
import { REFRESH_MAX_MS, REFRESH_MIN_MS, TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry';
|
||||
import { settingsSheetAtom } from '../../state/settingsSheet';
|
||||
|
|
@ -29,12 +33,16 @@ import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
|
|||
import * as css from './StreamHeader.css';
|
||||
import { Segment } from './Segment';
|
||||
import { RefreshMascot } from './RefreshMascot';
|
||||
import { RadarPulse, useEchoes } from './RadarPulse';
|
||||
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';
|
||||
// Exported so the pager's static header can `aria-controls` the same
|
||||
// form region (MobileTabsPagerHeader imports this instead of keeping a
|
||||
// hand-synced copy of the literal).
|
||||
export const INLINE_FORM_ID = 'stream-header-inline-form';
|
||||
|
||||
type StreamHeaderProps = {
|
||||
// Scroll viewport that hosts the chat list under the curtain. The
|
||||
|
|
@ -63,7 +71,11 @@ type StreamHeaderProps = {
|
|||
// 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;
|
||||
//
|
||||
// Typed as the pager tab name: the pin/reveal maps keyed by this are
|
||||
// read back by MobileTabsPager(+Header) via the pager's own tab
|
||||
// union, so the names must coincide — compiler-enforced.
|
||||
pinKey: MobilePagerTab;
|
||||
// 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
|
||||
|
|
@ -207,20 +219,33 @@ export function StreamHeader({
|
|||
});
|
||||
|
||||
const isActive = isFormSnap(curtain.snap);
|
||||
const openSearch = useCallback(() => curtain.open(), [curtain]);
|
||||
const { close } = curtain;
|
||||
// Destructured because these callbacks are individually STABLE
|
||||
// (useCallback inside useCurtainState) while the `curtain` object
|
||||
// changes identity on every drag frame (`liveDragPx` is in its memo
|
||||
// deps). Depending on `curtain` itself here used to rebuild
|
||||
// `pagerControls` — and therefore rewrite `mobilePagerCurtainAtom`
|
||||
// (cleanup null + setup) — on every touchmove of a curtain drag,
|
||||
// re-rendering the static pager header at input frequency. The
|
||||
// memoisation only works when these flow through stable references.
|
||||
const { open: openSearch, close, acknowledgeClosed } = 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`).
|
||||
// Refresh-reveal gate — the mascot card is on screen (or being pulled
|
||||
// into view): the curtain rests at the `refresh` snap OR 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,
|
||||
// `liveDragPx > 0` term that flipped the pulse 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 revealing = isRefreshSnap(curtain.snap) || curtain.isDragging;
|
||||
|
||||
// Radar-pulse overlay gate for the IN-STAGE layer. Native-only (the
|
||||
// gesture is), silent under reduced-motion — and NON-pager only: in
|
||||
// pager mode the rings live in the strip-hosted PagerRefreshSingleton,
|
||||
// fed by the reveal map published below.
|
||||
const reducedMotion = useMemo(
|
||||
() =>
|
||||
typeof window !== 'undefined' &&
|
||||
|
|
@ -228,22 +253,54 @@ export function StreamHeader({
|
|||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
[]
|
||||
);
|
||||
const showPulse =
|
||||
isNativePlatform() && !reducedMotion && (isRefreshSnap(curtain.snap) || curtain.isDragging);
|
||||
const showPulse = isNativePlatform() && !reducedMotion && !inPagerMode && revealing;
|
||||
|
||||
// Tap-to-poke echo rings. Tapping the mascot (RefreshMascot `onPoke`) spawns
|
||||
// a one-shot ring (`stagePulseEcho`) on top of the ambient radar pulse —
|
||||
// same look + speed as an ambient ring, just one pass; each is dropped from
|
||||
// the DOM by a timer once its 4s pulse has faded. They only paint while the
|
||||
// pulse layer is mounted (`showPulse`).
|
||||
const [echoes, setEchoes] = useState<number[]>([]);
|
||||
const echoIdRef = useRef(0);
|
||||
const spawnEcho = useCallback(() => {
|
||||
const id = echoIdRef.current;
|
||||
echoIdRef.current += 1;
|
||||
setEchoes((prev) => [...prev, id]);
|
||||
window.setTimeout(() => setEchoes((prev) => prev.filter((x) => x !== id)), 4100);
|
||||
}, []);
|
||||
// Tap-to-poke echo rings (non-pager path). Tapping the mascot
|
||||
// (RefreshMascot `onPoke`) spawns a one-shot ring on top of the ambient
|
||||
// radar pulse — same look + speed as an ambient ring, just one pass
|
||||
// (drawn by the RadarPulse canvas from its spawn timestamp). They only
|
||||
// paint while the pulse layer is mounted (`showPulse`). In pager mode
|
||||
// the echo state lives in `PagerRefreshSingleton` instead, and the
|
||||
// invisible poke catcher below proxies taps to it through
|
||||
// `mobilePagerMascotApiAtom`.
|
||||
const { echoes, spawnEcho } = useEchoes();
|
||||
|
||||
// Imperative channel published by the pager's refresh singleton: the
|
||||
// live dance <video> for the loop-boundary auto-close, and the echo
|
||||
// spawner the invisible poke catcher proxies taps into (the singleton
|
||||
// is pointer-events:none behind the panes and can never receive the
|
||||
// tap itself).
|
||||
const mascotApi = useAtomValue(mobilePagerMascotApiAtom);
|
||||
const onPokeCatcher = useCallback(() => mascotApi?.spawnEcho(), [mascotApi]);
|
||||
|
||||
// Publish this pane's reveal state into the per-tab map (keyed by
|
||||
// `pinKey` — each pane owns its key, writers never race). The
|
||||
// strip-hosted PagerRefreshSingleton ORs the map
|
||||
// (`mobilePagerAnyRevealAtom`) and shows the one screen-static
|
||||
// mascot+rings while ANY tab reveals — an inactive pane resting at
|
||||
// `refresh` through its auto-close dwell keeps the shared mascot
|
||||
// alive so a swipe-back mid-refresh is seamless.
|
||||
//
|
||||
// The `!activeForm` guard: a form-close drag has `isDragging` true
|
||||
// but no band chrome on screen — and the singleton paints behind the
|
||||
// panes' content, where the form band has no opaque backdrop in pager
|
||||
// mode.
|
||||
//
|
||||
// useLayoutEffect so the atom write + singleton render flush in the
|
||||
// same pre-paint commit as the first band-exposing frame at
|
||||
// drag-start. Compare-and-skip keeps subscribers quiet when nothing
|
||||
// flips.
|
||||
const setRefreshRevealByTab = useSetAtom(curtainRefreshRevealByTabAtom);
|
||||
const refreshRevealing = revealing && !curtain.activeForm;
|
||||
useLayoutEffect(() => {
|
||||
if (!inPagerMode) return undefined;
|
||||
setRefreshRevealByTab((prev) =>
|
||||
!!prev[pinKey] === refreshRevealing ? prev : { ...prev, [pinKey]: refreshRevealing }
|
||||
);
|
||||
return () => {
|
||||
setRefreshRevealByTab((prev) => (prev[pinKey] ? { ...prev, [pinKey]: false } : prev));
|
||||
};
|
||||
}, [inPagerMode, pinKey, refreshRevealing, setRefreshRevealByTab]);
|
||||
|
||||
// Pull-to-refresh side-effect. When the curtain commits to the
|
||||
// `refresh` snap, keep the mascot dancing for at least REFRESH_MIN_MS
|
||||
|
|
@ -322,7 +379,11 @@ export function StreamHeader({
|
|||
const refreshDragging = curtain.isDragging;
|
||||
useEffect(() => {
|
||||
if (!(refreshing && refreshReady && !refreshDragging)) return undefined;
|
||||
const video = mascotVideoRef.current;
|
||||
// In pager mode the dance video lives in the strip-hosted singleton,
|
||||
// reachable through the api atom (null while hidden / poster — close
|
||||
// immediately, matching the non-pager null-ref semantics); the local
|
||||
// ref only hosts a video on non-pager native surfaces.
|
||||
const video = inPagerMode ? mascotApi?.getVideo() ?? null : mascotVideoRef.current;
|
||||
if (!video || video.paused || !Number.isFinite(video.duration) || video.duration <= 0) {
|
||||
close();
|
||||
return undefined;
|
||||
|
|
@ -334,20 +395,24 @@ export function StreamHeader({
|
|||
}
|
||||
const timer = window.setTimeout(close, remainingMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [refreshing, refreshReady, refreshDragging, close]);
|
||||
}, [refreshing, refreshReady, refreshDragging, close, inPagerMode, mascotApi]);
|
||||
|
||||
// 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.
|
||||
// render would never match the atom's current contents. Rewrites only
|
||||
// on real open/close/acknowledge edges — never per drag frame; see
|
||||
// the `formMounted` comment in mobilePagerHeader.ts.
|
||||
const formMounted = curtain.activeForm !== null;
|
||||
const pagerControls = useMemo<MobilePagerCurtainControls>(
|
||||
() => ({
|
||||
openSearch,
|
||||
closeForm: close,
|
||||
isFormActive: isActive,
|
||||
formMounted,
|
||||
primaryAction: primaryAction ?? null,
|
||||
}),
|
||||
[openSearch, close, isActive, primaryAction]
|
||||
[openSearch, close, isActive, formMounted, primaryAction]
|
||||
);
|
||||
|
||||
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
|
||||
|
|
@ -395,9 +460,9 @@ export function StreamHeader({
|
|||
(evt: TransitionEvent<HTMLDivElement>) => {
|
||||
if (evt.target !== evt.currentTarget) return;
|
||||
if (evt.propertyName !== 'top') return;
|
||||
curtain.acknowledgeClosed();
|
||||
acknowledgeClosed();
|
||||
},
|
||||
[curtain]
|
||||
[acknowledgeClosed]
|
||||
);
|
||||
|
||||
// On-screen keyboard detection via VisualViewport API. Global
|
||||
|
|
@ -453,6 +518,28 @@ export function StreamHeader({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Dancing-mascot pull-to-refresh slot below the tabs row whenever no
|
||||
// form is active. The curtain z-occludes it at `closed`; drag-down
|
||||
// reveals it at the `refresh` snap.
|
||||
//
|
||||
// In pager mode the VISIBLE mascot is hoisted to the strip-hosted
|
||||
// singleton (it must not ride this pane), so the slot holds only an
|
||||
// invisible tap-catcher of the same geometry: the singleton is
|
||||
// pointer-events-none, and without the catcher the tap-to-poke easter
|
||||
// egg would die on the pane's transparent band. Non-pager native
|
||||
// surfaces keep the in-stage card.
|
||||
const refreshSlot = inPagerMode ? (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div className={css.mascotPokeCatcher} onClick={onPokeCatcher} aria-hidden="true" />
|
||||
) : (
|
||||
<RefreshMascot
|
||||
caption={t('Direct.refreshing')}
|
||||
revealing={revealing}
|
||||
videoRef={mascotVideoRef}
|
||||
onPoke={spawnEcho}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={css.stage} data-platform={isNativePlatform() ? undefined : 'web'}>
|
||||
<header className={css.header}>
|
||||
|
|
@ -578,48 +665,21 @@ export function StreamHeader({
|
|||
</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}
|
||||
onPoke={spawnEcho}
|
||||
/>
|
||||
refreshSlot
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* ── Radar-pulse overlay ────────────────────────────────
|
||||
{/* ── Radar-pulse overlay (non-pager native surfaces only) ─
|
||||
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`. */}
|
||||
top edge). `pointerEvents: none` so taps fall through to the tabs.
|
||||
See `RadarPulse` for the single-canvas rationale (the tile-memory
|
||||
postmortem) and `stagePulseLayer` for the geometry. In pager mode
|
||||
`showPulse` is always false — the rings render in the strip-hosted
|
||||
PagerRefreshSingleton instead. */}
|
||||
{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` }}
|
||||
/>
|
||||
))}
|
||||
{/* One-shot echo rings, one per mascot tap (see `spawnEcho`). */}
|
||||
{echoes.map((id) => (
|
||||
<circle key={`echo-${id}`} className={css.stagePulseEcho} cx={380} cy={380} r={380} />
|
||||
))}
|
||||
</svg>
|
||||
<RadarPulse echoes={echoes} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,17 +21,21 @@
|
|||
// results clip flush at the curtain's top edge; the
|
||||
// form's own height already reaches the curtain)
|
||||
//
|
||||
// Pinned visual contract: at `pinned` the curtain's top edge lands at
|
||||
// Pinned visual contract: at `pinned` the curtain's top edge RESTS at
|
||||
// y = safe-top in viewport coords (because the stage starts after the
|
||||
// PageNav / appBody padding-top: var(--vojo-safe-top)). The system tray
|
||||
// strip stays painted by appBody / PageNav-inner / MobileTabsPager's
|
||||
// static header — all of which use `SurfaceVariant.Container` for that
|
||||
// zone, so the colour is continuous across surfaces. The curtain MUST
|
||||
// NOT extend into the safe-top zone (otherwise system text is covered)
|
||||
// NOT REST inside the safe-top zone (otherwise system text is covered)
|
||||
// and MUST NOT add internal padding-top (otherwise the chat list grows
|
||||
// visually taller). The clamp on the up-drag (= -TABS_ROW_PX) enforces
|
||||
// the first invariant; we deliberately do not add any padding inside
|
||||
// the curtain to enforce the second.
|
||||
// visually taller). This is a REST-state invariant: every commit
|
||||
// destination (pinned = 0) honours it, while a live `closed-free` /
|
||||
// `close-refresh` drag intentionally has NO upper clamp and may
|
||||
// overshoot into the safe-top under the finger (the «drag up
|
||||
// off-screen freely» feel) — the release always settles back to a
|
||||
// legal snap. We deliberately do not add any padding inside the
|
||||
// curtain to enforce the second half.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Tabs row height. Always visible above the curtain.
|
||||
|
|
@ -73,6 +77,50 @@ export const WEB_TABS_ROW_PX = 54;
|
|||
export const MASCOT_VIDEO_PX = 96;
|
||||
export const MASCOT_CARD_PX = 124;
|
||||
|
||||
// Radar-pulse ring field — the square box (px) the concentric rings
|
||||
// expand inside, centred on the mascot. 760 is the APPROVED look —
|
||||
// byte-for-byte the original ring field — and is load-bearing twice
|
||||
// over. DO NOT GROW IT:
|
||||
//
|
||||
// (1) Speed/feel: a ring scales 0.05 → 1 over a fixed 4s, so the box
|
||||
// size IS the travel speed. An attempt to widen the sweep to
|
||||
// 1600 made the rings (and especially the tap-echo rings)
|
||||
// visibly «вылетать» ~2× faster than the approved pace —
|
||||
// user-rejected on device.
|
||||
// (2) Memory: the field is one transparent 2D canvas (RadarPulse),
|
||||
// and the box's bottom edge sets the canvas height — the GPU
|
||||
// buffers (~7 MB × 2–3 at dpr 3) scale with it. The WebView
|
||||
// tile-memory budget around this feature has NO headroom — an
|
||||
// attempted hoist of the ring field out of the strip blew it
|
||||
// even at 760² (see the `pagerRefreshSingleton` postmortem in
|
||||
// mobile-tabs-pager/style.css.ts); a bigger box only digs the
|
||||
// hole deeper.
|
||||
//
|
||||
// «Rings across the whole screen / mascot doesn't ride the tab» is
|
||||
// achieved by the HOST instead: in pager mode the chrome renders ONCE
|
||||
// in a strip-hosted singleton (PagerRefreshSingleton) counter-
|
||||
// translated against the strip's live translation (inline `counterTx`
|
||||
// prop, compositor-transitioned in lockstep with the strip), so it
|
||||
// stays screen-static during tab swipes and the rings spill across the
|
||||
// inter-pane seam — while the field keeps the approved size and pace,
|
||||
// and stays inside the strip where the tile budget is proven to hold.
|
||||
export const PULSE_SPAN_PX = 760;
|
||||
|
||||
// Vertical centre of the radar pulse, measured from the TOP OF THE
|
||||
// SCREEN minus the safe-top inset — i.e. the canvas height formula adds
|
||||
// `var(--vojo-safe-top)` itself. = tabs row + the mascot card's top
|
||||
// pad + half the video box, so the rings emanate from the mascot's
|
||||
// centre. Encoded in `stagePulseCanvas`'s height (StreamHeader.css.ts);
|
||||
// RadarPulse recovers the draw centre as
|
||||
// height − (PULSE_SPAN_PX/2 + PULSE_EDGE_PX). Both hosts' layers start
|
||||
// at the screen top, so one formula serves either.
|
||||
export const PULSE_CENTER_Y_PX = TABS_ROW_PX + 53;
|
||||
|
||||
// Canvas headroom (px) below the largest ring's outer edge: half the
|
||||
// full-scale 2px stroke plus 1px of anti-aliasing, so the ring never
|
||||
// clips at the canvas's bottom edge.
|
||||
export const PULSE_EDGE_PX = 2;
|
||||
|
||||
// Initial estimate for the search form's outer height. The actual
|
||||
// height is measured at runtime via ResizeObserver and adapts to the
|
||||
// available viewport so the form never overflows the chats card.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
|
||||
import type { MobilePagerTab } from '../mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import { CURTAIN_SNAP_MS, MASCOT_CARD_PX, SEARCH_FORM_BASE_PX, TABS_ROW_PX } from './geometry';
|
||||
|
||||
// Discrete snap stops for the curtain. The curtain's resting `top`
|
||||
|
|
@ -100,7 +101,11 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
|
|||
}
|
||||
}
|
||||
|
||||
export function useCurtainState(pinKey: string): CurtainState {
|
||||
// `pinKey` is typed as the pager tab name (not a free-form string):
|
||||
// the pin and reveal maps it keys into are read back by the pager and
|
||||
// its static header via `[activeTab]` / `[tab]`, so the coupling is a
|
||||
// hard contract, compiler-enforced since Jun 2026.
|
||||
export function useCurtainState(pinKey: MobilePagerTab): CurtainState {
|
||||
const [snap, setSnap] = useState<CurtainSnap>('closed');
|
||||
const [activeForm, setActiveForm] = useState<ActiveForm>(null);
|
||||
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
|
||||
|
|
|
|||
|
|
@ -33,12 +33,13 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
|
|||
// pager's static header, giving the system-tray text a
|
||||
// consistent backdrop across surfaces.
|
||||
//
|
||||
// Note: the curtain itself NEVER paints into the safe-top zone — the
|
||||
// pin gesture's hard clamp at `-PIN_TRAVEL_PX = -TABS_ROW_PX` stops
|
||||
// the curtain at `top: 0` of the stage (= `y = safe-top` in viewport),
|
||||
// preserving the system-tray strip. See
|
||||
// `components/stream-header/geometry.ts::PIN_TRAVEL_PX` and the
|
||||
// «pinned visual contract» comment for the full invariant.
|
||||
// Note: the curtain never RESTS inside the safe-top zone — every snap
|
||||
// destination floors at `top: 0` of the stage (= `y = safe-top` in
|
||||
// viewport), preserving the system-tray strip. (A live drag may
|
||||
// transiently overshoot above it under the finger — the free-range
|
||||
// gestures are unclamped by design — but the release always settles
|
||||
// back to a legal snap.) See the «pinned visual contract» comment in
|
||||
// `components/stream-header/geometry.ts` for the full invariant.
|
||||
export const container = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
|
|
|
|||
|
|
@ -359,6 +359,16 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
setDrag(null);
|
||||
};
|
||||
|
||||
// System cancel (incoming call, notification shade, app switch) must
|
||||
// NEVER commit an open/close — mirror the never-commit-on-cancel
|
||||
// contract every other gesture driver in the app upholds (curtain
|
||||
// handle/body, pager, swipe-back). Drop the drag; the silhouette
|
||||
// springs back to its resting state.
|
||||
const applyCancel = () => {
|
||||
if (!dragRef.current) return;
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
const targetIsDragOrigin = (target: EventTarget | null): boolean => {
|
||||
if (!target || !(target instanceof Element)) return false;
|
||||
return target.closest(DRAG_ORIGIN_SELECTOR) !== null;
|
||||
|
|
@ -397,6 +407,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
const onTouchMove = (e: TouchEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.inputType !== 'touch') return;
|
||||
// Second finger mid-gesture — abort without committing, mirroring
|
||||
// the curtain/pager drivers.
|
||||
if (e.touches.length !== 1) {
|
||||
applyCancel();
|
||||
return;
|
||||
}
|
||||
applyMove(e.touches[0].clientX, e.touches[0].clientY, e);
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
|
|
@ -404,6 +420,11 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
if (!d || d.inputType !== 'touch') return;
|
||||
applyEnd();
|
||||
};
|
||||
const onTouchCancel = () => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.inputType !== 'touch') return;
|
||||
applyCancel();
|
||||
};
|
||||
|
||||
// === Mouse / pen path ===
|
||||
const onDocPointerDown = (e: PointerEvent) => {
|
||||
|
|
@ -449,15 +470,21 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
if (!d || d.inputType !== 'pointer') return;
|
||||
applyEnd();
|
||||
};
|
||||
const onDocPointerCancel = (e: PointerEvent) => {
|
||||
if (e.pointerType === 'touch') return;
|
||||
const d = dragRef.current;
|
||||
if (!d || d.inputType !== 'pointer') return;
|
||||
applyCancel();
|
||||
};
|
||||
|
||||
document.addEventListener('touchstart', onDocTouchStart, { passive: true });
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||
document.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||
document.addEventListener('pointerdown', onDocPointerDown);
|
||||
document.addEventListener('pointermove', onDocPointerMove, { passive: false });
|
||||
document.addEventListener('pointerup', onDocPointerEnd, { passive: true });
|
||||
document.addEventListener('pointercancel', onDocPointerEnd, { passive: true });
|
||||
document.addEventListener('pointercancel', onDocPointerCancel, { passive: true });
|
||||
if (handleEl) {
|
||||
handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
|
||||
handleEl.addEventListener('pointerdown', onHandlePointerDown);
|
||||
|
|
@ -467,11 +494,11 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
document.removeEventListener('touchstart', onDocTouchStart);
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
document.removeEventListener('touchcancel', onTouchEnd);
|
||||
document.removeEventListener('touchcancel', onTouchCancel);
|
||||
document.removeEventListener('pointerdown', onDocPointerDown);
|
||||
document.removeEventListener('pointermove', onDocPointerMove);
|
||||
document.removeEventListener('pointerup', onDocPointerEnd);
|
||||
document.removeEventListener('pointercancel', onDocPointerEnd);
|
||||
document.removeEventListener('pointercancel', onDocPointerCancel);
|
||||
if (handleEl) {
|
||||
handleEl.removeEventListener('touchstart', onHandleTouchStart);
|
||||
handleEl.removeEventListener('pointerdown', onHandlePointerDown);
|
||||
|
|
@ -555,6 +582,15 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
<div
|
||||
className={css.appBody}
|
||||
style={{
|
||||
// Always emitted, even at rest where it computes to
|
||||
// `inset(0 …)` — see the `appBodyClipPath` comment above for
|
||||
// why (interpolating from `undefined` would snap). Safe to
|
||||
// keep always-on: the pull-to-refresh chrome is hosted in the
|
||||
// strip-level singleton OUTSIDE this subtree, so the resting
|
||||
// self-bounds clip slices nothing. (Bonus: a non-none
|
||||
// clip-path makes appBody a permanent stacking context, which
|
||||
// the singleton's paint-order contract counts on — see
|
||||
// mobile-tabs-pager/style.css.ts::pagerRefreshSingleton.)
|
||||
clipPath: appBodyClipPath,
|
||||
transition: appBodyTransition,
|
||||
overscrollBehaviorY: 'contain',
|
||||
|
|
|
|||
|
|
@ -294,6 +294,16 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
|||
setDrag(null);
|
||||
};
|
||||
|
||||
// System cancel (incoming call, notification shade, app switch) must
|
||||
// NEVER commit an open/close — mirror the never-commit-on-cancel
|
||||
// contract every other gesture driver in the app upholds (curtain
|
||||
// handle/body, pager, swipe-back). Drop the drag; the silhouette
|
||||
// springs back to its resting state.
|
||||
const applyCancel = () => {
|
||||
if (!dragRef.current) return;
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
const targetIsDragOrigin = (target: EventTarget | null): boolean => {
|
||||
if (!target || !(target instanceof Element)) return false;
|
||||
return target.closest(DRAG_ORIGIN_SELECTOR) !== null;
|
||||
|
|
@ -332,6 +342,12 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
|||
const onTouchMove = (e: TouchEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.inputType !== 'touch') return;
|
||||
// Second finger mid-gesture — abort without committing, mirroring
|
||||
// the curtain/pager drivers.
|
||||
if (e.touches.length !== 1) {
|
||||
applyCancel();
|
||||
return;
|
||||
}
|
||||
applyMove(e.touches[0].clientX, e.touches[0].clientY, e);
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
|
|
@ -339,6 +355,11 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
|||
if (!d || d.inputType !== 'touch') return;
|
||||
applyEnd();
|
||||
};
|
||||
const onTouchCancel = () => {
|
||||
const d = dragRef.current;
|
||||
if (!d || d.inputType !== 'touch') return;
|
||||
applyCancel();
|
||||
};
|
||||
|
||||
// === Mouse / pen path ===
|
||||
const onDocPointerDown = (e: PointerEvent) => {
|
||||
|
|
@ -384,15 +405,21 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
|||
if (!d || d.inputType !== 'pointer') return;
|
||||
applyEnd();
|
||||
};
|
||||
const onDocPointerCancel = (e: PointerEvent) => {
|
||||
if (e.pointerType === 'touch') return;
|
||||
const d = dragRef.current;
|
||||
if (!d || d.inputType !== 'pointer') return;
|
||||
applyCancel();
|
||||
};
|
||||
|
||||
document.addEventListener('touchstart', onDocTouchStart, { passive: true });
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||
document.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||
document.addEventListener('pointerdown', onDocPointerDown);
|
||||
document.addEventListener('pointermove', onDocPointerMove, { passive: false });
|
||||
document.addEventListener('pointerup', onDocPointerEnd, { passive: true });
|
||||
document.addEventListener('pointercancel', onDocPointerEnd, { passive: true });
|
||||
document.addEventListener('pointercancel', onDocPointerCancel, { passive: true });
|
||||
if (handleEl) {
|
||||
handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
|
||||
handleEl.addEventListener('pointerdown', onHandlePointerDown);
|
||||
|
|
@ -402,11 +429,11 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
|||
document.removeEventListener('touchstart', onDocTouchStart);
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
document.removeEventListener('touchcancel', onTouchEnd);
|
||||
document.removeEventListener('touchcancel', onTouchCancel);
|
||||
document.removeEventListener('pointerdown', onDocPointerDown);
|
||||
document.removeEventListener('pointermove', onDocPointerMove);
|
||||
document.removeEventListener('pointerup', onDocPointerEnd);
|
||||
document.removeEventListener('pointercancel', onDocPointerEnd);
|
||||
document.removeEventListener('pointercancel', onDocPointerCancel);
|
||||
if (handleEl) {
|
||||
handleEl.removeEventListener('touchstart', onHandleTouchStart);
|
||||
handleEl.removeEventListener('pointerdown', onHandlePointerDown);
|
||||
|
|
@ -470,6 +497,9 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
|||
<div
|
||||
className={css.appBody}
|
||||
style={{
|
||||
// Always emitted, even at rest where it computes to
|
||||
// `inset(0 …)` — see MobileSettingsHorseshoe.tsx for the full
|
||||
// always-emitted / stacking-context rationale.
|
||||
clipPath: appBodyClipPath,
|
||||
transition: appBodyTransition,
|
||||
overscrollBehaviorY: 'contain',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { atom } from 'jotai';
|
||||
import { IconSrc } from 'folds';
|
||||
import type { MobilePagerTab } from '../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||
|
||||
// Per-tab «primary action» published by a pane's StreamHeader. When
|
||||
// present it replaces the default Plus / «new chat» button in the
|
||||
|
|
@ -30,6 +31,16 @@ export type MobilePagerCurtainControls = {
|
|||
openSearch: () => void;
|
||||
closeForm: () => void;
|
||||
isFormActive: boolean;
|
||||
// True while the inline form is MOUNTED (`activeForm !== null`) —
|
||||
// outlives the snap-based `isFormActive` through the 280–480ms close
|
||||
// window until `acknowledgeClosed` / the safety timer clears the form
|
||||
// (useCurtainState.ts). The pager's refresh singleton gates on THIS:
|
||||
// gating on `isFormActive` would pop a mascot kept alive by another
|
||||
// dwelling tab in behind the still-visible closing form, whose band
|
||||
// has no opaque backdrop in pager mode. `isFormActive` stays
|
||||
// untouched — the static header's Search↔X swap must still flip at
|
||||
// close-start.
|
||||
formMounted: boolean;
|
||||
// Optional tab-specific create action (the Plus button). When null
|
||||
// (Direct) the static header renders NO Plus — new-chat is gone and
|
||||
// people are found through search. Channels sets it to «create
|
||||
|
|
@ -39,6 +50,67 @@ export type MobilePagerCurtainControls = {
|
|||
|
||||
export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(null);
|
||||
|
||||
// Per-tab pull-to-refresh reveal state, published by EVERY pane's
|
||||
// StreamHeader (keyed by its `pinKey`, like `curtainPinnedByTabAtom`):
|
||||
// true while that pane's curtain is at the `refresh` snap or a non-form
|
||||
// drag is in flight — i.e. while the pane's refresh band is (or is
|
||||
// being pulled) on screen.
|
||||
//
|
||||
// Consumed (ORed via `mobilePagerAnyRevealAtom`) by
|
||||
// `PagerRefreshSingleton` — the strip-hosted, screen-static refresh
|
||||
// chrome (one mascot card + ring field for the whole app) shows while
|
||||
// ANY tab reveals. Why a per-tab map and not an active-pane boolean:
|
||||
// an INACTIVE pane legitimately rests at `refresh` through its
|
||||
// auto-close dwell after a swipe-away, and keeping the singleton alive
|
||||
// for it makes a return swipe seamless — no dance restart.
|
||||
//
|
||||
// Per-key writes need no compare-and-clear choreography: each pane owns
|
||||
// its key, so writers never race.
|
||||
//
|
||||
// Keyed by `MobilePagerTab` (not a free-form string): the pager-side
|
||||
// readers index these maps by the pager's tab name, so a pane's
|
||||
// `pinKey` MUST be the tab name — the type makes the compiler enforce
|
||||
// what previously held only by convention.
|
||||
export const curtainRefreshRevealByTabAtom = atom<Partial<Record<MobilePagerTab, boolean>>>({});
|
||||
|
||||
// OR over the per-tab reveal map. Derived so the singleton re-renders
|
||||
// only on actual flips — jotai skips notifying when the derived value
|
||||
// is Object.is-equal — not on map identity churn
|
||||
// ({A:true} → {A:true,B:false}).
|
||||
export const mobilePagerAnyRevealAtom = atom((get) =>
|
||||
Object.values(get(curtainRefreshRevealByTabAtom)).some(Boolean)
|
||||
);
|
||||
|
||||
// ACTIVE pane's form-MOUNTED boolean, derived from the controls atom so
|
||||
// the singleton does not subscribe to the whole controls object (it is
|
||||
// rewritten on activation/primaryAction changes); the boolean only
|
||||
// notifies on real flips. See MobilePagerCurtainControls.formMounted
|
||||
// for why MOUNTED (not the snap-based isFormActive).
|
||||
export const mobilePagerFormMountedAtom = atom(
|
||||
(get) => get(mobilePagerCurtainAtom)?.formMounted ?? false
|
||||
);
|
||||
|
||||
// Imperative channel published the OTHER way — by the strip-hosted
|
||||
// refresh singleton (PagerRefreshSingleton), consumed by the active
|
||||
// pane's StreamHeader:
|
||||
//
|
||||
// * `getVideo` — the live dance <video> (null while the poster is
|
||||
// shown). StreamHeader's pull-to-refresh auto-close reads the loop
|
||||
// position so the curtain only retracts at a dance-loop boundary.
|
||||
// * `spawnEcho` — one-shot echo pulse ring. The singleton is
|
||||
// `pointer-events: none` behind the panes, so it can never receive
|
||||
// the user's tap itself; the active pane's StreamHeader keeps an
|
||||
// invisible tap-catcher in the mascot band and proxies pokes here.
|
||||
//
|
||||
// Both members are stable (the singleton publishes one object on
|
||||
// mount), so consumers can safely list the api object in effect deps.
|
||||
export type MobilePagerMascotApi = {
|
||||
getVideo: () => HTMLVideoElement | null;
|
||||
spawnEcho: () => void;
|
||||
};
|
||||
|
||||
export const mobilePagerMascotApiAtom = atom<MobilePagerMascotApi | null>(null);
|
||||
|
||||
// Per-tab pinned state for the StreamHeader curtain. Each listing
|
||||
// surface (Direct / Channels / Bots) reads/writes by its own stable
|
||||
// `pinKey` so the pin survives the route-driven listing-pane unmount
|
||||
|
|
@ -49,12 +121,13 @@ export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(nu
|
|||
// state — user expects a fresh «header visible» default on cold start;
|
||||
// surviving app restarts would be surprising.
|
||||
//
|
||||
// Map shape (keys are arbitrary stable strings, owned by each
|
||||
// StreamHeader consumer):
|
||||
// { direct: true, channels: false, bots: false, ... }
|
||||
// Map shape (keys are the pager tab names — the type enforces the
|
||||
// pinKey↔tab-name coupling that `MobileTabsPagerHeader`'s elevation
|
||||
// gate relies on when it reads `pinnedByTab[activeTab]`):
|
||||
// { direct: true, channels: false, bots: false }
|
||||
// Missing key ⇒ false. Each tab's pin lives independently — pinning
|
||||
// Direct doesn't pin Channels.
|
||||
export const curtainPinnedByTabAtom = atom<Record<string, boolean>>({});
|
||||
export const curtainPinnedByTabAtom = atom<Partial<Record<MobilePagerTab, boolean>>>({});
|
||||
|
||||
// True while a horseshoe bottom sheet (settings on Direct, workspace
|
||||
// switcher on Channels) is geometrically active — i.e. `expandedPx > 0`,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue