feat(calls): EC iframe load watchdog + recovery UI; avatar decorations on call tiles
- CallEmbed: 25s load watchdog that fails fast on iframe error / preparing-error / timeout instead of hanging on a permanent spinner; additive onLoadError API, cleared on ready/capabilities/joined. - CallView: user-visible "call failed to load" overlay with Retry/Leave (folds + tokens) via a new useCallLoadError hook. - CallMemberCard: wrap the participant avatar in AvatarDecoration so decorations render in the call roster (the tile rendered UserAvatar bare while member lists already wrapped it). Addresses LOTUS_BUGS item 3 (avatar decorations in calls) and EC iframe failure monitoring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,14 @@ import {
|
||||
import { CallControl } from './CallControl';
|
||||
import { CallControlState } from './CallControlState';
|
||||
|
||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||
// iframe has hung (e.g. blocked network, crashed widget, blank "Loading...")
|
||||
// and surface a recoverable error instead of an indefinite spinner.
|
||||
const CALL_LOAD_WATCHDOG_MS = 25_000;
|
||||
|
||||
export type CallLoadErrorReason = 'timeout' | 'iframe';
|
||||
|
||||
export class CallEmbed {
|
||||
private mx: MatrixClient;
|
||||
|
||||
@@ -55,6 +63,15 @@ export class CallEmbed {
|
||||
|
||||
private themeKind: ElementCallThemeKind = 'dark';
|
||||
|
||||
// Watchdog: detects an iframe that never reaches a usable state.
|
||||
private loadWatchdog?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private loadSettled = false;
|
||||
|
||||
private loadError?: CallLoadErrorReason;
|
||||
|
||||
private readonly loadErrorListeners = new Set<(reason: CallLoadErrorReason) => void>();
|
||||
|
||||
// Arrow-function class fields so dispose() passes the exact same reference to mx.off()
|
||||
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
|
||||
|
||||
@@ -218,6 +235,19 @@ export class CallEmbed {
|
||||
iframe.onload = () => {
|
||||
this.control.startObserving();
|
||||
};
|
||||
// If the iframe document itself fails to load, fail fast.
|
||||
iframe.onerror = () => {
|
||||
this.settleLoad('iframe');
|
||||
};
|
||||
|
||||
// Clear the watchdog as soon as the call reaches any usable state. The
|
||||
// happy path (onCallJoined) clears it too; these cover earlier signals so a
|
||||
// user sitting in the lobby/prescreen isn't flagged as an error.
|
||||
this.disposables.push(this.onReady(() => this.settleLoad()));
|
||||
this.disposables.push(this.onCapabilitiesNotified(() => this.settleLoad()));
|
||||
this.disposables.push(this.onPreparingError(() => this.settleLoad('iframe')));
|
||||
|
||||
this.startLoadWatchdog();
|
||||
|
||||
let initialMediaEvent = true;
|
||||
this.disposables.push(
|
||||
@@ -314,6 +344,8 @@ export class CallEmbed {
|
||||
this.disposables.forEach((disposable) => {
|
||||
disposable();
|
||||
});
|
||||
this.clearLoadWatchdog();
|
||||
this.loadErrorListeners.clear();
|
||||
this.styleRetryObserver?.disconnect();
|
||||
this.call.stop();
|
||||
this.container.removeChild(this.iframe);
|
||||
@@ -329,7 +361,57 @@ export class CallEmbed {
|
||||
this.eventsToFeed = new WeakSet<MatrixEvent>();
|
||||
}
|
||||
|
||||
private startLoadWatchdog(): void {
|
||||
if (this.loadWatchdog !== undefined) return;
|
||||
this.loadWatchdog = setTimeout(() => {
|
||||
this.settleLoad('timeout');
|
||||
}, CALL_LOAD_WATCHDOG_MS);
|
||||
}
|
||||
|
||||
private clearLoadWatchdog(): void {
|
||||
if (this.loadWatchdog !== undefined) {
|
||||
clearTimeout(this.loadWatchdog);
|
||||
this.loadWatchdog = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the load lifecycle as settled. Called on success (no reason) or on
|
||||
* failure (reason set). Idempotent so the first signal wins.
|
||||
*/
|
||||
private settleLoad(reason?: CallLoadErrorReason): void {
|
||||
if (this.loadSettled) return;
|
||||
this.loadSettled = true;
|
||||
this.clearLoadWatchdog();
|
||||
if (reason) {
|
||||
this.loadError = reason;
|
||||
this.loadErrorListeners.forEach((cb) => cb(reason));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the call failed to load within the watchdog window or errored.
|
||||
*/
|
||||
public get loadFailed(): CallLoadErrorReason | undefined {
|
||||
return this.loadError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to load-failure events (watchdog timeout or iframe error). If the
|
||||
* load has already failed by the time of subscription, the callback fires
|
||||
* immediately so late subscribers still see the error.
|
||||
* @returns an unsubscribe function.
|
||||
*/
|
||||
public onLoadError(callback: (reason: CallLoadErrorReason) => void): () => void {
|
||||
this.loadErrorListeners.add(callback);
|
||||
if (this.loadError) callback(this.loadError);
|
||||
return () => {
|
||||
this.loadErrorListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private onCallJoined(): void {
|
||||
this.settleLoad();
|
||||
this.joined = true;
|
||||
this.applyStyles();
|
||||
this.control.startObserving();
|
||||
|
||||
Reference in New Issue
Block a user