diff --git a/package-lock.json b/package-lock.json
index a89d1508e..da81dbbf9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
- "@lotusguild/element-call-embedded": "0.25.0-lotus.24",
+ "@lotusguild/element-call-embedded": "0.25.0-lotus.25",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
- "version": "0.25.0-lotus.24",
- "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.24/element-call-embedded-0.25.0-lotus.24.tgz",
- "integrity": "sha512-6cMjJ0WCGvZJridoF0YslvF3giYar/uLgc/JAlgLqBg+Irv+Mm/xWuC2bme60BT+f/wmEX30gr7WDeIc4JUL7w==",
+ "version": "0.25.0-lotus.25",
+ "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.25/element-call-embedded-0.25.0-lotus.25.tgz",
+ "integrity": "sha512-Vvw6B+QoG0/qFJcBzhlAbE+vnk83DikpeeF2xbygpoHfmG9r1H/mS47V91jAMH2vmNbGiALwFlLbJjEET43A2g==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
diff --git a/package.json b/package.json
index e14e9e2c5..66da31d52 100644
--- a/package.json
+++ b/package.json
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
- "@lotusguild/element-call-embedded": "0.25.0-lotus.24",
+ "@lotusguild/element-call-embedded": "0.25.0-lotus.25",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx
index 12c1f4b11..aeb37870d 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';
import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
const PIP_MIN_W = 200;
@@ -817,6 +818,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useCallPolicyRevokedToast(embed, joined);
useCallEndedToast(embed);
useScreenshareNotices(embed);
+ useReconnectWhenSynced(embed.control);
useDenoiseNotices(embed);
// [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).
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 aefcb1ea5..e73c041ca 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 type AudioOutputOption = { id: string; label: string };
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
@@ -233,6 +236,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 a5532a87b..cc7496acc 100644
--- a/src/app/plugins/call/CallEmbed.ts
+++ b/src/app/plugins/call/CallEmbed.ts
@@ -453,6 +453,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 #310] The fork's own output list + selection (frame-valid ids).
this.disposables.push(
this.listenAction('io.lotus.audio_output_state', (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 };
+}