vojo/src/app/pages/client/SyncIndicator.tsx

241 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 sub-100ms 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). 100ms keeps the bar
// responsive enough that real transitions land before the user gives up.
//
// Trade-off: combined with the cycle-complete hide below, transients in
// the 100-1800ms window now block the bar for a full slide cycle (~2s
// of visible bar) instead of cutting at the actual recovery moment.
// Deliberate choice — graceful animation > strict timing accuracy.
const PROGRESS_FADE_IN_DELAY_MS = 100;
// Fallback hide deadline if `animationiteration` never fires — covers
// any compositor freeze that drops the event (e.g. tab backgrounded
// mid-cycle and just foregrounded). 2100ms gives ~300ms slack over the
// 1.8s slide-cycle. The reduced-motion path skips this branch entirely.
const PROGRESS_HIDE_FALLBACK_MS = 2100;
// 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;
// Three logical phases for the green progress bar (mirrors the bot
// widget loading bar):
// - hidden (showProgress=false, pendingProgressHide=false)
// - visible (showProgress=true, pendingProgressHide=false)
// - closing (showProgress=true, pendingProgressHide=true) →
// progress→hidden landed mid-sweep, waiting for the
// animationiteration boundary to land before snapping
// invisible so the user always sees a complete left-to-
// right sweep, never a mid-stroke cut.
// Cycle-complete is only applied for progress→hidden. progress→error
// skips it because we want the red layer to take over without a half-
// faded green sweep visually mixing on top of it.
const [showProgress, setShowProgress] = useState(false);
const [pendingProgressHide, setPendingProgressHide] = useState(false);
useEffect(() => {
if (visual === 'progress') {
setPendingProgressHide(false);
if (showProgress) return undefined;
const timer = setTimeout(() => setShowProgress(true), PROGRESS_FADE_IN_DELAY_MS);
return () => clearTimeout(timer);
}
if (visual === 'hidden' && showProgress) {
// Reduced-motion: animation is off, no iteration will land,
// parking a static green bar for ~2s isn't graceful — it's just
// a stuck bar. Snap invisible immediately.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setShowProgress(false);
setPendingProgressHide(false);
return undefined;
}
// Sync recovered while bar was mid-sweep — defer the hide to the
// next iteration boundary. Fallback force-hides if the event
// never lands (compositor freeze on backgrounded tab).
setPendingProgressHide(true);
const fallback = setTimeout(() => {
setShowProgress(false);
setPendingProgressHide(false);
}, PROGRESS_HIDE_FALLBACK_MS);
return () => clearTimeout(fallback);
}
// Either visual === 'error' (red layer takes over, drop green now)
// or visual === 'hidden' && !showProgress (already hidden, no-op).
setShowProgress(false);
setPendingProgressHide(false);
return undefined;
}, [visual, showProgress]);
const handleProgressIteration = () => {
if (pendingProgressHide) {
setShowProgress(false);
setPendingProgressHide(false);
}
};
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.
//
// On progress→error we snap the green to opacity:0 with no transition
// (`transition: 'none'`). The default 250ms fade-out would otherwise
// cross-fade with the red layer's 250ms fade-in — both stack at the
// same bottom edge, both at intermediate alpha, producing a muddy
// green-on-red blend at the exact moment we're trying to alarm the
// user. The `paused` animation also freezes the green at whatever
// mid-cycle position it was at, so the cross-fade looks like a static
// green smear bleeding into a static red bar. Snap-hide kills it.
return (
<div className={css.root} aria-hidden>
<div
className={css.barProgress}
style={{
opacity: showProgress ? 1 : 0,
animationPlayState: showProgress ? 'running' : 'paused',
transition: showError ? 'none' : undefined,
}}
onAnimationIteration={handleProgressIteration}
/>
<div className={css.barError} style={{ opacity: showError ? 1 : 0 }} />
</div>
);
}