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 }; +}