Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e7915d086 | ||
|
|
84c906fe33 |
@@ -47,6 +47,9 @@ import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ri
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
||||
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||
import { SrOnly } from '../features/call-status/styles.css';
|
||||
import { useCallHotkeys } from '../hooks/useCallHotkeys';
|
||||
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
|
||||
import { useCallQuality } from '../hooks/useCallQuality';
|
||||
@@ -676,6 +679,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
);
|
||||
}
|
||||
|
||||
// [Gitea #168] Lives outside the embed so "Call ended" is still announced.
|
||||
function CallAnnouncementRegion() {
|
||||
const announcement = useAtomValue(callAnnouncementAtom);
|
||||
return (
|
||||
<span className={SrOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||
{announcement}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
@@ -687,6 +700,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
useAfkAutoMute(joined ? embed : undefined);
|
||||
useCallJoinLeaveSounds(embed);
|
||||
useCallPolicyRevokedToast(embed, joined);
|
||||
useCallAnnouncements(embed, joined);
|
||||
useCallThemeSync(embed);
|
||||
useCallQuality(embed);
|
||||
useCallHangupEvent(
|
||||
@@ -1230,6 +1244,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />}
|
||||
<CallAnnouncementRegion />
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||
{children}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<CallRoomName room={room} />
|
||||
{clockSkew.warning && clockSkew.skewMs !== null && (
|
||||
<>
|
||||
<StatusDivider />
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ color: color.Warning.Main }}
|
||||
title="Fix your computer's clock — calls and encryption depend on it"
|
||||
>
|
||||
Clock {describeSkewVsServer(clockSkew.skewMs)} — calls will fail
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{speakers.size > 0 && (
|
||||
<>
|
||||
<StatusDivider />
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useCallMembersChange, useCallSession } from './useCall';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getMemberName } from '../utils/room';
|
||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||
|
||||
const BURST_MS = 1500;
|
||||
|
||||
const listNames = (names: string[], verb: string): string => {
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return `${names[0]} ${verb}`;
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]} ${verb}`;
|
||||
return `${names.length} people ${verb}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #168] Screen-reader announcements for call events, via the
|
||||
* aria-live region CallEmbedProvider renders. Nothing visible, nothing
|
||||
* audible for anyone else. Joins/leaves within 1.5 s are batched ("3 people
|
||||
* joined"); your own mute / deafen / screenshare changes are announced as
|
||||
* they happen; "Call ended" fires when the embed goes away.
|
||||
*/
|
||||
export function useCallAnnouncements(embed: CallEmbed, joined: boolean): void {
|
||||
const mx = useMatrixClient();
|
||||
const setAnnouncement = useSetAtom(callAnnouncementAtom);
|
||||
const session = useCallSession(embed.room);
|
||||
const { microphone, sound, screenshare } = useCallControlState(embed.control);
|
||||
|
||||
const announce = useCallback(
|
||||
(text: string) => {
|
||||
// Re-announce identical text by nudging it: aria-live only speaks changes.
|
||||
setAnnouncement((prev) => (prev === text ? `${text} ` : text));
|
||||
},
|
||||
[setAnnouncement],
|
||||
);
|
||||
|
||||
// --- joins / leaves, batched per user
|
||||
const prevUsersRef = useRef<Set<string> | null>(null);
|
||||
const pending = useRef<{ joined: Set<string>; left: Set<string>; timer?: number }>({
|
||||
joined: new Set(),
|
||||
left: new Set(),
|
||||
});
|
||||
useEffect(() => {
|
||||
prevUsersRef.current = new Set(session.memberships.map((m) => m.sender ?? ''));
|
||||
}, [session]);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
const { joined: j, left: l } = pending.current;
|
||||
const room = embed.room;
|
||||
const parts = [
|
||||
listNames(
|
||||
Array.from(j, (u) => getMemberName(room, u)),
|
||||
'joined',
|
||||
),
|
||||
listNames(
|
||||
Array.from(l, (u) => getMemberName(room, u)),
|
||||
'left',
|
||||
),
|
||||
].filter(Boolean);
|
||||
j.clear();
|
||||
l.clear();
|
||||
pending.current.timer = undefined;
|
||||
if (parts.length) announce(parts.join('. '));
|
||||
}, [announce, embed.room]);
|
||||
|
||||
useCallMembersChange(
|
||||
session,
|
||||
useCallback(
|
||||
(members: CallMembership[]) => {
|
||||
const next = new Set(members.map((m) => m.sender ?? ''));
|
||||
const prev = prevUsersRef.current ?? next;
|
||||
prevUsersRef.current = next;
|
||||
if (!joined) return;
|
||||
const me = mx.getSafeUserId();
|
||||
next.forEach((u) => {
|
||||
if (!prev.has(u) && u !== me) {
|
||||
pending.current.left.delete(u);
|
||||
pending.current.joined.add(u);
|
||||
}
|
||||
});
|
||||
prev.forEach((u) => {
|
||||
if (!next.has(u) && u !== me) {
|
||||
pending.current.joined.delete(u);
|
||||
pending.current.left.add(u);
|
||||
}
|
||||
});
|
||||
if (pending.current.timer === undefined) {
|
||||
pending.current.timer = window.setTimeout(flush, BURST_MS);
|
||||
}
|
||||
},
|
||||
[joined, mx, flush],
|
||||
),
|
||||
);
|
||||
|
||||
// --- own state
|
||||
const first = useRef(true);
|
||||
const prevOwn = useRef({ microphone, sound, screenshare });
|
||||
useEffect(() => {
|
||||
const was = prevOwn.current;
|
||||
prevOwn.current = { microphone, sound, screenshare };
|
||||
if (!joined) return;
|
||||
if (first.current) {
|
||||
first.current = false;
|
||||
return;
|
||||
}
|
||||
if (was.microphone !== microphone) announce(microphone ? 'You are unmuted' : 'You are muted');
|
||||
if (was.sound !== sound) announce(sound ? 'You are undeafened' : 'You are deafened');
|
||||
if (was.screenshare !== screenshare) {
|
||||
announce(screenshare ? 'You started sharing your screen' : 'You stopped sharing your screen');
|
||||
}
|
||||
}, [microphone, sound, screenshare, joined, announce]);
|
||||
|
||||
// --- call ended
|
||||
const wasJoined = useRef(false);
|
||||
useEffect(() => {
|
||||
if (joined) wasJoined.current = true;
|
||||
}, [joined]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (pending.current.timer !== undefined) window.clearTimeout(pending.current.timer);
|
||||
if (wasJoined.current) announce('Call ended');
|
||||
},
|
||||
[announce],
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
/**
|
||||
* [Gitea #168] Latest screen-reader-only call announcement ("alice joined",
|
||||
* "You are muted", "Call ended"). Rendered by CallEmbedProvider in an
|
||||
* aria-live region that outlives the call embed, so "Call ended" is heard.
|
||||
*/
|
||||
export const callAnnouncementAtom = atom<string>('');
|
||||
@@ -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<ClockSkewState>({ skewMs: null, warning: false });
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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.";
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user