diff --git a/docs/plans/splash_mascot.md b/docs/plans/splash_mascot.md new file mode 100644 index 00000000..13c5c609 --- /dev/null +++ b/docs/plans/splash_mascot.md @@ -0,0 +1,130 @@ +# Splash & mascot — open notes + +State of play after the connection-indicator (`SyncIndicator`) work landed. +Anything in this file is **deferred / future work**, not active. Treat it as +context for the next person to pick up the splash topic. + +## Current visible chain (cold load, web/desktop) + +1. **Initial blank** — HTML loaded, React mounts. ~50ms. `body` background + is `#0d0e11` (the `--vojo-safe-area-bg` var), so it's a dark frame, not + white. +2. **`ConfigConfigLoading` mascot splash** — + [`pages/ConfigConfig.tsx`](../../src/app/pages/ConfigConfig.tsx) renders + `` (mascot + footer) while `/config.json` is fetched. + ~50–300ms on Caddy-served static. +3. **`SpecVersions` mascot splash** — + [`pages/client/SpecVersions.tsx`](../../src/app/pages/client/SpecVersions.tsx) + renders `` while `/_matrix/client/versions` is fetched + from the homeserver. **0.5–2s, network-bound, the longest of the chain.** +4. **`ClientRoot` mascot splash** — + [`pages/client/ClientRoot.tsx`](../../src/app/pages/client/ClientRoot.tsx) + `loading || !mx ? : ...`. Shown while `initClient` + runs (IndexedDB open + crypto WASM init, ~300–700ms) AND while waiting + for `SyncState.Prepared` (the `useSyncState` gate at line ~200). The + Prepared wait can be 1–30s depending on cache warmth and account size. +5. App tree mounts. `SyncIndicator` takes over for the residual sync + activity (green slide while sync is still settling, hidden once Syncing). + +## Current visible chain (Android native) + +1. **OS system splash** — Android 12+ enforces a system splash with the + launcher icon centered over `windowBackground`. Configured via + [`android/app/src/main/res/values/styles.xml`](../../android/app/src/main/res/values/styles.xml) + `AppTheme.NoActionBarLaunch` → `windowBackground=@android:color/black`. + Cannot be fully eliminated; ~300–500ms while Android starts the process + and Capacitor loads the WebView. +2. Then the web chain (steps 1–5 above). + +So an Android cold-launch sees: black-icon (OS) → black blank (HTML) → +mascot (config + spec-versions + initClient + Prepared) → app. + +## What we tried and reverted (during the SyncIndicator work) + +In the 0.3.0 attempt that got reverted, we made several aggressive changes +to shorten the chain. They worked, but introduced subtle bugs that the user +chose to roll back to keep the diff clean. Specifically: + +### A. SpecVersions made non-blocking + +[Commit reverted in `ff01e6c`.] The change made `SpecVersions` render +children immediately with `versions: []` and hydrate the real value in the +background (with retry on `online` event). This eliminated the 0.5–2s +mascot in step 3. + +**Why reverted**: it surfaced a latent bug in +[`components/message/content/ImageContent.tsx`](../../src/app/components/message/content/ImageContent.tsx) +and `VideoContent.tsx`. While `versions: []`, `useMediaAuthentication()` +returns false, autoPlay images load with unauth URLs, the server rejects, +the local `error=true` flag latches and is never cleared on the post- +hydration retry. The reviewer flagged this as a blocker. + +**Fix attempted**: reset `error` and `load` state in the `loadSrc`-deps +useEffect AND in `handleLoad` — both `ImageContent.tsx` and +`VideoContent.tsx`. The fix was correct but the user judged it as out-of- +scope scope-creep for the connection-indicator feature and rolled the +whole change back. + +**To redo cleanly**: ship SpecVersions-non-blocking and the +ImageContent/VideoContent fix together, in a separate dedicated commit +(scope = "make boot non-blocking", not bundled with the indicator). + +### B. ClientRoot loading paths replaced with `null` + +[`pages/ConfigConfig.tsx::ConfigConfigLoading`](../../src/app/pages/ConfigConfig.tsx) +returned `null` instead of ``. Same for the `!mx` branch +in `ClientRoot`. Eliminated the mascot during steps 2 and 4-init-only. +Reverted as part of the same revert. + +**To redo**: low risk on its own. Change those two callsites to render +`null` (the dark body background shows through). Skip the Prepared gate +removal (that's the deeper change). + +### C. ClientRoot Prepared gate removed + +The ambitious move: render `MatrixClientProvider` as soon as `mx` is created +(after `initClient`), not after `Prepared`. Eliminates the 1–30s wait for +first sync — app shell renders against the IndexedDB cache while sync +populates new events in background. + +**Why reverted**: needed an extra fix in +[`pages/client/direct/RoomProvider.tsx`](../../src/app/pages/client/direct/RoomProvider.tsx) +to add `useAtomValue(allRoomsAtom)` so cold-start deep-links (push tap) +re-render when the room arrives. Plus arguably exposes a brief empty-UI +flash on first-time logins. + +**To redo**: do it after a careful audit of every consumer of +`mx.getRoom()` / `mx.getRooms()` to make sure they all tolerate empty +stores. The deep-link re-render fix in `direct/RoomProvider.tsx` is the +mechanical trigger; there may be others. + +## What's open + +In rough priority order: + +1. **SpecVersions non-blocking + media-auth race fix** (B above). + Biggest win for cold-start time-to-interactive (~1.5s saved). Bundle + with the ImageContent/VideoContent error reset in one commit. +2. **`null` instead of mascot during ConfigConfig + `!mx` loading** + (B above, simpler half). ~400ms shaved. +3. **Drop the Prepared gate** (C above). Most invasive; needs full audit. + Could wait until `dm_1x1_redesign.md` or another major-mode change is + ready to absorb the risk. +4. **OS system splash on Android** — currently iconic-on-black via + `AppTheme.NoActionBarLaunch`. Can't eliminate on Android 12+ but can + be tuned (different background color, different icon) via + `android/app/src/main/res/values/styles.xml`. Not user-reported as a + problem, just noting the lever exists. + +## Don't break + +- **`body { background: var(--vojo-safe-area-bg, #0d0e11) }`** in + `src/index.css` — the dark frame is what makes a `null` loading path + look intentional. Without it the user sees a white flash. +- **Mascot on auth pages** (`/login`, `/register`) is INTENDED — those + pages design the mascot in deliberately. Don't gate-remove it there. + Only the loading-state mascot is the candidate for removal. +- **The error-retry mascot in `ClientRoot`** (the + `{retry-dialog}` branch in + `ClientRoot.tsx`) is the rare case where the mascot is fine to keep — + it's a hard-error context that needs anchoring for the dialog. diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 9973d56d..62b9c13c 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -12,7 +12,7 @@ import { RectCords, Text, } from 'folds'; -import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk'; +import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react'; import { @@ -30,7 +30,7 @@ import { SpecVersions } from './SpecVersions'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useSyncState } from '../../hooks/useSyncState'; import { stopPropagation } from '../../utils/keyboard'; -import { SyncStatus } from './SyncStatus'; +import { SyncIndicator } from './SyncIndicator'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession } from '../../state/sessions'; import { AutoDiscovery } from './AutoDiscovery'; @@ -179,6 +179,24 @@ export function ClientRoot({ children }: ClientRootProps) { writeSessionBridge(mx); }, [mx]); + // When the OS reports the network is back, prod the sync loop instead of + // waiting for matrix-js-sdk's internal keep-alive jitter (5–10s backoff + // per `sync.js`). Only acts when the SDK is genuinely paused on a failed + // connection — Error or Reconnecting — so a healthy Syncing client just + // ignores the event. `retryImmediately` is itself idempotent, so spurious + // duplicate `online` events from flaky NICs are harmless. + useEffect(() => { + if (!mx) return undefined; + const onOnline = () => { + const state = mx.getSyncState(); + if (state === SyncState.Error || state === SyncState.Reconnecting) { + mx.retryImmediately(); + } + }; + window.addEventListener('online', onOnline); + return () => window.removeEventListener('online', onOnline); + }, [mx]); + useSyncState( mx, useCallback((state) => { @@ -191,7 +209,7 @@ export function ClientRoot({ children }: ClientRootProps) { return ( - {mx && } + {mx && } {loading && } {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( diff --git a/src/app/pages/client/SyncIndicator.css.ts b/src/app/pages/client/SyncIndicator.css.ts new file mode 100644 index 00000000..70b81fca --- /dev/null +++ b/src/app/pages/client/SyncIndicator.css.ts @@ -0,0 +1,70 @@ +import { keyframes, style } from '@vanilla-extract/css'; +import { config } from 'folds'; + +// One sweep, left → right. The bar is full-viewport-wide; the radial gradient +// fades to transparent at both edges, so the keyframe loop from +// `translateX(100%)` back to `translateX(-100%)` happens entirely off-screen +// and the snap is invisible. +const slide = keyframes({ + '0%': { transform: 'translateX(-100%)' }, + '100%': { transform: 'translateX(100%)' }, +}); + +// Container is taller than the visible bar so the drop-shadow halo isn't +// clipped by `overflow: hidden`. The bar itself sits flush to the bottom +// edge of the safe-area inset (the 26px above is a transparent +// pointer-events: none zone where only the glow renders). +export const root = style({ + position: 'fixed', + left: 'env(safe-area-inset-left, 0px)', + right: 'env(safe-area-inset-right, 0px)', + bottom: 'env(safe-area-inset-bottom, 0px)', + height: '28px', + pointerEvents: 'none', + // Z400 sits above app chrome and below folds modals/popouts (which use + // Max=9999). Connection state should be visible on the page, not over + // dialogs. + zIndex: config.zIndex.Z400, + overflow: 'hidden', +}); + +const barBase = style({ + position: 'absolute', + bottom: 0, + left: 0, + width: '100%', + height: '2px', + // 250ms opacity transition carries progress↔hidden and progress↔error + // crossfades smoothly. Both layers are always mounted; the React side + // toggles opacity. + transition: 'opacity 250ms ease', +}); + +export const barProgress = style([ + barBase, + { + background: + 'radial-gradient(ellipse 50% 100% at center, #5BE3C5 0%, rgba(91, 227, 197, 0.45) 35%, rgba(91, 227, 197, 0) 70%)', + filter: 'drop-shadow(0 0 6px rgba(91, 227, 197, 0.8))', + animation: `${slide} 1.8s linear infinite`, + willChange: 'transform', + '@media': { + '(prefers-reduced-motion: reduce)': { + animation: 'none', + }, + }, + }, +]); + +export const barError = style([ + barBase, + { + // Slightly brighter, denser-fade red — the gradient extends closer to + // the viewport edges than the green sweep so the static error visual + // reads as a stronger statement than the moving progress one. Same + // 2px height as the progress bar. + background: + 'radial-gradient(ellipse 50% 100% at center, #FF3030 0%, rgba(255, 48, 48, 0.7) 50%, rgba(255, 48, 48, 0) 90%)', + filter: 'drop-shadow(0 0 12px rgba(255, 48, 48, 0.95))', + }, +]); diff --git a/src/app/pages/client/SyncIndicator.tsx b/src/app/pages/client/SyncIndicator.tsx new file mode 100644 index 00000000..2570b81c --- /dev/null +++ b/src/app/pages/client/SyncIndicator.tsx @@ -0,0 +1,175 @@ +import React, { useEffect, useState } from 'react'; +import { App } from '@capacitor/app'; +import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk'; +import { isAndroidPlatform } from '../../utils/capacitor'; +import * as css from './SyncIndicator.css'; + +// Suppress 0-150ms green flashes during fast transient state cycles +// (e.g. one /sync request blips Reconnecting then immediately recovers to +// Syncing — without the delay the bar would briefly appear at whatever +// position the slide animation happened to be at). +const PROGRESS_FADE_IN_DELAY_MS = 150; + +// On detected resume, hold the visual at "progress" (green) for this long if +// the SDK is in `Error`. Worst-case keep-alive backoff in matrix-js-sdk is +// ~5–10s (jittered), so 10s is wide enough that we don't lose the race and +// flash red on a stale Error that the SDK is about to clear on its own. +const RESUME_GRACE_MS = 10_000; + +// Below this threshold, ignore the inactive period — it's a notification +// pulldown, brief alt-tab, or system permission prompt, not a real lock / +// background. 3s is conservative: anything shorter than the SDK's first +// retry attempt is uninteresting. +const RESUME_HIDDEN_THRESHOLD_MS = 3_000; + +type SyncIndicatorProps = { + mx: MatrixClient; +}; + +type Visual = 'hidden' | 'progress' | 'error'; + +// Steady-state mapping. The real SDK state machine has more values; this +// collapses the cases that matter for the indicator: +// - Syncing / Stopped → connection is healthy or terminating → hidden +// - Error → SDK gave up after 3 retries → red +// - everything else → SDK is in transit (Reconnecting / Catchup / the +// unrealistic post-mount null/Prepared) → green +const stateToVisual = (state: SyncState | null): Visual => { + if (state === SyncState.Error) return 'error'; + if (state === SyncState.Syncing || state === SyncState.Stopped) return 'hidden'; + return 'progress'; +}; + +export function SyncIndicator({ mx }: SyncIndicatorProps) { + const [syncState, setSyncState] = useState(() => mx.getSyncState()); + // Timestamp until which a latched `Error` should be demoted to `progress` + // (green). Set on a real resume from background; cleared either by the + // expiry timer below or by the first `Syncing` event after resume. + const [resumeGraceUntil, setResumeGraceUntil] = useState(0); + + useEffect(() => { + // Re-read after the listener is wired to close the gap between the + // useState initializer (synchronous render) and the effect mount + // (post-paint). A Sync event in that ~16ms window would otherwise be + // dropped. + setSyncState(mx.getSyncState()); + const handler = (state: SyncState) => { + setSyncState(state); + // First Syncing after resume — recovery confirmed, drop the grace + // immediately rather than waiting out the timer. Avoids leaving the + // bar green for the residual seconds when the SDK already recovered. + if (state === SyncState.Syncing) { + setResumeGraceUntil(0); + } + }; + mx.on(ClientEvent.Sync, handler); + return () => { + mx.removeListener(ClientEvent.Sync, handler); + }; + }, [mx]); + + // Lifecycle: on resume from a long-hidden state (phone unlock, app + // foregrounded), kick the SDK out of any latched Error with + // retryImmediately() and set a grace window during which the indicator + // shows green ("reconnecting") instead of red. Without this, an Android + // screen-off period kills the in-flight long-poll, the SDK retries 3× + // during the throttled JS window, lands on Error, and the user sees a + // ~5s red flash on unlock before the SDK's keep-alive jitter recovers. + // + // Listener split mirrors IncomingCallStripRenderer.tsx: Android pause/ + // resume fire on Activity onPause/onResume (ms latency), while + // appStateChange ties to onStop (10–1000ms after onPause) which is too + // late. iOS/web have no `pause` event — `appStateChange` is the canonical + // signal there. + useEffect(() => { + let cancelled = false; + const handles: Array<{ remove: () => void }> = []; + const track = (h: { remove: () => void }) => { + if (cancelled) h.remove(); + else handles.push(h); + }; + + let hiddenAt: number | null = null; + + const onActiveChange = (isActive: boolean) => { + if (!isActive) { + hiddenAt = performance.now(); + return; + } + if (hiddenAt === null) return; + const gap = performance.now() - hiddenAt; + hiddenAt = null; + if (gap < RESUME_HIDDEN_THRESHOLD_MS) return; + setResumeGraceUntil(performance.now() + RESUME_GRACE_MS); + // retryImmediately is idempotent: a no-op when the SDK isn't waiting + // on a scheduled retry (i.e. healthy Syncing). Cheap to call. + if (mx.getSyncState() !== SyncState.Syncing) { + mx.retryImmediately(); + } + }; + + if (isAndroidPlatform()) { + App.addListener('pause', () => onActiveChange(false)).then(track); + App.addListener('resume', () => onActiveChange(true)).then(track); + } else { + App.addListener('appStateChange', ({ isActive }) => + onActiveChange(isActive) + ).then(track); + } + + return () => { + cancelled = true; + handles.forEach((h) => h.remove()); + }; + }, [mx]); + + // Auto-clear the grace window when its timer expires. Skipped if the + // first-Syncing handler above already cleared it. + useEffect(() => { + if (resumeGraceUntil === 0) return undefined; + const remaining = resumeGraceUntil - performance.now(); + if (remaining <= 0) { + setResumeGraceUntil(0); + return undefined; + } + const timer = setTimeout(() => setResumeGraceUntil(0), remaining); + return () => clearTimeout(timer); + }, [resumeGraceUntil]); + + const baseVisual = stateToVisual(syncState); + // Grace window only overrides Error → progress. Other transitions + // (Syncing→hidden, etc) fire normally — the user's recovery is real. + const visual: Visual = + resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual; + + // Anti-flicker debounce: the green layer only becomes visible if the + // progress visual is sustained for at least 150ms. + const [showProgress, setShowProgress] = useState(false); + useEffect(() => { + if (visual !== 'progress') { + setShowProgress(false); + return undefined; + } + const timer = setTimeout(() => setShowProgress(true), PROGRESS_FADE_IN_DELAY_MS); + return () => clearTimeout(timer); + }, [visual]); + + const showError = visual === 'error'; + + // Both layers are always mounted; opacity controls visibility with a + // 250ms transition (set on barBase). `animationPlayState: 'paused'` on + // the green layer when invisible avoids continuous compositor work — + // `opacity: 0` does NOT pause CSS animations in any major browser. + return ( +
+
+
+
+ ); +} diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx deleted file mode 100644 index 9cd4b0b2..00000000 --- a/src/app/pages/client/SyncStatus.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { MatrixClient, SyncState } from 'matrix-js-sdk'; -import React, { useCallback, useState } from 'react'; -import { Box, config, Line, Text } from 'folds'; -import { useSyncState } from '../../hooks/useSyncState'; -import { ContainerColor } from '../../styles/ContainerColor.css'; - -type StateData = { - current: SyncState | null; - previous: SyncState | null | undefined; -}; - -type SyncStatusProps = { - mx: MatrixClient; -}; -export function SyncStatus({ mx }: SyncStatusProps) { - const [stateData, setStateData] = useState({ - current: null, - previous: undefined, - }); - - useSyncState( - mx, - useCallback((current, previous) => { - setStateData((s) => { - if (s.current === current && s.previous === previous) { - return s; - } - return { current, previous }; - }); - }, []) - ); - - if ( - (stateData.current === SyncState.Prepared || - stateData.current === SyncState.Syncing || - stateData.current === SyncState.Catchup) && - stateData.previous !== SyncState.Syncing - ) { - return ( - - - Connecting... - - - - ); - } - - if (stateData.current === SyncState.Reconnecting) { - return ( - - - Connection Lost! Reconnecting... - - - - ); - } - - if (stateData.current === SyncState.Error) { - return ( - - - Connection Lost! - - - - ); - } - - return null; -} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 0bad07af..f08f9f2c 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -1,10 +1,12 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useNavigate } from 'react-router-dom'; +import { SyncState } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useSyncState } from '../../../hooks/useSyncState'; import { factoryRoomIdByActivity } from '../../../utils/sort'; import { NavCategory, NavEmptyCenter, NavEmptyLayout } from '../../../components/nav'; import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils'; @@ -65,6 +67,34 @@ function DirectEmpty() { function DirectFooterStatus() { const { t } = useTranslation(); + const mx = useMatrixClient(); + const [syncState, setSyncState] = useState(() => mx.getSyncState()); + useSyncState( + mx, + useCallback((state: SyncState) => { + setSyncState(state); + }, []) + ); + // Re-read post-mount to close the same render-to-effect gap as in + // SyncIndicator — useState initializer runs before useSyncState attaches + // its listener. + useEffect(() => { + setSyncState(mx.getSyncState()); + }, [mx]); + + // Steady-state mapping (same source-of-truth philosophy as SyncIndicator): + // - SDK Syncing → green dot, all is well + // - SDK Error → red dot, give-up state + // - everything else (transitional Reconnecting / Catchup / etc) → muted + // `currentColor` so the dot reads as a transitional gray under the + // parent's 0.45 opacity. + const dotBackground = + syncState === SyncState.Error + ? color.Critical.Main + : syncState === SyncState.Syncing + ? color.Success.Main + : 'currentColor'; + return (