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
+11 -8
View File
@@ -9,6 +9,7 @@ import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useRoom } from '../../hooks/useRoom';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
import { getMouseEventCords } from '../../utils/dom';
import * as css from './styles.css';
@@ -51,14 +52,16 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
}
>
<Box grow="Yes" gap="300" alignItems="Center">
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
<AvatarDecoration userId={userId}>
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</AvatarDecoration>
<Box grow="Yes">
<Text size="L400" truncate>
{name}
+49 -5
View File
@@ -1,6 +1,13 @@
import React, { RefObject, useRef } from 'react';
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
import { useSetAtom } from 'jotai';
import { Badge, Box, Button, color, Header, Icon, Icons, Scroll, Text, toRem } from 'folds';
import {
useCallEmbed,
useCallJoined,
useCallEmbedPlacementSync,
useCallLoadError,
} from '../../hooks/useCallEmbed';
import { callEmbedAtom } from '../../state/callEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { PrescreenControls } from './PrescreenControls';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
@@ -153,6 +160,37 @@ function CallPrescreen() {
);
}
function CallLoadErrorMessage() {
const setCallEmbed = useSetAtom(callEmbedAtom);
// Disposing the embed tears down the hung iframe and returns the user to the
// prescreen, from which they can join again ("Retry") or simply walk away.
const dismiss = () => setCallEmbed(undefined);
return (
<Box
className={css.CallViewContent}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
>
<Icon src={Icons.Warning} size="400" style={{ color: color.Critical.Main }} />
<Text style={{ color: color.Critical.Main }} size="L400" align="Center">
The call failed to load. Check your connection and try again.
</Text>
<Box gap="200" alignItems="Center">
<Button variant="Primary" size="300" radii="400" onClick={dismiss}>
<Text size="B400">Retry</Text>
</Button>
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
<Text size="B400">Leave</Text>
</Button>
</Box>
</Box>
);
}
type CallJoinedProps = {
containerRef: RefObject<HTMLDivElement>;
joined: boolean;
@@ -175,8 +213,13 @@ export function CallView() {
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
const loadError = useCallLoadError(callEmbed);
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
const isCurrentRoom = callEmbed?.roomId === room.roomId;
const currentJoined = isCurrentRoom && callJoined;
// Show the recovery UI when this room's embed failed to load and we never
// made it into the call (a hung iframe / blank spinner otherwise).
const showLoadError = isCurrentRoom && !currentJoined && Boolean(loadError);
return (
<Box
@@ -184,8 +227,9 @@ export function CallView() {
style={{ minWidth: toRem(280) }}
grow="Yes"
>
{!currentJoined && <CallPrescreen />}
<CallJoined joined={currentJoined} containerRef={callContainerRef} />
{showLoadError && <CallLoadErrorMessage />}
{!currentJoined && !showLoadError && <CallPrescreen />}
{!showLoadError && <CallJoined joined={currentJoined} containerRef={callContainerRef} />}
</Box>
);
}
+21
View File
@@ -3,6 +3,7 @@ import { MatrixClient, Room } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai';
import {
CallEmbed,
CallLoadErrorReason,
ElementCallThemeKind,
ElementWidgetActions,
useClientWidgetApiEvent,
@@ -156,6 +157,26 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
return joined;
};
/**
* Surfaces a load failure (watchdog timeout or iframe error) from the embedded
* Element Call iframe so the UI can show a recovery affordance instead of an
* indefinite "Loading..." spinner.
*/
export const useCallLoadError = (embed?: CallEmbed): CallLoadErrorReason | undefined => {
const [error, setError] = useState<CallLoadErrorReason | undefined>(() => embed?.loadFailed);
useEffect(() => {
if (!embed) {
setError(undefined);
return undefined;
}
setError(embed.loadFailed);
return embed.onLoadError((reason) => setError(reason));
}, [embed]);
return error;
};
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
+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();