feat: warn when the local clock is far off the homeserver's (#158)

Incident 2026-09-17: a wrong Windows clock broke calls and media keys while
the server answered 200 to everything, with no hint in the UI.

Measurement needs no extra requests and no CORS-exposed headers: every live
event carries origin_server_ts and unsigned.age (our server's now − ts at
response time), so localTimestamp − origin_server_ts is the skew. Only
RoomEvent.Timeline live events count (cache replays have stale age and are
already flagged liveEvent=false by the SDK); the initial network sync
qualifies, so a wrong clock is flagged within seconds of startup. Median of
the last 5 samples, ≥3 needed; warn at |skew| > 30 s, clear below 15 s.

UI: a banner in the sync-status slot — "Your computer's clock is 14 minutes
ahead of the server. Encrypted messages and voice calls will fail until it is
fixed." with a per-OS How-to-fix hint and Dismiss for 24 h — plus the same
line in the call status bar while in a call. Never auto-corrects anything.

Unit-tested (median, hysteresis, stale-age rejection, wording); verified
headless with Playwright's clock skewed +14 min and −3 h (banner, hint,
in-call line, dismiss) and in sync (nothing shown).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 12:52:03 -04:00
co-authored by Claude Opus 5
parent 464951edf4
commit 84c906fe33
7 changed files with 372 additions and 1 deletions
@@ -31,6 +31,8 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation';
import { ClockSkewMonitor } from '../../utils/clockSkew';
import { clockSkewAtom } from '../../state/clockSkew';
import {
getDirectRoomPath,
getHomeRoomPath,
@@ -934,6 +936,39 @@ function SearchCacheInvalidationFeature(): null {
return null;
}
/**
* [Gitea #158] Feeds every LIVE timeline event (origin_server_ts + unsigned.age
* vs the SDK's localTimestamp) to the skew monitor. `data.liveEvent` is false
* for events replayed from the IndexedDB cache — whose `age` is stale — so
* only network deliveries (including the initial sync) count.
*/
function ClockSkewFeature() {
const mx = useMatrixClient();
const setSkew = useSetAtom(clockSkewAtom);
useEffect(() => {
const monitor = new ClockSkewMonitor();
const unsub = monitor.subscribe(setSkew);
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (
mEvent,
_room,
_toStart,
_removed,
data,
) => {
if (!data.liveEvent) return;
monitor.sample(mEvent.getTs(), mEvent.getAge(), mEvent.localTimestamp);
};
mx.on(RoomEvent.Timeline, onTimeline);
return () => {
mx.off(RoomEvent.Timeline, onTimeline);
unsub();
};
}, [mx, setSkew]);
return null;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
@@ -947,6 +982,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<PwaInstallFeature />
<FaviconUpdater />
<PresenceUpdater />
<ClockSkewFeature />
<MuteTimerRestore />
<StatusExpiryMonitor />
<InviteNotifications />
+2
View File
@@ -43,6 +43,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { ClockSkewBanner } from './ClockSkewBanner';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { pushSessionToSW } from '../../../sw-session';
@@ -246,6 +247,7 @@ export function ClientRoot({ children }: ClientRootProps) {
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}>
{mx && !syncError && <SyncStatus mx={mx} />}
{mx && !syncError && <ClockSkewBanner />}
{loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
+82
View File
@@ -0,0 +1,82 @@
import React, { useCallback, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Box, Button, config, Line, Text } from 'folds';
import { clockSkewAtom } from '../../state/clockSkew';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { clockFixHint, describeSkewVsServer, detectClockFixPlatform } from '../../utils/clockSkew';
const DISMISS_KEY = 'lotus-clock-skew-dismissed-until';
const DISMISS_MS = 24 * 60 * 60 * 1000;
const readDismissedUntil = (): number => {
try {
const raw = localStorage.getItem(DISMISS_KEY);
const n = raw ? Number(raw) : 0;
return Number.isFinite(n) ? n : 0;
} catch {
return 0;
}
};
/**
* [Gitea #158] "Your computer's clock is 14 minutes ahead of the server."
* Same slot and style as the sync banners. Shown while the skew monitor is
* over its threshold; the direction matters, so it is said. Dismissable for
* 24 h; never auto-corrects anything.
*/
export function ClockSkewBanner() {
const { skewMs, warning } = useAtomValue(clockSkewAtom);
const [dismissedUntil, setDismissedUntil] = useState(readDismissedUntil);
const [showHint, setShowHint] = useState(false);
const dismiss = useCallback(() => {
const until = Date.now() + DISMISS_MS;
try {
localStorage.setItem(DISMISS_KEY, String(until));
} catch {
// storage unavailable — dismiss for this session only
}
setDismissedUntil(until);
}, []);
if (!warning || skewMs === null || Date.now() < dismissedUntil) return null;
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
direction="Column"
alignItems="Center"
gap="100"
role="alert"
>
<Box alignItems="Center" gap="300" wrap="Wrap" justifyContent="Center">
<Text size="L400" align="Center">
Your computer&apos;s clock is <b>{describeSkewVsServer(skewMs)}</b>. Encrypted messages
and voice calls will fail until it is fixed.
</Text>
<Button
size="300"
variant="Warning"
fill="Soft"
radii="300"
onClick={() => setShowHint((v) => !v)}
aria-expanded={showHint}
>
<Text size="B300">How to fix</Text>
</Button>
<Button size="300" variant="Warning" fill="None" radii="300" onClick={dismiss}>
<Text size="B300">Dismiss for 24 h</Text>
</Button>
</Box>
{showHint && (
<Text size="T200" align="Center">
{clockFixHint(detectClockFixPlatform(navigator.userAgent))}
</Text>
)}
</Box>
<Line variant="Warning" size="300" />
</Box>
);
}