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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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