241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
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<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;
|
||
|
||
// 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>
|
||
);
|
||
}
|