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:
2026-06-24 08:22:01 -04:00
parent d2946c00ce
commit 0394fce929
4 changed files with 163 additions and 13 deletions
+82
View File
@@ -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();