diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx
index fd96f2166..8ebbf3613 100644
--- a/src/app/features/call-status/CallStatus.tsx
+++ b/src/app/features/call-status/CallStatus.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { Box, Spinner } from 'folds';
+import { Box, Spinner, Text, color } from 'folds';
+import { useAtomValue } from 'jotai';
import classNames from 'classnames';
import { LiveChip } from './LiveChip';
import * as css from './styles.css';
@@ -14,6 +15,8 @@ import { CallEmbed } from '../../plugins/call/CallEmbed';
import { useCallJoined } from '../../hooks/useCallEmbed';
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
import { MemberSpeaking } from './MemberSpeaking';
+import { clockSkewAtom } from '../../state/clockSkew';
+import { describeSkewVsServer } from '../../utils/clockSkew';
type CallStatusProps = {
callEmbed: CallEmbed;
@@ -26,6 +29,8 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
const screenSize = useScreenSize();
const callJoined = useCallJoined(callEmbed);
const speakers = useCallSpeakers(callEmbed);
+ // [Gitea #158] Same warning as the top banner, where the user is looking during a call.
+ const clockSkew = useAtomValue(clockSkewAtom);
const compact = screenSize === ScreenSize.Mobile;
@@ -51,6 +56,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
{!compact && (
<>
+ {clockSkew.warning && clockSkew.skewMs !== null && (
+ <>
+
+
+ Clock {describeSkewVsServer(clockSkew.skewMs)} — calls will fail
+
+ >
+ )}
{speakers.size > 0 && (
<>
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 9c520f207..cb5f906c5 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -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) {
+
diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index 21753d080..934b7dc28 100644
--- a/src/app/pages/client/ClientRoot.tsx
+++ b/src/app/pages/client/ClientRoot.tsx
@@ -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) {
{mx && !syncError && }
+ {mx && !syncError && }
{loading && }
{(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
diff --git a/src/app/pages/client/ClockSkewBanner.tsx b/src/app/pages/client/ClockSkewBanner.tsx
new file mode 100644
index 000000000..0c39d3f7d
--- /dev/null
+++ b/src/app/pages/client/ClockSkewBanner.tsx
@@ -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 (
+
+
+
+
+ Your computer's clock is {describeSkewVsServer(skewMs)}. Encrypted messages
+ and voice calls will fail until it is fixed.
+
+
+
+
+ {showHint && (
+
+ {clockFixHint(detectClockFixPlatform(navigator.userAgent))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/state/clockSkew.ts b/src/app/state/clockSkew.ts
new file mode 100644
index 000000000..87710ef40
--- /dev/null
+++ b/src/app/state/clockSkew.ts
@@ -0,0 +1,5 @@
+import { atom } from 'jotai';
+import { ClockSkewState } from '../utils/clockSkew';
+
+/** [Gitea #158] Latest local-vs-homeserver clock skew reading. */
+export const clockSkewAtom = atom({ skewMs: null, warning: false });
diff --git a/src/app/utils/clockSkew.test.ts b/src/app/utils/clockSkew.test.ts
new file mode 100644
index 000000000..df7985556
--- /dev/null
+++ b/src/app/utils/clockSkew.test.ts
@@ -0,0 +1,83 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ ClockSkewMonitor,
+ clockFixHint,
+ describeSkewVsServer,
+ detectClockFixPlatform,
+ formatSkew,
+} from './clockSkew';
+
+// A live event received when the local clock is `skew` ms ahead of the server:
+// origin_server_ts = T (server clock), age = a, localTimestamp = (T + a + skew) - a.
+const feed = (m: ClockSkewMonitor, skew: number, age = 500, t = 1_700_000_000_000) =>
+ m.sample(t, age, t + skew);
+
+test('needs three samples, then reports the median with direction', () => {
+ const m = new ClockSkewMonitor();
+ assert.equal(feed(m, 60_000).skewMs, null);
+ assert.equal(feed(m, 61_000).skewMs, null);
+ const s = feed(m, 59_000);
+ assert.equal(s.skewMs, 60_000);
+ assert.equal(s.warning, true);
+ assert.equal(formatSkew(s.skewMs!), '60 seconds ahead');
+});
+
+test('one bad sample cannot trip the warning (median) and hysteresis clears only under 15 s', () => {
+ const m = new ClockSkewMonitor();
+ feed(m, 1000);
+ feed(m, 1500);
+ assert.equal(feed(m, 90_000).warning, false); // outlier
+ assert.equal(m.getState().skewMs, 1500);
+
+ const w = new ClockSkewMonitor();
+ [40_000, 41_000, 39_000, 40_000, 40_000].forEach((s) => feed(w, s));
+ assert.equal(w.getState().warning, true);
+ // drifting down to 20 s: still >= 15 s → stays on
+ [20_000, 20_000, 20_000, 20_000, 20_000].forEach((s) => feed(w, s));
+ assert.equal(w.getState().warning, true);
+ [10_000, 10_000, 10_000, 10_000, 10_000].forEach((s) => feed(w, s));
+ assert.equal(w.getState().warning, false);
+});
+
+test('stale or missing age is ignored (cache replay must not read as skew)', () => {
+ const m = new ClockSkewMonitor();
+ const t = 1_700_000_000_000;
+ m.sample(t, undefined, t + 3_600_000);
+ m.sample(t, 40 * 24 * 60 * 60 * 1000, t + 3_600_000);
+ m.sample(t, -5, t);
+ assert.equal(m.getState().skewMs, null);
+});
+
+test('subscribe fires on change only; reset clears', () => {
+ const m = new ClockSkewMonitor();
+ const seen: (number | null)[] = [];
+ m.subscribe((s) => seen.push(s.skewMs));
+ feed(m, -120_000);
+ feed(m, -120_000);
+ feed(m, -120_000);
+ feed(m, -120_000);
+ assert.deepEqual(seen, [-120_000]);
+ assert.equal(formatSkew(-120_000), '2 minutes behind');
+ m.reset();
+ assert.deepEqual(seen, [-120_000, null]);
+});
+
+test('formatSkew picks a sensible unit', () => {
+ assert.equal(formatSkew(45_000), '45 seconds ahead');
+ assert.equal(formatSkew(-14 * 60_000), '14 minutes behind');
+ assert.equal(formatSkew(3 * 3_600_000), '3 hours ahead');
+ assert.equal(formatSkew(2 * 86_400_000), '2 days ahead');
+ assert.equal(describeSkewVsServer(-3 * 3_600_000), '3 hours behind the server');
+ assert.equal(describeSkewVsServer(14 * 60_000), '14 minutes ahead of the server');
+});
+
+test('platform hint', () => {
+ assert.equal(detectClockFixPlatform('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'), 'windows');
+ assert.equal(
+ detectClockFixPlatform('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'),
+ 'ios',
+ );
+ assert.equal(detectClockFixPlatform('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)'), 'mac');
+ assert.match(clockFixHint('windows'), /Sync now/);
+});
diff --git a/src/app/utils/clockSkew.ts b/src/app/utils/clockSkew.ts
new file mode 100644
index 000000000..011e3112d
--- /dev/null
+++ b/src/app/utils/clockSkew.ts
@@ -0,0 +1,145 @@
+/**
+ * [Gitea #158] Local-clock-vs-homeserver skew detection.
+ *
+ * Incident 2026-09-17: a wrong Windows clock made every MatrixRTC membership
+ * look expired locally (calls failed, media keys rejected) while the server
+ * answered 200 to everything, and nothing in the UI hinted at the cause.
+ *
+ * Measurement needs no extra requests and no CORS-exposed headers: every event
+ * a `/sync` delivers carries `origin_server_ts` (stamped by its origin server)
+ * and `unsigned.age` (= OUR server's `now − origin_server_ts` at the moment it
+ * built the response). So `origin_server_ts + age` is our server's clock at
+ * response time, and `receivedAt − (origin_server_ts + age)` is our skew plus
+ * the download latency (tens of ms; ignored). matrix-js-sdk already computes
+ * `localTimestamp = Date.now() − age` at event construction, so a sample is
+ * simply `localTimestamp − origin_server_ts`.
+ *
+ * Only LIVE events count (`RoomEvent.Timeline` data.liveEvent, which the SDK
+ * already sets false for events replayed from the IndexedDB cache — those carry
+ * a stale `age` that would read as hours of skew). The initial network sync's
+ * events qualify, so a wrong clock is flagged within seconds of startup.
+ */
+
+export const SKEW_WARN_MS = 30_000;
+export const SKEW_CLEAR_MS = 15_000;
+export const SKEW_SAMPLES = 5;
+export const SKEW_MIN_SAMPLES = 3;
+/**
+ * Sanity cap on `age`. Old events are still valid samples (the server computes
+ * `age` at response time, so `ts + age` is its clock regardless of the event's
+ * own age) — this only rejects garbage.
+ */
+export const SKEW_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
+
+export type ClockSkewState = {
+ /** Median of the recent samples, ms; positive = local clock is AHEAD. */
+ skewMs: number | null;
+ /** Over the threshold (with hysteresis). */
+ warning: boolean;
+};
+
+const median = (xs: number[]): number => {
+ const s = [...xs].sort((a, b) => a - b);
+ const mid = Math.floor(s.length / 2);
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
+};
+
+export class ClockSkewMonitor {
+ private samples: number[] = [];
+
+ private state: ClockSkewState = { skewMs: null, warning: false };
+
+ private listeners = new Set<(state: ClockSkewState) => void>();
+
+ public getState(): ClockSkewState {
+ return this.state;
+ }
+
+ public subscribe(cb: (state: ClockSkewState) => void): () => void {
+ this.listeners.add(cb);
+ return () => {
+ this.listeners.delete(cb);
+ };
+ }
+
+ /**
+ * Feed one live event. `originServerTs` + `age` come from the event;
+ * `localTimestamp` is the SDK's `Date.now() − age` at construction.
+ * Returns the new state (unchanged object when nothing moved).
+ */
+ public sample(
+ originServerTs: number,
+ age: number | undefined,
+ localTimestamp: number,
+ ): ClockSkewState {
+ if (age === undefined || !Number.isFinite(age) || age < 0 || age > SKEW_MAX_AGE_MS) {
+ return this.state;
+ }
+ if (!Number.isFinite(originServerTs) || !Number.isFinite(localTimestamp)) return this.state;
+ this.samples.push(localTimestamp - originServerTs);
+ if (this.samples.length > SKEW_SAMPLES) this.samples.shift();
+ if (this.samples.length < SKEW_MIN_SAMPLES) return this.state;
+
+ const skewMs = median(this.samples);
+ const abs = Math.abs(skewMs);
+ const warning = this.state.warning ? abs >= SKEW_CLEAR_MS : abs > SKEW_WARN_MS;
+ if (skewMs === this.state.skewMs && warning === this.state.warning) return this.state;
+ this.state = { skewMs, warning };
+ this.listeners.forEach((cb) => cb(this.state));
+ return this.state;
+ }
+
+ public reset(): void {
+ this.samples = [];
+ if (this.state.skewMs !== null || this.state.warning) {
+ this.state = { skewMs: null, warning: false };
+ this.listeners.forEach((cb) => cb(this.state));
+ }
+ }
+}
+
+/** "14 minutes ahead" / "2 hours behind" / "45 seconds ahead". */
+export const formatSkew = (skewMs: number): string => {
+ const abs = Math.abs(skewMs);
+ const dir = skewMs > 0 ? 'ahead' : 'behind';
+ const unit = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
+ if (abs >= 36 * 60 * 60 * 1000)
+ return `${unit(Math.round(abs / (24 * 60 * 60 * 1000)), 'day')} ${dir}`;
+ if (abs >= 90 * 60 * 1000) return `${unit(Math.round(abs / (60 * 60 * 1000)), 'hour')} ${dir}`;
+ if (abs >= 90 * 1000) return `${unit(Math.round(abs / 60_000), 'minute')} ${dir}`;
+ return `${unit(Math.round(abs / 1000), 'second')} ${dir}`;
+};
+
+/** "14 minutes ahead of the server" / "3 hours behind the server". */
+export const describeSkewVsServer = (skewMs: number): string =>
+ formatSkew(skewMs)
+ .replace(/ ahead$/, ' ahead of the server')
+ .replace(/ behind$/, ' behind the server');
+
+export type ClockFixPlatform = 'windows' | 'mac' | 'linux' | 'ios' | 'android' | 'other';
+
+export const detectClockFixPlatform = (ua: string): ClockFixPlatform => {
+ if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
+ if (/Android/i.test(ua)) return 'android';
+ if (/Windows/i.test(ua)) return 'windows';
+ if (/Mac OS X|Macintosh/i.test(ua)) return 'mac';
+ if (/Linux|X11/i.test(ua)) return 'linux';
+ return 'other';
+};
+
+export const clockFixHint = (platform: ClockFixPlatform): string => {
+ switch (platform) {
+ case 'windows':
+ return 'Windows: Settings → Time & language → Date & time → turn on "Set time automatically", then Sync now.';
+ case 'mac':
+ return 'macOS: System Settings → General → Date & Time → turn on "Set time and date automatically".';
+ case 'linux':
+ return "Linux: enable NTP (e.g. `timedatectl set-ntp true`) or your desktop's Date & Time → Automatic.";
+ case 'ios':
+ return 'iOS: Settings → General → Date & Time → Set Automatically.';
+ case 'android':
+ return 'Android: Settings → System → Date & time → Set time automatically.';
+ default:
+ return "Turn on automatic (network) time in your operating system's date & time settings.";
+ }
+};