feat(connection): replace 30s 'Connecting...' banner with bottom-edge sync indicator and resume-grace window for phone-unlock UX
This commit is contained in:
parent
f102593081
commit
949860bc1a
6 changed files with 429 additions and 92 deletions
130
docs/plans/splash_mascot.md
Normal file
130
docs/plans/splash_mascot.md
Normal 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.
|
||||||
|
~50–300ms 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.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 ? <ClientRootLoading /> : ...`. 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 `<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 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
|
||||||
|
`<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.
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
RectCords,
|
RectCords,
|
||||||
Text,
|
Text,
|
||||||
} from 'folds';
|
} 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 FocusTrap from 'focus-trap-react';
|
||||||
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
|
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,7 +30,7 @@ import { SpecVersions } from './SpecVersions';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { SyncStatus } from './SyncStatus';
|
import { SyncIndicator } from './SyncIndicator';
|
||||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
import { getFallbackSession } from '../../state/sessions';
|
import { getFallbackSession } from '../../state/sessions';
|
||||||
import { AutoDiscovery } from './AutoDiscovery';
|
import { AutoDiscovery } from './AutoDiscovery';
|
||||||
|
|
@ -179,6 +179,24 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||||
writeSessionBridge(mx);
|
writeSessionBridge(mx);
|
||||||
}, [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(
|
useSyncState(
|
||||||
mx,
|
mx,
|
||||||
useCallback((state) => {
|
useCallback((state) => {
|
||||||
|
|
@ -191,7 +209,7 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||||
return (
|
return (
|
||||||
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
|
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
|
||||||
<SpecVersions baseUrl={baseUrl!}>
|
<SpecVersions baseUrl={baseUrl!}>
|
||||||
{mx && <SyncStatus mx={mx} />}
|
{mx && <SyncIndicator mx={mx} />}
|
||||||
{loading && <ClientRootOptions mx={mx} />}
|
{loading && <ClientRootOptions mx={mx} />}
|
||||||
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
||||||
<AuthSplashScreen>
|
<AuthSplashScreen>
|
||||||
|
|
|
||||||
70
src/app/pages/client/SyncIndicator.css.ts
Normal file
70
src/app/pages/client/SyncIndicator.css.ts
Normal 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))',
|
||||||
|
},
|
||||||
|
]);
|
||||||
175
src/app/pages/client/SyncIndicator.tsx
Normal file
175
src/app/pages/client/SyncIndicator.tsx
Normal 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
|
||||||
|
// ~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<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 (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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 { useTranslation } from 'react-i18next';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds';
|
import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { SyncState } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useSyncState } from '../../../hooks/useSyncState';
|
||||||
import { factoryRoomIdByActivity } from '../../../utils/sort';
|
import { factoryRoomIdByActivity } from '../../../utils/sort';
|
||||||
import { NavCategory, NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
|
import { NavCategory, NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
|
||||||
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
|
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
|
||||||
|
|
@ -65,6 +67,34 @@ function DirectEmpty() {
|
||||||
|
|
||||||
function DirectFooterStatus() {
|
function DirectFooterStatus() {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
|
|
@ -83,8 +113,9 @@ function DirectFooterStatus() {
|
||||||
width: toRem(6),
|
width: toRem(6),
|
||||||
height: toRem(6),
|
height: toRem(6),
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: color.Success.Main,
|
background: dotBackground,
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
|
transition: 'background 200ms ease',
|
||||||
}}
|
}}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue