Files
cinny/src/app/plugins/call/CallEmbed.ts
T
Lotus CIandClaude Opus 5.5 4dcc5176e2
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
feat(call): optional Element Call origin via config.json elementCallUrl (#43)
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
2026-09-26 23:24:24 -04:00

780 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
};
}
}