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 // ~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; // 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 (
); }