fix(calls): the call bar shows "Call disconnected" and reconnects when sync is back (#318)

After a homeserver stall Element Call showed "Connection lost" while the
call bar still looked joined. Handle the fork's io.lotus.call_error:
show "Call disconnected" with a Reconnect button (io.lotus.reconnect),
and reconnect automatically once sync is back.

Fixes #318

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
Lotus CI
2026-10-18 00:55:29 -04:00
co-authored by Claude Opus 5.5
parent 906a034e26
commit fe70616412
7 changed files with 139 additions and 2 deletions
+2
View File
@@ -86,6 +86,7 @@ import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
import { zIndices } from '../styles/zIndex';
import { useReconnectWhenSynced } from '../hooks/useCallError';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112;
@@ -793,6 +794,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useCallPolicyRevokedToast(embed, joined);
useCallEndedToast(embed);
useScreenshareNotices(embed);
useReconnectWhenSynced(embed.control);
// [Gitea #43] The in-frame screenshare button follows the same room policy
// as the host bar's (hidden where the server would refuse the share).
const { allowScreenshare } = useRoomCallPolicy(embed.room);
+35 -2
View File
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { Box, Spinner, Text, color } from 'folds';
import { Box, Button, Spinner, Text, color } from 'folds';
import { useAtomValue } from 'jotai';
import classNames from 'classnames';
import { LiveChip } from './LiveChip';
@@ -18,6 +18,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MemberSpeaking } from './MemberSpeaking';
import { clockSkewAtom } from '../../state/clockSkew';
import { describeSkewVsServer } from '../../utils/clockSkew';
import { useCallError } from '../../hooks/useCallError';
type CallStatusProps = {
callEmbed: CallEmbed;
@@ -42,6 +43,8 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
const clockSkew = useAtomValue(clockSkewAtom);
const compact = screenSize === ScreenSize.Mobile;
// [Gitea #318] The call dropped ("Connection lost" in the frame).
const callError = useCallError(callEmbed.control);
const memberVisible = callJoined && callMembers.length > 0;
@@ -65,6 +68,25 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
{!compact && (
<>
<CallRoomName room={room} />
{callError && (
<>
<StatusDivider />
<Text size="T200" truncate role="alert" style={{ color: color.Critical.Main }}>
Call disconnected
</Text>
{callError.recoverable && (
<Button
size="300"
radii="300"
variant="Critical"
fill="Soft"
onClick={() => callEmbed.control.reconnect()}
>
<Text size="B300">Reconnect</Text>
</Button>
)}
</>
)}
{clockSkew.warning && clockSkew.skewMs !== null && (
<>
<StatusDivider />
@@ -102,8 +124,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
{memberVisible && !compact && <StatusDivider />}
<Box shrink="No" alignItems="Center" gap="Inherit">
{compact && (
<Box grow="Yes">
<Box grow="Yes" alignItems="Center" gap="200">
<CallRoomName room={room} />
{callError?.recoverable && (
<Button
size="300"
radii="300"
variant="Critical"
fill="Soft"
onClick={() => callEmbed.control.reconnect()}
>
<Text size="B300">Reconnect</Text>
</Button>
)}
</Box>
)}
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
+41
View File
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { SyncState } from 'matrix-js-sdk';
import { CALL_ERROR_CHANGED, CallControl } from '../plugins/call/CallControl';
import { CallErrorState } from '../plugins/call/callError';
import { useMatrixClient } from './useMatrixClient';
import { useSyncState } from './useSyncState';
/** [Gitea #318] The fork's current call error (null while the call is fine). */
export function useCallError(control: CallControl): CallErrorState {
const [err, setErr] = useState(control.callError);
useEffect(() => {
const on = () => setErr(control.callError);
on();
control.on(CALL_ERROR_CHANGED, on);
return () => {
control.off(CALL_ERROR_CHANGED, on);
};
}, [control]);
return err;
}
/**
* [Gitea #318] After a homeserver stall the call drops with "Connection
* lost". Once sync is back, reconnect automatically (once per drop).
*/
export function useReconnectWhenSynced(control: CallControl): void {
const mx = useMatrixClient();
const err = useCallError(control);
const tried = useRef<CallErrorState>(null);
useSyncState(
mx,
useCallback(
(state: SyncState | null) => {
if (state !== SyncState.Syncing || !err?.recoverable || tried.current === err) return;
tried.current = err;
control.reconnect();
},
[err, control],
),
);
}
+24
View File
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
import { CallControlState } from './CallControlState';
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
import { canDelegateCapability } from './utils';
import { CallErrorState, parseCallError } from './callError';
export enum CallControlEvent {
StateUpdate = 'state_update',
@@ -54,6 +55,8 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
};
}
export const CALL_ERROR_CHANGED = 'call_error_changed';
export class CallControl extends EventEmitter implements CallControlState {
private state: CallControlState;
@@ -205,6 +208,27 @@ export class CallControl extends EventEmitter implements CallControlState {
return this._audioOutputId;
}
// [Gitea #318] The fork's error page ("Connection lost") is up; the host
// kept showing the call as joined with controls that did nothing.
private _callError: CallErrorState = null;
public get callError(): CallErrorState {
return this._callError;
}
/** The fork's `io.lotus.call_error` report. */
public onCallError(data: unknown) {
const next = parseCallError(data);
if (next === undefined) return;
this._callError = next;
this.emit(CALL_ERROR_CHANGED);
}
/** Press the fork's Reconnect / Try again. */
public reconnect(): void {
this.call.transport.send('io.lotus.reconnect', {}).catch(() => undefined);
}
public setAudioOutput(deviceId: string): void {
this._audioOutputId = deviceId;
this.sendAudioOutput(deviceId);
+6
View File
@@ -450,6 +450,12 @@ export class CallEmbed {
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
// [Gitea #318] The fork's error page ("Connection lost") is up / cleared.
this.disposables.push(
this.listenAction('io.lotus.call_error', (evt) => {
this.control.onCallError((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
this.disposables.push(
this.listenAction('io.lotus.hotkey', (evt) => {
+17
View File
@@ -0,0 +1,17 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseCallError } from './callError';
test('parseCallError (#318)', () => {
assert.deepEqual(parseCallError({ code: 'CONNECTION_LOST_ERROR', recoverable: true }), {
code: 'CONNECTION_LOST_ERROR',
recoverable: true,
});
assert.deepEqual(parseCallError({ code: 'E2EE_NOT_SUPPORTED' }), {
code: 'E2EE_NOT_SUPPORTED',
recoverable: false,
});
assert.equal(parseCallError({ code: null }), null);
assert.equal(parseCallError({}), undefined);
assert.equal(parseCallError('x'), undefined);
});
+14
View File
@@ -0,0 +1,14 @@
/**
* [Gitea #318] The fork's `io.lotus.call_error`: its error page is showing
* (`{ code, recoverable }`) or was cleared (`{ code: null }`).
*/
export type CallErrorState = { code: string; recoverable: boolean } | null;
/** Pure: parse a report; undefined for garbage (ignored). */
export function parseCallError(data: unknown): CallErrorState | undefined {
if (typeof data !== 'object' || data === null) return undefined;
const { code, recoverable } = data as { code?: unknown; recoverable?: unknown };
if (code === null) return null;
if (typeof code !== 'string' || !code) return undefined;
return { code, recoverable: recoverable === true };
}