CI / Build & Quality Checks (pull_request) Successful in 3m31s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m27s
Groundwork for serving the call page from its own origin (call.chat.lotusguild.org). Inert until config.json sets `elementCallUrl`: without it the bundled same-origin page is used exactly as today. - callPageUrl: resolves `elementCallUrl` — absolute https only (http only on localhost for development); anything else, and the desktop app, fall back to the bundled page so a bad value can't break calls. Set once from the loaded client config. - CallEmbed builds the widget URL from it; the widget origin (used by the message guard and Capability Delegation) follows automatically. - Soundboard: a host blob: URL can't be fetched from another origin, so io.lotus.inject_audio now also carries the clip's bytes (`audio`). Forks that predate it ignore the field and use `url`, so this is safe on the released fork. Needs element-call's lotus-call-origin branch (host-origin message check + inject_audio bytes) released and pinned before `elementCallUrl` is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
780 lines
28 KiB
TypeScript
780 lines
28 KiB
TypeScript
import {
|
||
ClientEvent,
|
||
KnownMembership,
|
||
MatrixClient,
|
||
MatrixEvent,
|
||
MatrixEventEvent,
|
||
Room,
|
||
RoomStateEvent,
|
||
} from 'matrix-js-sdk';
|
||
import {
|
||
ClientWidgetApi,
|
||
IRoomEvent,
|
||
IWidget,
|
||
Widget,
|
||
WidgetApiFromWidgetAction,
|
||
WidgetApiToWidgetAction,
|
||
WidgetDriver,
|
||
} from 'matrix-widget-api';
|
||
import { CallWidgetDriver } from './CallWidgetDriver';
|
||
import { trimTrailingSlash } from '../../utils/common';
|
||
import { NoiseSuppressionMode } from '../../state/settings';
|
||
import {
|
||
ElementCallIntent,
|
||
ElementCallThemeKind,
|
||
ElementMediaStateDetail,
|
||
ElementWidgetActions,
|
||
} from './types';
|
||
import { CallControl } from './CallControl';
|
||
import { CallControlState } from './CallControlState';
|
||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||
import { canDelegateCapability } from './utils';
|
||
import { getCallPageUrl } from './callPageUrl';
|
||
import { restrictWidgetMessages } from '../widgetTransport';
|
||
|
||
// 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';
|
||
|
||
/** Payload entry of the fork's io.lotus.call_state widget event (#2). */
|
||
export interface LotusCallParticipant {
|
||
id: string;
|
||
userId: string;
|
||
speaking: boolean;
|
||
audioEnabled: boolean;
|
||
videoEnabled: boolean;
|
||
/** [lotus #37] Local participant only: voice on the mic while it is muted. */
|
||
speakingWhileMuted?: boolean;
|
||
}
|
||
|
||
/** [Gitea #143] The fork's one-shot io.lotus.call_summary payload. */
|
||
export interface LotusCallSummary {
|
||
durationMs: number;
|
||
reconnects: number;
|
||
poorMs: number;
|
||
verdict: 'good' | 'fair' | 'poor' | 'unknown';
|
||
}
|
||
|
||
export class CallEmbed {
|
||
private mx: MatrixClient;
|
||
|
||
public readonly call: ClientWidgetApi;
|
||
|
||
public readonly iframe: HTMLIFrameElement;
|
||
|
||
public readonly room: Room;
|
||
|
||
public joined = false;
|
||
|
||
/** [Gitea #143] When the first JoinCall landed, for the hangup readout. */
|
||
public joinedAt: number | undefined;
|
||
|
||
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
|
||
public lastSummary: LotusCallSummary | undefined;
|
||
|
||
// C-M4: set once dispose() has run so the hangup fallback timer can tell
|
||
// whether the embed was already torn down by the normal Close/Hangup echo.
|
||
public disposed = false;
|
||
|
||
// [lotus #2] Latest per-participant state from io.lotus.call_state, or null
|
||
// until the fork sends the first one. When non-null, the speaker/mute hooks
|
||
// read it instead of scraping the EC iframe DOM.
|
||
private lotusParticipants: LotusCallParticipant[] | null = null;
|
||
|
||
private lotusCallStateListeners = new Set<() => void>();
|
||
|
||
// [Gitea #146] Local mic level (0–3 bars) from io.lotus.mic_level.
|
||
private micLevel = 0;
|
||
|
||
private micLevelListeners = new Set<() => void>();
|
||
|
||
// [Gitea #17] Listeners notified when the fork sends io.lotus.request_state.
|
||
private forkStateRequestListeners = new Set<() => void>();
|
||
|
||
public readonly control: CallControl;
|
||
|
||
private readonly container: HTMLElement;
|
||
|
||
private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
|
||
|
||
private eventsToFeed = new WeakSet<MatrixEvent>();
|
||
|
||
private readonly disposables: Array<() => void> = [];
|
||
|
||
private readonly initialState: CallControlState;
|
||
|
||
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 | undefined) => void
|
||
>();
|
||
|
||
// Arrow-function class fields so dispose() passes the exact same reference to mx.off()
|
||
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
|
||
|
||
private readonly boundOnEventDecrypted = (ev: MatrixEvent) => this.onEventDecrypted(ev);
|
||
|
||
private readonly boundOnStateUpdate = (ev: MatrixEvent) => this.onStateUpdate(ev);
|
||
|
||
private readonly boundOnToDeviceEvent = (ev: MatrixEvent) => this.onToDeviceEvent(ev);
|
||
|
||
static getIntent(dm: boolean, ongoing: boolean, video?: boolean): ElementCallIntent {
|
||
if (dm && ongoing) {
|
||
return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice;
|
||
}
|
||
if (dm) {
|
||
return video ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCallDMVoice;
|
||
}
|
||
|
||
if (ongoing) {
|
||
return video ? ElementCallIntent.JoinExisting : ElementCallIntent.JoinExistingVoice;
|
||
}
|
||
return video ? ElementCallIntent.StartCall : ElementCallIntent.StartCallVoice;
|
||
}
|
||
|
||
static dmCall(intent: ElementCallIntent): boolean {
|
||
return (
|
||
intent === ElementCallIntent.JoinExistingDM ||
|
||
intent === ElementCallIntent.JoinExistingDMVoice ||
|
||
intent === ElementCallIntent.StartCallDM ||
|
||
intent === ElementCallIntent.StartCallDMVoice
|
||
);
|
||
}
|
||
|
||
static startingCall(intent: ElementCallIntent): boolean {
|
||
return (
|
||
intent === ElementCallIntent.StartCallDM ||
|
||
intent === ElementCallIntent.StartCallDMVoice ||
|
||
intent === ElementCallIntent.StartCall ||
|
||
intent === ElementCallIntent.StartCallVoice
|
||
);
|
||
}
|
||
|
||
static getWidget(
|
||
mx: MatrixClient,
|
||
room: Room,
|
||
intent: ElementCallIntent,
|
||
themeKind: ElementCallThemeKind,
|
||
denoiseMode: NoiseSuppressionMode = 'browser',
|
||
denoiseModel: string = 'rnnoise',
|
||
// [lotus] "Series suppression": also run EC's built-in WebRTC NS before the
|
||
// in-source ML model (opt-in test aid for stacking browser NS + ML).
|
||
denoiseNativeNS: boolean = false,
|
||
denoiseGate: boolean = false,
|
||
denoiseGateThreshold: number = -45,
|
||
initialAudio = true,
|
||
initialVideo = false,
|
||
): Widget {
|
||
const userId = mx.getSafeUserId();
|
||
const deviceId = mx.getDeviceId() ?? '';
|
||
const clientOrigin = window.location.origin;
|
||
const widgetId = 'call-embed';
|
||
|
||
const params = new URLSearchParams({
|
||
widgetId,
|
||
parentUrl: clientOrigin,
|
||
baseUrl: mx.baseUrl,
|
||
roomId: room.roomId,
|
||
userId,
|
||
deviceId,
|
||
intent,
|
||
|
||
skipLobby: 'true',
|
||
confineToRoom: 'true',
|
||
appPrompt: 'false',
|
||
perParticipantE2EE: room.hasEncryptionStateEvent().toString(),
|
||
lang: 'en-EN',
|
||
theme: themeKind,
|
||
// EC's built-in WebRTC suppressor: on for the 'browser' tier, and for the
|
||
// 'ml' tier only when "series suppression" is opted into (stack browser NS
|
||
// before the fork's in-source ML model). Plain 'ml' keeps it OFF so the
|
||
// fork's TrackProcessor (lotusDenoiseSource) gets a raw mic.
|
||
noiseSuppression: (
|
||
denoiseMode === 'browser' ||
|
||
(denoiseMode === 'ml' && denoiseNativeNS)
|
||
).toString(),
|
||
// Turn the browser's auto gain control OFF for the ML tier only: its
|
||
// dynamic gain fights the in-source ML denoiser (pumping). Browser/off
|
||
// tiers keep the browser's normal capture pipeline (AGC on).
|
||
autoGainControl: (denoiseMode !== 'ml').toString(),
|
||
audio: initialAudio.toString(),
|
||
video: initialVideo.toString(),
|
||
header: 'none',
|
||
// [lotus] Activate the self-built fork's in-source features (each is a
|
||
// no-op on the EC side unless its flag/action is present):
|
||
// - call-state stream (speaking/mute events) -> useCallSpeakers
|
||
// - transparent background so the room wallpaper shows through natively
|
||
lotusCallState: 'true',
|
||
lotusTransparent: 'true',
|
||
// [Gitea #43] The fork hides its own footer (we draw the call bar) and
|
||
// sets its root color-scheme from the theme, instead of us injecting CSS.
|
||
lotusHostControls: 'true',
|
||
// [Gitea #43] Engines without Capability Delegation (Firefox, Safari,
|
||
// WebKitGTK) can't start a share from our call bar: getDisplayMedia
|
||
// needs the click inside the frame. There the fork shows EC's own
|
||
// screenshare button and we hide ours.
|
||
...(canDelegateCapability() ? {} : { lotusFrameScreenshare: 'true' }),
|
||
// [lotus #3 / P5-15] Arm the fork's audio-inject handler so the in-call
|
||
// soundboard can publish clips into the call. Dormant until the host
|
||
// sends io.lotus.inject_audio (only on an explicit user click), so
|
||
// arming it for every call is safe.
|
||
lotusAudioInject: 'true',
|
||
});
|
||
|
||
if (denoiseMode === 'ml') {
|
||
// [lotus] In-source ML denoise: the fork runs RNNoise/Speex/DTLN/DFN as a
|
||
// real LiveKit audio TrackProcessor (survives reconnects — fixes A7),
|
||
// replacing the old build-time getUserMedia shim. The shim injection was
|
||
// removed from vite.config.js; the denoise/ assets are still shipped and
|
||
// loaded by the processor. lotusDenoiseSource (not lotusDenoise=ml) gates
|
||
// it so the two engines can never both run.
|
||
params.append('lotusDenoiseSource', 'true');
|
||
params.append('lotusModel', denoiseModel);
|
||
params.append('lotusGate', denoiseGate.toString());
|
||
params.append('lotusGateThreshold', denoiseGateThreshold.toString());
|
||
|
||
// [lotus] Fire-and-forget: confirm the fork's ML-denoise assets are
|
||
// actually served under public/element-call/denoise/ (they're copied by
|
||
// vite.config.js at build time). Warns once if the copy step regressed;
|
||
// never blocks call start.
|
||
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
||
}
|
||
|
||
if (CallEmbed.startingCall(intent)) {
|
||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
||
}
|
||
|
||
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
||
const externalPage = getCallPageUrl();
|
||
const widgetUrl = externalPage
|
||
? new URL(externalPage)
|
||
: new URL(
|
||
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
|
||
window.location.origin,
|
||
);
|
||
widgetUrl.search = params.toString();
|
||
|
||
const options: IWidget = {
|
||
id: widgetId,
|
||
creatorUserId: userId,
|
||
name: 'Call',
|
||
type: 'm.call',
|
||
url: widgetUrl.href,
|
||
waitForIframeLoad: false,
|
||
data: {},
|
||
};
|
||
|
||
const widget: Widget = new Widget(options);
|
||
|
||
return widget;
|
||
}
|
||
|
||
static getIframe(url: string): HTMLIFrameElement {
|
||
const iframe = document.createElement('iframe');
|
||
|
||
iframe.title = 'Call Embed';
|
||
iframe.sandbox =
|
||
'allow-forms allow-scripts allow-same-origin allow-popups allow-modals allow-downloads';
|
||
iframe.allow = 'microphone; camera; display-capture; autoplay; clipboard-write;';
|
||
iframe.src = url;
|
||
|
||
iframe.style.width = '100%';
|
||
iframe.style.height = '100%';
|
||
iframe.style.border = 'none';
|
||
|
||
return iframe;
|
||
}
|
||
|
||
constructor(
|
||
mx: MatrixClient,
|
||
room: Room,
|
||
widget: Widget,
|
||
container: HTMLElement,
|
||
initialControlState?: CallControlState,
|
||
themeKind: ElementCallThemeKind = 'dark',
|
||
) {
|
||
const iframe = CallEmbed.getIframe(
|
||
widget.getCompleteUrl({ currentUserId: mx.getSafeUserId() }),
|
||
);
|
||
container.append(iframe);
|
||
|
||
const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId);
|
||
const call: ClientWidgetApi = new ClientWidgetApi(widget, iframe, callWidgetDriver);
|
||
// Only messages from the call's own frame and origin reach the widget API.
|
||
restrictWidgetMessages(call, iframe, widget.origin);
|
||
|
||
this.mx = mx;
|
||
this.call = call;
|
||
this.room = room;
|
||
this.iframe = iframe;
|
||
this.container = container;
|
||
this.themeKind = themeKind;
|
||
|
||
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||
this.control = new CallControl(controlState, call, iframe);
|
||
this.initialState = controlState;
|
||
// 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(
|
||
this.listenAction<ElementMediaStateDetail>(ElementWidgetActions.DeviceMute, (evt) => {
|
||
if (initialMediaEvent) {
|
||
initialMediaEvent = false;
|
||
this.control.applyState();
|
||
return;
|
||
}
|
||
this.control.onMediaState(evt);
|
||
}),
|
||
);
|
||
|
||
this.start();
|
||
}
|
||
|
||
get roomId(): string {
|
||
return this.room.roomId;
|
||
}
|
||
|
||
public setTheme(theme: ElementCallThemeKind) {
|
||
this.themeKind = theme;
|
||
return this.call.transport
|
||
.send(WidgetApiToWidgetAction.ThemeChange, { name: theme })
|
||
.catch(() => {
|
||
// Widget transport not ready yet; theme URL param is the fallback
|
||
});
|
||
}
|
||
|
||
public hangup() {
|
||
return this.call.transport.send(ElementWidgetActions.HangupCall, {});
|
||
}
|
||
|
||
public onPreparing(callback: () => void) {
|
||
return this.listenEvent('preparing', callback);
|
||
}
|
||
|
||
public onPreparingError(callback: (error: unknown) => void) {
|
||
return this.listenEvent('error:preparing', callback);
|
||
}
|
||
|
||
public onReady(callback: () => void) {
|
||
return this.listenEvent('ready', callback);
|
||
}
|
||
|
||
public onCapabilitiesNotified(callback: () => void) {
|
||
return this.listenEvent('capabilitiesNotified', callback);
|
||
}
|
||
|
||
private start() {
|
||
// Room widgets get locked to the room they were added in
|
||
this.call.setViewedRoomId(this.roomId);
|
||
this.disposables.push(
|
||
this.listenAction(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this)),
|
||
);
|
||
// Acknowledge set_always_on_screen; we manage PiP ourselves so just return success
|
||
this.disposables.push(
|
||
this.listenAction(WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, () => {}),
|
||
);
|
||
// [lotus #2] Consume the fork's per-participant call-state stream. listenAction
|
||
// auto-replies {} so the fork's transport doesn't time out. Stored for the
|
||
// speaker/mute hooks (which prefer this over DOM scraping).
|
||
// [Gitea #17 / EC#17] The fork asks for a full state re-push whenever its
|
||
// lotus handlers (re)register — e.g. an EC-side InCallView remount that
|
||
// does not unmount us. Re-send the sticky fork state (deafen/quality/focus)
|
||
// and let the decoration pusher re-send its roster.
|
||
this.disposables.push(
|
||
this.listenAction('io.lotus.request_state', () => {
|
||
if (!this.joined) return;
|
||
this.control.resendForkState();
|
||
this.forkStateRequestListeners.forEach((l) => l());
|
||
}),
|
||
);
|
||
this.disposables.push(
|
||
this.listenAction('io.lotus.call_summary', (evt) => {
|
||
const data = (evt.detail as { data?: Partial<LotusCallSummary> } | undefined)?.data;
|
||
if (data && typeof data.durationMs === 'number') {
|
||
this.lastSummary = {
|
||
durationMs: data.durationMs,
|
||
reconnects: data.reconnects ?? 0,
|
||
poorMs: data.poorMs ?? 0,
|
||
verdict: data.verdict ?? 'unknown',
|
||
};
|
||
}
|
||
}),
|
||
);
|
||
this.disposables.push(
|
||
this.listenAction('io.lotus.mic_level', (evt) => {
|
||
const bars = (evt.detail as { data?: { bars?: unknown } } | undefined)?.data?.bars;
|
||
const level = typeof bars === 'number' ? Math.max(0, Math.min(3, Math.round(bars))) : 0;
|
||
if (level === this.micLevel) return;
|
||
this.micLevel = level;
|
||
this.micLevelListeners.forEach((l) => l());
|
||
}),
|
||
);
|
||
// [Gitea #43] Screenshare + layout state from the fork; also switches the
|
||
// call bar's layout / settings / reactions buttons to widget actions.
|
||
this.disposables.push(
|
||
this.listenAction('io.lotus.controls_state', (evt) => {
|
||
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
|
||
}),
|
||
);
|
||
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
|
||
this.disposables.push(
|
||
this.listenAction('io.lotus.hotkey', (evt) => {
|
||
this.control.onHotkeyReport((evt.detail as { data?: unknown } | undefined)?.data);
|
||
}),
|
||
);
|
||
this.disposables.push(
|
||
this.listenAction('io.lotus.call_state', (evt) => {
|
||
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
|
||
this.lotusParticipants = Array.isArray(data?.participants)
|
||
? (data!.participants as LotusCallParticipant[])
|
||
: [];
|
||
this.lotusCallStateListeners.forEach((l) => l());
|
||
}),
|
||
);
|
||
|
||
// Populate the map of "read up to" events for this widget with the current event in every room.
|
||
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
|
||
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
|
||
this.mx.getRooms().forEach((room) => {
|
||
// Timelines are most recent last
|
||
const events = room.getLiveTimeline()?.getEvents() || [];
|
||
const roomEvent = events[events.length - 1];
|
||
if (!roomEvent) return; // force later code to think the room is fresh
|
||
this.readUpToMap[room.roomId] = roomEvent.getId()!;
|
||
});
|
||
|
||
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
|
||
this.mx.on(ClientEvent.Event, this.boundOnEvent);
|
||
this.mx.on(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted);
|
||
this.mx.on(RoomStateEvent.Events, this.boundOnStateUpdate);
|
||
this.mx.on(ClientEvent.ToDeviceEvent, this.boundOnToDeviceEvent);
|
||
}
|
||
|
||
/**
|
||
* Stops the widget messaging for if it is started. Skips stopping if it is an active
|
||
* widget.
|
||
* @param opts
|
||
*/
|
||
public dispose(): void {
|
||
if (this.disposed) return;
|
||
this.disposed = true;
|
||
this.disposables.forEach((disposable) => {
|
||
disposable();
|
||
});
|
||
this.clearLoadWatchdog();
|
||
this.loadErrorListeners.clear();
|
||
this.call.stop();
|
||
this.container.removeChild(this.iframe);
|
||
this.control.dispose();
|
||
|
||
this.mx.off(ClientEvent.Event, this.boundOnEvent);
|
||
this.mx.off(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted);
|
||
this.mx.off(RoomStateEvent.Events, this.boundOnStateUpdate);
|
||
this.mx.off(ClientEvent.ToDeviceEvent, this.boundOnToDeviceEvent);
|
||
|
||
// Clear internal state
|
||
this.readUpToMap = {};
|
||
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;
|
||
}
|
||
}
|
||
|
||
private notifyLoadListeners(reason: CallLoadErrorReason | undefined): void {
|
||
this.loadErrorListeners.forEach((cb) => {
|
||
try {
|
||
cb(reason);
|
||
} catch {
|
||
// a misbehaving subscriber must not block the others
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Marks the load lifecycle as settled.
|
||
*
|
||
* - Failure (reason set): the FIRST failure wins; a later success can still
|
||
* heal it (below). Once we've genuinely succeeded, later spurious failures
|
||
* are ignored.
|
||
* - Success (no reason): always clears the watchdog. Crucially, if we had
|
||
* previously settled as a failure (e.g. the 25s watchdog fired on a slow
|
||
* network but EC then finished loading), we self-heal: clear the error and
|
||
* notify subscribers with `undefined` so the recovery UI dismisses itself
|
||
* instead of stranding the user on an error screen over a live call.
|
||
*/
|
||
private settleLoad(reason?: CallLoadErrorReason): void {
|
||
if (reason) {
|
||
if (this.loadSettled) return;
|
||
this.loadSettled = true;
|
||
this.clearLoadWatchdog();
|
||
this.loadError = reason;
|
||
this.notifyLoadListeners(reason);
|
||
return;
|
||
}
|
||
|
||
this.clearLoadWatchdog();
|
||
const wasFailed = this.loadError !== undefined;
|
||
this.loadSettled = true;
|
||
this.loadError = undefined;
|
||
if (wasFailed) {
|
||
this.notifyLoadListeners(undefined);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 | undefined) => void): () => void {
|
||
this.loadErrorListeners.add(callback);
|
||
if (this.loadError) callback(this.loadError);
|
||
return () => {
|
||
this.loadErrorListeners.delete(callback);
|
||
};
|
||
}
|
||
|
||
private onCallJoined(): void {
|
||
this.settleLoad();
|
||
|
||
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
|
||
// once-guard). forceState() would reset live mic/video/deafen back to the
|
||
// join-time snapshot, so only run it on the FIRST join. On a rejoin we just
|
||
// re-push the sticky fork state (deafen/quality), leaving the user's live
|
||
// media state untouched.
|
||
if (this.joined) {
|
||
this.control.resendForkState();
|
||
return;
|
||
}
|
||
this.joined = true;
|
||
this.joinedAt = Date.now();
|
||
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
||
this.control.forceState(this.initialState);
|
||
}
|
||
|
||
private onEvent(ev: MatrixEvent): void {
|
||
this.mx.decryptEventIfNeeded(ev);
|
||
this.feedEvent(ev);
|
||
}
|
||
|
||
private onEventDecrypted(ev: MatrixEvent): void {
|
||
this.feedEvent(ev);
|
||
}
|
||
|
||
private onStateUpdate(ev: MatrixEvent): void {
|
||
if (this.call === null) return;
|
||
const raw = ev.getEffectiveEvent();
|
||
this.call.feedStateUpdate(raw as IRoomEvent).catch((e) => {
|
||
console.error(
|
||
'Error sending state update to widget:',
|
||
e instanceof Error ? e.message : 'unknown error',
|
||
);
|
||
});
|
||
}
|
||
|
||
private async onToDeviceEvent(ev: MatrixEvent): Promise<void> {
|
||
await this.mx.decryptEventIfNeeded(ev);
|
||
if (ev.isDecryptionFailure()) return;
|
||
await this.call?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
|
||
}
|
||
|
||
/**
|
||
* Determines whether the event has a relation to an unknown parent.
|
||
*/
|
||
private relatesToUnknown(ev: MatrixEvent): boolean {
|
||
// Replies to unknown events don't count
|
||
if (!ev.relationEventId || ev.replyEventId) return false;
|
||
const room = this.mx.getRoom(ev.getRoomId());
|
||
return room === null || !room.findEventById(ev.relationEventId);
|
||
}
|
||
|
||
/**
|
||
* Advances the "read up to" marker for a room to a certain event. No-ops if
|
||
* the event is before the marker.
|
||
* @returns Whether the "read up to" marker was advanced.
|
||
*/
|
||
private advanceReadUpToMarker(ev: MatrixEvent): boolean {
|
||
const evId = ev.getId();
|
||
if (evId === undefined) return false;
|
||
const roomId = ev.getRoomId();
|
||
if (roomId === undefined) return false;
|
||
const room = this.mx.getRoom(roomId);
|
||
if (room === null) return false;
|
||
|
||
const upToEventId = this.readUpToMap[ev.getRoomId()!];
|
||
if (!upToEventId) {
|
||
// There's no marker yet; start it at this event
|
||
this.readUpToMap[roomId] = evId;
|
||
return true;
|
||
}
|
||
|
||
// Small optimization for exact match (skip the search)
|
||
if (upToEventId === evId) return false;
|
||
|
||
// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
|
||
// to avoid overusing the CPU.
|
||
const timeline = room.getLiveTimeline();
|
||
const events = [...timeline.getEvents()].reverse().slice(0, 100);
|
||
function isRelevantTimelineEvent(timelineEvent: MatrixEvent): boolean {
|
||
return timelineEvent.getId() === upToEventId || timelineEvent.getId() === ev.getId();
|
||
}
|
||
const possibleMarkerEv = events.find(isRelevantTimelineEvent);
|
||
if (possibleMarkerEv?.getId() === upToEventId) {
|
||
// The event must be somewhere before the "read up to" marker
|
||
return false;
|
||
}
|
||
if (possibleMarkerEv?.getId() === ev.getId()) {
|
||
// The event is after the marker; advance it
|
||
this.readUpToMap[roomId] = evId;
|
||
return true;
|
||
}
|
||
|
||
// We can't say for sure whether the widget has seen the event; let's
|
||
// just assume that it has
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Determines whether the event comes from a room that we've been invited to
|
||
* (in which case we likely don't have the full timeline).
|
||
*/
|
||
private isFromInvite(ev: MatrixEvent): boolean {
|
||
const room = this.mx.getRoom(ev.getRoomId());
|
||
return room?.getMyMembership() === KnownMembership.Invite;
|
||
}
|
||
|
||
private feedEvent(ev: MatrixEvent): void {
|
||
if (this.call === null) return;
|
||
if (
|
||
// If we had decided earlier to feed this event to the widget, but
|
||
// it just wasn't ready, give it another try
|
||
this.eventsToFeed.delete(ev) ||
|
||
// Skip marker timeline check for events with relations to unknown parent because these
|
||
// events are not added to the timeline here and will be ignored otherwise:
|
||
// https://github.com/matrix-org/matrix-js-sdk/blob/d3dfcd924201d71b434af3d77343b5229b6ed75e/src/models/room.ts#L2207-L2213
|
||
this.relatesToUnknown(ev) ||
|
||
// Skip marker timeline check for rooms where membership is
|
||
// 'invite', otherwise the membership event from the invitation room
|
||
// will advance the marker and new state events will not be
|
||
// forwarded to the widget.
|
||
this.isFromInvite(ev) ||
|
||
// Check whether this event would be before or after our "read up to" marker. If it's
|
||
// before, or we can't decide, then we assume the widget will have already seen the event.
|
||
// If the event is after, or we don't have a marker for the room,
|
||
// then the marker will advance and we'll send it through.
|
||
// This approach of "read up to" prevents widgets receiving decryption spam from startup or
|
||
// receiving ancient events from backfill and such.
|
||
this.advanceReadUpToMarker(ev)
|
||
) {
|
||
// If the event is still being decrypted, remember that we want to
|
||
// feed it to the widget (even if not strictly in the order given by
|
||
// the timeline) and get back to it later
|
||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) {
|
||
this.eventsToFeed.add(ev);
|
||
} else {
|
||
const raw = ev.getEffectiveEvent();
|
||
this.call.feedEvent(raw as IRoomEvent).catch((e) => {
|
||
console.error(
|
||
'Error sending event to widget:',
|
||
e instanceof Error ? e.message : 'unknown error',
|
||
);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/** [lotus #2] Latest io.lotus.call_state participants, or null if the fork
|
||
* hasn't sent any yet (callers then fall back to DOM scraping). */
|
||
public getLotusParticipants(): LotusCallParticipant[] | null {
|
||
return this.lotusParticipants;
|
||
}
|
||
|
||
/** [lotus #2] Subscribe to io.lotus.call_state updates. Returns an unsubscribe. */
|
||
/** [Gitea #17] Subscribe to the fork's io.lotus.request_state. Returns an unsubscribe. */
|
||
public onForkStateRequest(cb: () => void): () => void {
|
||
this.forkStateRequestListeners.add(cb);
|
||
return () => {
|
||
this.forkStateRequestListeners.delete(cb);
|
||
};
|
||
}
|
||
|
||
/** [Gitea #146] Latest local mic level, 0–3 bars (0 while muted). */
|
||
public getMicLevel(): number {
|
||
return this.micLevel;
|
||
}
|
||
|
||
/** [Gitea #146] Subscribe to mic level changes. Returns an unsubscribe. */
|
||
public onMicLevel(cb: () => void): () => void {
|
||
this.micLevelListeners.add(cb);
|
||
return () => {
|
||
this.micLevelListeners.delete(cb);
|
||
};
|
||
}
|
||
|
||
public onLotusCallState(cb: () => void): () => void {
|
||
this.lotusCallStateListeners.add(cb);
|
||
return () => {
|
||
this.lotusCallStateListeners.delete(cb);
|
||
};
|
||
}
|
||
|
||
public listenAction<T>(type: string, callback: (event: CustomEvent<T>) => void) {
|
||
const wrapped = (ev: CustomEvent<T>) => {
|
||
ev.preventDefault();
|
||
// Reply with success so the widget transport doesn't time out waiting for a response
|
||
this.call.transport.reply(ev.detail as any, {});
|
||
callback(ev);
|
||
};
|
||
return this.listenEvent(`action:${type}`, wrapped);
|
||
}
|
||
|
||
public listenEvent<T>(type: string, callback: (event: T) => void) {
|
||
this.call.on(type, callback);
|
||
return () => {
|
||
this.call.off(type, callback);
|
||
};
|
||
}
|
||
}
|