diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx
index 17e054da5..f1ad75a36 100644
--- a/src/app/components/CallEmbedProvider.tsx
+++ b/src/app/components/CallEmbedProvider.tsx
@@ -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);
diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx
index 4b4be1543..9fb61fd2e 100644
--- a/src/app/features/call-status/CallStatus.tsx
+++ b/src/app/features/call-status/CallStatus.tsx
@@ -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 && (
<>
+ {callError && (
+ <>
+
+
+ Call disconnected
+
+ {callError.recoverable && (
+
+ )}
+ >
+ )}
{clockSkew.warning && clockSkew.skewMs !== null && (
<>
@@ -102,8 +124,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
{memberVisible && !compact && }
{compact && (
-
+
+ {callError?.recoverable && (
+
+ )}
)}
diff --git a/src/app/hooks/useCallError.ts b/src/app/hooks/useCallError.ts
new file mode 100644
index 000000000..3f9119938
--- /dev/null
+++ b/src/app/hooks/useCallError.ts
@@ -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(null);
+ useSyncState(
+ mx,
+ useCallback(
+ (state: SyncState | null) => {
+ if (state !== SyncState.Syncing || !err?.recoverable || tried.current === err) return;
+ tried.current = err;
+ control.reconnect();
+ },
+ [err, control],
+ ),
+ );
+}
diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts
index da734785c..5d1580d44 100644
--- a/src/app/plugins/call/CallControl.ts
+++ b/src/app/plugins/call/CallControl.ts
@@ -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);
diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts
index 0ef609645..9f28686eb 100644
--- a/src/app/plugins/call/CallEmbed.ts
+++ b/src/app/plugins/call/CallEmbed.ts
@@ -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) => {
diff --git a/src/app/plugins/call/callError.test.ts b/src/app/plugins/call/callError.test.ts
new file mode 100644
index 000000000..8759985fa
--- /dev/null
+++ b/src/app/plugins/call/callError.test.ts
@@ -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);
+});
diff --git a/src/app/plugins/call/callError.ts b/src/app/plugins/call/callError.ts
new file mode 100644
index 000000000..f9e0889df
--- /dev/null
+++ b/src/app/plugins/call/callError.ts
@@ -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 };
+}