diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index dec5f063..cde2747b 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -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 `` 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/`. diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx index b1f95efd..598c0635 100644 --- a/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx +++ b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx @@ -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( () => ({ 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(() => , []); + const channelsPane = useMemo( + () => ( + + {activeSpace ? ( + + + + ) : ( + + )} + + ), + [activeSpace] + ); + const botsPane = useMemo(() => , []); + // 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. */}
+ {/* 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. */} + - - - + {directPane} - - - {activeSpace ? ( - - - - ) : ( - - )} - - + {channelsPane} {showBots && ( - - - + {botsPane} )}
diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx index 487b3be2..ebe824d1 100644 --- a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx +++ b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx @@ -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; diff --git a/src/app/components/mobile-tabs-pager/PagerRefreshSingleton.tsx b/src/app/components/mobile-tabs-pager/PagerRefreshSingleton.tsx new file mode 100644 index 00000000..4eccda18 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/PagerRefreshSingleton.tsx @@ -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