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 { useRoom } from '../../hooks/useRoom';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
|
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||||
import { getMouseEventCords } from '../../utils/dom';
|
import { getMouseEventCords } from '../../utils/dom';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
|
|
||||||
@@ -51,14 +52,16 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" gap="300" alignItems="Center">
|
<Box grow="Yes" gap="300" alignItems="Center">
|
||||||
<Avatar size="200" radii="400">
|
<AvatarDecoration userId={userId}>
|
||||||
<UserAvatar
|
<Avatar size="200" radii="400">
|
||||||
userId={userId}
|
<UserAvatar
|
||||||
src={avatarUrl}
|
userId={userId}
|
||||||
alt={name}
|
src={avatarUrl}
|
||||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
alt={name}
|
||||||
/>
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
</Avatar>
|
/>
|
||||||
|
</Avatar>
|
||||||
|
</AvatarDecoration>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="L400" truncate>
|
<Text size="L400" truncate>
|
||||||
{name}
|
{name}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import React, { RefObject, useRef } from 'react';
|
import React, { RefObject, useRef } from 'react';
|
||||||
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
|
import { useSetAtom } from 'jotai';
|
||||||
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
|
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 { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { PrescreenControls } from './PrescreenControls';
|
import { PrescreenControls } from './PrescreenControls';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
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 = {
|
type CallJoinedProps = {
|
||||||
containerRef: RefObject<HTMLDivElement>;
|
containerRef: RefObject<HTMLDivElement>;
|
||||||
joined: boolean;
|
joined: boolean;
|
||||||
@@ -175,8 +213,13 @@ export function CallView() {
|
|||||||
|
|
||||||
const callEmbed = useCallEmbed();
|
const callEmbed = useCallEmbed();
|
||||||
const callJoined = useCallJoined(callEmbed);
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -184,8 +227,9 @@ export function CallView() {
|
|||||||
style={{ minWidth: toRem(280) }}
|
style={{ minWidth: toRem(280) }}
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
>
|
>
|
||||||
{!currentJoined && <CallPrescreen />}
|
{showLoadError && <CallLoadErrorMessage />}
|
||||||
<CallJoined joined={currentJoined} containerRef={callContainerRef} />
|
{!currentJoined && !showLoadError && <CallPrescreen />}
|
||||||
|
{!showLoadError && <CallJoined joined={currentJoined} containerRef={callContainerRef} />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MatrixClient, Room } from 'matrix-js-sdk';
|
|||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
CallEmbed,
|
CallEmbed,
|
||||||
|
CallLoadErrorReason,
|
||||||
ElementCallThemeKind,
|
ElementCallThemeKind,
|
||||||
ElementWidgetActions,
|
ElementWidgetActions,
|
||||||
useClientWidgetApiEvent,
|
useClientWidgetApiEvent,
|
||||||
@@ -156,6 +157,26 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
|
|||||||
return joined;
|
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) => {
|
export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
|
||||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
|
||||||
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ import {
|
|||||||
import { CallControl } from './CallControl';
|
import { CallControl } from './CallControl';
|
||||||
import { CallControlState } from './CallControlState';
|
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 {
|
export class CallEmbed {
|
||||||
private mx: MatrixClient;
|
private mx: MatrixClient;
|
||||||
|
|
||||||
@@ -55,6 +63,15 @@ export class CallEmbed {
|
|||||||
|
|
||||||
private themeKind: ElementCallThemeKind = 'dark';
|
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()
|
// Arrow-function class fields so dispose() passes the exact same reference to mx.off()
|
||||||
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
|
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
|
||||||
|
|
||||||
@@ -218,6 +235,19 @@ export class CallEmbed {
|
|||||||
iframe.onload = () => {
|
iframe.onload = () => {
|
||||||
this.control.startObserving();
|
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;
|
let initialMediaEvent = true;
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
@@ -314,6 +344,8 @@ export class CallEmbed {
|
|||||||
this.disposables.forEach((disposable) => {
|
this.disposables.forEach((disposable) => {
|
||||||
disposable();
|
disposable();
|
||||||
});
|
});
|
||||||
|
this.clearLoadWatchdog();
|
||||||
|
this.loadErrorListeners.clear();
|
||||||
this.styleRetryObserver?.disconnect();
|
this.styleRetryObserver?.disconnect();
|
||||||
this.call.stop();
|
this.call.stop();
|
||||||
this.container.removeChild(this.iframe);
|
this.container.removeChild(this.iframe);
|
||||||
@@ -329,7 +361,57 @@ export class CallEmbed {
|
|||||||
this.eventsToFeed = new WeakSet<MatrixEvent>();
|
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 {
|
private onCallJoined(): void {
|
||||||
|
this.settleLoad();
|
||||||
this.joined = true;
|
this.joined = true;
|
||||||
this.applyStyles();
|
this.applyStyles();
|
||||||
this.control.startObserving();
|
this.control.startObserving();
|
||||||
|
|||||||
Reference in New Issue
Block a user