CI / Build & Quality Checks (pull_request) Successful in 3m1s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Failing after 11m28s
Incident 2026-09-29: the homeserver's host ran out of memory and stalled for ~2 minutes. The /sync that finally went out carried events whose `age` was computed ~30 s before it arrived, so every client showed "Your computer's clock is 30 seconds ahead of the server" while the real problem was the server (all host clocks were within 0.25 s the whole evening). The skew estimate was the median of the last 5 samples, and a sample is local skew + delivery delay, so one late /sync with a handful of events tripped it. - Estimate = the LOWEST sample of the last 5 minutes: delay only ever adds, so the fastest-delivered event is the truest. - "Behind" (which a delay can't cause) is reported as soon as there are 3 samples, like before. "Ahead" must hold across samples received at least a minute apart, so a single late burst never trips it. - Samples are aged on the monotonic clock, and a change of the local clock (someone fixing it) resets the measurement, so the warning clears at once. - Only events stamped by our own homeserver are sampled: a federated event's origin_server_ts is the other server's clock. - Wording: "This device's clock is … Voice calls and encrypted messages can fail until it's corrected." / call bar "Device clock … : calls may fail" (was "will fail"). Unit tests: the incident (late burst after normal traffic, and a fresh client whose first samples are all late), mixed slow/fast deliveries, ahead only after a minute, behind at once, hysteresis, clock fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
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';
|
|
import { CallRoomName } from './CallRoomName';
|
|
import { CallControl } from './CallControl';
|
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
|
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
|
import { MemberGlance } from './MemberGlance';
|
|
import { StatusDivider } from './components';
|
|
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
|
import { useCallJoined } from '../../hooks/useCallEmbed';
|
|
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { MemberSpeaking } from './MemberSpeaking';
|
|
import { clockSkewAtom } from '../../state/clockSkew';
|
|
import { describeSkewVsServer } from '../../utils/clockSkew';
|
|
|
|
type CallStatusProps = {
|
|
callEmbed: CallEmbed;
|
|
};
|
|
export function CallStatus({ callEmbed }: CallStatusProps) {
|
|
const { room } = callEmbed;
|
|
|
|
const callSession = useCallSession(room);
|
|
const callMembers = useCallMembers(callSession);
|
|
const screenSize = useScreenSize();
|
|
const callJoined = useCallJoined(callEmbed);
|
|
const speakers = useCallSpeakers(callEmbed);
|
|
// [Gitea #146] Your own voice is on the mic button's level meter; the
|
|
// "… is speaking" line is for everyone else. (Avatar rings still include you.)
|
|
const mx = useMatrixClient();
|
|
const myUserId = mx.getUserId();
|
|
const otherSpeakers = useMemo(
|
|
() => new Set([...speakers].filter((id) => id !== myUserId)),
|
|
[speakers, myUserId],
|
|
);
|
|
// [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;
|
|
|
|
const memberVisible = callJoined && callMembers.length > 0;
|
|
|
|
return (
|
|
<Box
|
|
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
|
|
shrink="No"
|
|
gap="400"
|
|
alignItems={compact ? undefined : 'Center'}
|
|
direction={compact ? 'Column' : 'Row'}
|
|
>
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|
{memberVisible ? (
|
|
<Box shrink="No">
|
|
<LiveChip count={callMembers.length} room={room} members={callMembers} />
|
|
</Box>
|
|
) : (
|
|
<Spinner variant="Secondary" size="200" />
|
|
)}
|
|
<Box grow="Yes" alignItems="Center" gap="Inherit">
|
|
{!compact && (
|
|
<>
|
|
<CallRoomName room={room} />
|
|
{clockSkew.warning && clockSkew.skewMs !== null && (
|
|
<>
|
|
<StatusDivider />
|
|
<Text
|
|
size="T200"
|
|
truncate
|
|
style={{ color: color.Warning.Main }}
|
|
title="This device's clock is off. Calls and encryption depend on it: turn on automatic time in your system settings."
|
|
>
|
|
Device clock {describeSkewVsServer(clockSkew.skewMs)}: calls may fail
|
|
</Text>
|
|
</>
|
|
)}
|
|
{otherSpeakers.size > 0 && (
|
|
<>
|
|
<StatusDivider />
|
|
<span data-spacing-node />
|
|
<MemberSpeaking room={room} speakers={otherSpeakers} />
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Box>
|
|
{memberVisible && (
|
|
<Box shrink="No">
|
|
<MemberGlance
|
|
room={room}
|
|
members={callMembers}
|
|
speakers={speakers}
|
|
callEmbed={callEmbed}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
{memberVisible && !compact && <StatusDivider />}
|
|
<Box shrink="No" alignItems="Center" gap="Inherit">
|
|
{compact && (
|
|
<Box grow="Yes">
|
|
<CallRoomName room={room} />
|
|
</Box>
|
|
)}
|
|
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|