diff --git a/src/app/features/call/CallMemberCard.tsx b/src/app/features/call/CallMemberCard.tsx index 95cafa404..793853d4a 100644 --- a/src/app/features/call/CallMemberCard.tsx +++ b/src/app/features/call/CallMemberCard.tsx @@ -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) { } > - - } - /> - + + + } + /> + + {name} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 9c8117020..2edef8910 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -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 ( + + + + The call failed to load. Check your connection and try again. + + + + + + + ); +} + type CallJoinedProps = { containerRef: RefObject; 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 ( - {!currentJoined && } - + {showLoadError && } + {!currentJoined && !showLoadError && } + {!showLoadError && } ); } diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index 785f10a13..5180ff6b3 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -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(() => 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); diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 36167671e..9f1387107 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -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; + + 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(); } + 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();