Files
element-call-Github/src/widget.ts
T
Valere 182be8676d Return the widget from initializeWidget instead of assigning a global
Element Call reached the widget API through a mutable module-level
binding, which every consumer imported directly. Nothing outside the app
shell needs it any more, so hand it back from initializeWidget and thread
it through: the initializer returns it, main passes it to App, and App
uses it to build the host bridge and to await the client the host is
lending us.

ClientContext's loadClient is now only about restoring or creating a
session of Element Call's own, since a widget's client arrives as a prop
like any other host's would.

Also fixes an early return added in the previous commit, which skipped
starting the analytics settings listener when a client was supplied. That
was harmless until now, but would have stopped analytics following the
user's choices in widget mode.

sdk/main.ts asked the host to close by hand; it now uses the bridge, which
also stops the transport as the app does.
2026-09-02 16:56:09 +02:00

216 lines
7.1 KiB
TypeScript

/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/lib/logger";
import {
EventType,
createRoomWidgetClient,
type MatrixClient,
} from "matrix-js-sdk";
import {
WidgetApi,
MatrixCapabilities,
WidgetApiToWidgetAction,
} from "matrix-widget-api";
import type { IWidgetApiRequest } from "matrix-widget-api";
import { LazyEventEmitter } from "./LazyEventEmitter";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { seedSettingsFromConfig } from "./settings/settings";
import { ElementCallReactionEventType } from "./reactions";
// Subset of the actions in element-web
export enum ElementWidgetActions {
JoinCall = "io.element.join",
HangupCall = "im.vector.hangup",
Close = "io.element.close",
// This can be sent as from or to widget
// fromWidget: updates the client about the current device mute state
// toWidget: the client requests a specific device mute configuration
// The reply will always be the resulting configuration
// It is possible to sent an empty configuration to retrieve the current values or
// just one of the fields to update that particular value
// An undefined field means that EC will keep the mute state as is.
// -> this will allow the client to only get the current state
//
// The data of the widget action request and the response are:
// {
// audio_enabled?: boolean,
// video_enabled?: boolean
// }
DeviceMute = "io.element.device_mute",
}
export interface JoinCallData {
audioInput: string | null;
videoInput: string | null;
}
export interface WidgetHelpers {
api: WidgetApi;
lazyActions: LazyEventEmitter;
client: Promise<MatrixClient>;
}
/**
* Connects to the widget API, if Element Call is running as a widget.
*
* Should be called as soon as possible on app start (in the initializer, before
* React), because the widget messaging needs to be set up ASAP on load to
* ensure it doesn't miss any requests.
*
* @returns A point of access to the widget API, or null if Element Call is not
* running as a widget.
*/
// this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before
// execution.
export const initializeWidget = (
rtcApplication: string = "m.call",
sendRoomEvents = false,
): WidgetHelpers | null => {
try {
const {
widgetId,
parentUrl,
roomId,
userId,
deviceId,
baseUrl,
e2eEnabled,
allowIceFallback,
} = getUrlParams();
if (!roomId) throw new Error("Room ID must be supplied");
if (!userId) throw new Error("User ID must be supplied");
if (!deviceId) throw new Error("Device ID must be supplied");
if (!baseUrl) throw new Error("Base URL must be supplied");
if (widgetId && parentUrl) {
const parentOrigin = new URL(parentUrl).origin;
logger.info("Widget API is available");
const api = new WidgetApi(widgetId, parentOrigin);
api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
api.requestCapability(MatrixCapabilities.MSC4039DownloadFile);
// Set up the lazy action emitter, but only for select actions that we
// intend for the app to handle
const lazyActions = new LazyEventEmitter();
[
WidgetApiToWidgetAction.ThemeChange,
ElementWidgetActions.JoinCall,
ElementWidgetActions.HangupCall,
ElementWidgetActions.DeviceMute,
].forEach((action) => {
api.on(`action:${action}`, (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
lazyActions.emit(action, ev);
});
});
// Now, initialize the matryoshka MatrixClient (so named because it routes
// all requests through the host client via the widget API)
// We need to do this now rather than later because it has capabilities to
// request, and is responsible for starting the transport (should it be?)
// These are all the event types the app uses
const sendEvent = [
EventType.CallNotify, // Sent as a deprecated fallback
EventType.RTCNotification,
];
if (sendRoomEvents) {
sendEvent.push(EventType.RoomMessage);
}
const sendRecvEvent = [
"org.matrix.rageshake_request",
EventType.CallEncryptionKeysPrefix,
EventType.Reaction,
EventType.RoomRedaction,
ElementCallReactionEventType,
EventType.RTCDecline,
EventType.RTCMembership,
];
const sendState = [
userId, // Legacy call membership events
`_${userId}_${deviceId}_${rtcApplication}`, // Session membership events
`${userId}_${deviceId}_${rtcApplication}`, // The above with no leading underscore, for room versions whose auth rules allow it
].map((stateKey) => ({
eventType: EventType.GroupCallMemberPrefix,
stateKey,
}));
const receiveState = [
{ eventType: EventType.RoomCreate },
{ eventType: EventType.RoomName },
{ eventType: EventType.RoomMember },
{ eventType: EventType.RoomEncryption },
{ eventType: EventType.GroupCallMemberPrefix },
];
const sendRecvToDevice = [
EventType.CallInvite,
EventType.CallCandidates,
EventType.CallAnswer,
EventType.CallHangup,
EventType.CallReject,
EventType.CallSelectAnswer,
EventType.CallNegotiate,
EventType.CallSDPStreamMetadataChanged,
EventType.CallSDPStreamMetadataChangedPrefix,
EventType.CallReplaces,
EventType.CallEncryptionKeysPrefix,
];
const client = createRoomWidgetClient(
api,
{
sendEvent: [...sendEvent, ...sendRecvEvent],
receiveEvent: sendRecvEvent,
sendState,
receiveState,
sendToDevice: sendRecvToDevice,
receiveToDevice: sendRecvToDevice,
turnServers: false,
rtcTransports: true,
sendDelayedEvents: true,
updateDelayedEvents: true,
sendSticky: true,
receiveSticky: true,
},
roomId,
{
baseUrl,
userId,
deviceId,
timelineSupport: true,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: allowIceFallback,
},
// ContentLoaded event will be sent as soon as the theme is set (see useTheme.ts)
false,
);
const clientPromise = async (): Promise<MatrixClient> => {
// Wait for the config file to be ready (we load very early on so it might not
// be otherwise)
await Config.init();
seedSettingsFromConfig(Config.get().media_quality);
await client.startClient();
return client;
};
return { api, lazyActions, client: clientPromise() };
} else {
if (import.meta.env.MODE !== "test")
logger.info("No widget API available");
return null;
}
} catch (e) {
logger.warn("Continuing without the widget API", e);
return null;
}
};