0394fce929
- 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>
228 lines
7.0 KiB
TypeScript
228 lines
7.0 KiB
TypeScript
import { createContext, RefObject, useCallback, useContext, useEffect, useState } from 'react';
|
|
import { MatrixClient, Room } from 'matrix-js-sdk';
|
|
import { useSetAtom } from 'jotai';
|
|
import {
|
|
CallEmbed,
|
|
CallLoadErrorReason,
|
|
ElementCallThemeKind,
|
|
ElementWidgetActions,
|
|
useClientWidgetApiEvent,
|
|
} from '../plugins/call';
|
|
import { useMatrixClient } from './useMatrixClient';
|
|
import { ThemeKind, useTheme } from './useTheme';
|
|
import { callEmbedAtom } from '../state/callEmbed';
|
|
import { useResizeObserver } from './useResizeObserver';
|
|
import { CallControlState } from '../plugins/call/CallControlState';
|
|
import { useCallMembersChange, useCallSession } from './useCall';
|
|
import { CallPreferences } from '../state/callPreferences';
|
|
import { useSetting } from '../state/hooks/settings';
|
|
import { NoiseSuppressionMode, settingsAtom } from '../state/settings';
|
|
import { unlockCallSounds } from '../utils/callSounds';
|
|
|
|
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
|
|
|
|
export const CallEmbedContextProvider = CallEmbedContext.Provider;
|
|
|
|
export const useCallEmbed = (): CallEmbed | undefined => {
|
|
const callEmbed = useContext(CallEmbedContext);
|
|
|
|
return callEmbed;
|
|
};
|
|
|
|
const CallEmbedRefContext = createContext<RefObject<HTMLDivElement> | undefined>(undefined);
|
|
export const CallEmbedRefContextProvider = CallEmbedRefContext.Provider;
|
|
export const useCallEmbedRef = (): RefObject<HTMLDivElement> => {
|
|
const ref = useContext(CallEmbedRefContext);
|
|
if (!ref) {
|
|
throw new Error('CallEmbedRef is not provided!');
|
|
}
|
|
return ref;
|
|
};
|
|
|
|
export const createCallEmbed = (
|
|
mx: MatrixClient,
|
|
room: Room,
|
|
dm: boolean,
|
|
themeKind: ElementCallThemeKind,
|
|
container: HTMLElement,
|
|
pref?: CallPreferences,
|
|
denoiseMode: NoiseSuppressionMode = 'browser',
|
|
denoiseModel: string = 'rnnoise',
|
|
denoiseNativeNS: boolean = true,
|
|
denoiseGate: boolean = false,
|
|
denoiseGateThreshold: number = -45,
|
|
forceAudioOff = false,
|
|
): CallEmbed => {
|
|
const rtcSession = mx.matrixRTC.getRoomSession(room);
|
|
const ongoing = rtcSession.memberships.length > 0;
|
|
|
|
const intent = CallEmbed.getIntent(dm, ongoing, pref?.video);
|
|
const initialAudio = forceAudioOff ? false : (pref?.microphone ?? true);
|
|
const initialVideo = pref?.video ?? false;
|
|
const widget = CallEmbed.getWidget(
|
|
mx,
|
|
room,
|
|
intent,
|
|
themeKind,
|
|
denoiseMode,
|
|
denoiseModel,
|
|
denoiseNativeNS,
|
|
denoiseGate,
|
|
denoiseGateThreshold,
|
|
initialAudio,
|
|
initialVideo,
|
|
);
|
|
const controlState =
|
|
pref && new CallControlState(forceAudioOff ? false : pref.microphone, pref.video, pref.sound);
|
|
|
|
const embed = new CallEmbed(mx, room, widget, container, controlState, themeKind);
|
|
|
|
return embed;
|
|
};
|
|
|
|
export const useCallStart = (dm = false) => {
|
|
const mx = useMatrixClient();
|
|
const theme = useTheme();
|
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
|
const callEmbedRef = useCallEmbedRef();
|
|
const [callNoiseSuppression] = useSetting(settingsAtom, 'callNoiseSuppression');
|
|
const [callDenoiseModel] = useSetting(settingsAtom, 'callDenoiseModel');
|
|
const [callDenoiseNativeNS] = useSetting(settingsAtom, 'callDenoiseNativeNS');
|
|
const [callDenoiseGate] = useSetting(settingsAtom, 'callDenoiseGate');
|
|
const [callDenoiseGateThreshold] = useSetting(settingsAtom, 'callDenoiseGateThreshold');
|
|
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
|
|
|
const startCall = useCallback(
|
|
(room: Room, pref?: CallPreferences) => {
|
|
const container = callEmbedRef.current;
|
|
if (!container) {
|
|
throw new Error('Failed to start call, No embed container element found!');
|
|
}
|
|
// startCall is always invoked from a click/tap handler — unlock the Web
|
|
// Audio context now (within the gesture) so join/leave sounds that fire
|
|
// later, without any gesture, are audible to everyone in the call.
|
|
unlockCallSounds();
|
|
const callEmbed = createCallEmbed(
|
|
mx,
|
|
room,
|
|
dm,
|
|
theme.kind,
|
|
container,
|
|
pref,
|
|
callNoiseSuppression ?? 'browser',
|
|
callDenoiseModel ?? 'rnnoise',
|
|
callDenoiseNativeNS ?? true,
|
|
callDenoiseGate ?? false,
|
|
callDenoiseGateThreshold ?? -45,
|
|
!!pttMode,
|
|
);
|
|
|
|
setCallEmbed(callEmbed);
|
|
},
|
|
[
|
|
mx,
|
|
dm,
|
|
theme,
|
|
setCallEmbed,
|
|
callEmbedRef,
|
|
callNoiseSuppression,
|
|
callDenoiseModel,
|
|
callDenoiseNativeNS,
|
|
callDenoiseGate,
|
|
callDenoiseGateThreshold,
|
|
pttMode,
|
|
],
|
|
);
|
|
|
|
return startCall;
|
|
};
|
|
|
|
export const useCallJoined = (embed?: CallEmbed): boolean => {
|
|
const [joined, setJoined] = useState(embed?.joined ?? false);
|
|
|
|
useClientWidgetApiEvent(
|
|
embed?.call,
|
|
ElementWidgetActions.JoinCall,
|
|
useCallback(() => {
|
|
setJoined(true);
|
|
}, []),
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!embed) {
|
|
setJoined(false);
|
|
}
|
|
}, [embed]);
|
|
|
|
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);
|
|
};
|
|
|
|
export const useCallMemberSoundSync = (embed: CallEmbed) => {
|
|
const callSession = useCallSession(embed.room);
|
|
useCallMembersChange(
|
|
callSession,
|
|
useCallback(() => embed.control.applySound(), [embed]),
|
|
);
|
|
};
|
|
|
|
export const useCallThemeSync = (embed: CallEmbed) => {
|
|
const theme = useTheme();
|
|
|
|
useEffect(() => {
|
|
const name: ElementCallThemeKind = theme.kind === ThemeKind.Dark ? 'dark' : 'light';
|
|
|
|
embed.setTheme(name);
|
|
}, [theme.kind, embed]);
|
|
};
|
|
|
|
export const useCallEmbedPlacementSync = (containerViewRef: RefObject<HTMLDivElement>): void => {
|
|
const callEmbedRef = useCallEmbedRef();
|
|
|
|
const syncCallEmbedPlacement = useCallback(() => {
|
|
const embedEl = callEmbedRef.current;
|
|
const container = containerViewRef.current;
|
|
if (!embedEl || !container) return;
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
embedEl.style.top = `${rect.top}px`;
|
|
embedEl.style.left = `${rect.left}px`;
|
|
embedEl.style.width = `${rect.width}px`;
|
|
embedEl.style.height = `${rect.height}px`;
|
|
}, [callEmbedRef, containerViewRef]);
|
|
|
|
// Sync once on mount so the embed is positioned immediately (deps are stable refs)
|
|
useEffect(() => {
|
|
syncCallEmbedPlacement();
|
|
}, [syncCallEmbedPlacement]);
|
|
|
|
useResizeObserver(
|
|
syncCallEmbedPlacement,
|
|
useCallback(() => containerViewRef.current, [containerViewRef]),
|
|
);
|
|
};
|