feat(connection): replace 30s 'Connecting...' banner with bottom-edge sync indicator and resume-grace window for phone-unlock UX

This commit is contained in:
v.lagerev 2026-05-03 22:35:22 +03:00
parent ff01e6cacc
commit 73ccbaa45f
6 changed files with 429 additions and 92 deletions

130
docs/plans/splash_mascot.md Normal file
View file

@ -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
`<AuthSplashScreen />` (mascot + footer) while `/config.json` is fetched.
~50300ms on Caddy-served static.
3. **`SpecVersions` mascot splash** —
[`pages/client/SpecVersions.tsx`](../../src/app/pages/client/SpecVersions.tsx)
renders `<AuthSplashScreen />` while `/_matrix/client/versions` is fetched
from the homeserver. **0.52s, network-bound, the longest of the chain.**
4. **`ClientRoot` mascot splash** —
[`pages/client/ClientRoot.tsx`](../../src/app/pages/client/ClientRoot.tsx)
`loading || !mx ? <ClientRootLoading /> : ...`. Shown while `initClient`
runs (IndexedDB open + crypto WASM init, ~300700ms) AND while waiting
for `SyncState.Prepared` (the `useSyncState` gate at line ~200). The
Prepared wait can be 130s 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; ~300500ms while Android starts the process
and Capacitor loads the WebView.
2. Then the web chain (steps 15 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.52s
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 `<AuthSplashScreen />`. 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 130s 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
`<AuthSplashScreen>{retry-dialog}</AuthSplashScreen>` 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.

View file

@ -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 (510s 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 (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}>
{mx && <SyncStatus mx={mx} />}
{mx && <SyncIndicator mx={mx} />}
{loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
<AuthSplashScreen>

View file

@ -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))',
},
]);

View file

@ -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
// ~510s (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<SyncState | null>(() => 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 (101000ms 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 (
<div className={css.root} aria-hidden>
<div
className={css.barProgress}
style={{
opacity: showProgress ? 1 : 0,
animationPlayState: showProgress ? 'running' : 'paused',
}}
/>
<div className={css.barError} style={{ opacity: showError ? 1 : 0 }} />
</div>
);
}

View file

@ -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<StateData>({
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 (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Success' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
>
<Text size="L400">Connecting...</Text>
</Box>
<Line variant="Success" size="300" />
</Box>
);
}
if (stateData.current === SyncState.Reconnecting) {
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
>
<Text size="L400">Connection Lost! Reconnecting...</Text>
</Box>
<Line variant="Warning" size="300" />
</Box>
);
}
if (stateData.current === SyncState.Error) {
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Critical' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
>
<Text size="L400">Connection Lost!</Text>
</Box>
<Line variant="Critical" size="300" />
</Box>
);
}
return null;
}

View file

@ -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<SyncState | null>(() => 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 (
<Box
alignItems="Center"
@ -83,8 +113,9 @@ function DirectFooterStatus() {
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
background: color.Success.Main,
background: dotBackground,
display: 'inline-block',
transition: 'background 200ms ease',
}}
aria-hidden
/>