sdk-target-based-on-call-view-model client -> slot/participation

This commit is contained in:
Timo K.
2026-10-14 10:23:29 +02:00
committed by Timo
parent a5a0efe92b
commit cec5031f62
4 changed files with 124 additions and 236 deletions
@@ -2,8 +2,9 @@
> Superseded by the `sdk/` package (`@element-hq/matrixrtc-sdk`) and its harness in > Superseded by the `sdk/` package (`@element-hq/matrixrtc-sdk`) and its harness in
> `sdk/dev`; see `sdk/SdkArchitecture.md`. This bundle is in production use, so it > `sdk/dev`; see `sdk/SdkArchitecture.md`. This bundle is in production use, so it
> keeps building as a thin layer over that package, with its interface unchanged, > keeps building as a thin layer over that package until its consumers have moved
> until its consumers have moved to the package directly. It will then be removed. > to the package directly. It will then be removed. Its members are the package's
> `RTCMember`s now, in place of its own wrapper around the raw membership.
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps. EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
@@ -1,29 +0,0 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* This file contains helper functions and types for the legacy SDK bundle.
*/
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type WidgetHelpers } from "../src/widget";
export const tryMakeSticky = (widget: WidgetHelpers): void => {
const logger = rootLogger.getChild("[MatrixRTCSdk]");
logger.info("try making sticky MatrixRTCSdk");
void widget.api
.setAlwaysOnScreen(true)
.then(() => {
logger.info("sticky MatrixRTCSdk");
})
.catch((error) => {
logger.error("failed to make sticky MatrixRTCSdk", error);
});
};
/** The data channel topic this bundle has always used, so that old and new builds talk. */
export const TEXT_LK_TOPIC = "matrixRTC";
@@ -35,7 +35,7 @@
div.innerHTML = "<h3>Members:</h3>"; div.innerHTML = "<h3>Members:</h3>";
// Create member list // Create member list
const members = memberObjects.map((member) => member.membership.sender); const members = memberObjects.map((member) => member.userId);
console.info("members changed", members); console.info("members changed", members);
for (const m of members) { for (const m of members) {
console.info("member", m); console.info("member", m);
+107 -191
View File
@@ -8,125 +8,80 @@ Please see LICENSE in the repository root for full details.
/** /**
* LEGACY, in production use * LEGACY, in production use
* *
* This file is the entrypoint of the legacy SDK bundle, * The entrypoint of `pnpm build:sdk-target-based-on-call-view-model`, a
* `pnpm build:sdk-target-based-on-call-view-model`, for use in widgets. It * bundle for widgets that exposes `createMatrixRTCSdk`: join a slot and
* exposes `createMatrixRTCSdk`, which creates the `MatrixRTCSdk` interface * exchange realtime data, with the `@element-hq/matrixrtc-sdk` package and
* (see below) to join an RTC session and exchange realtime data. * Element Call's call view model doing the work underneath. New consumers use
* * the package directly; this bundle goes away once the existing ones have.
* It is a thin layer over the `@element-hq/matrixrtc-sdk` package and Element
* Call's call view model, which take care of all the tricky bits:
* - sending delayed events
* - finding the right sfu
* - handling the media stream
* - sending join/leave state or sticky events
* - setting up encryption and sharing keys
*
* The interface is kept as it was for its existing consumers. New consumers
* use the `@element-hq/matrixrtc-sdk` package directly; this bundle goes away
* once the existing ones have moved.
*/ */
import {
combineLatest,
filter,
map,
type Observable,
of,
switchMap,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { EMPTY, filter, map, type Observable, of, switchMap, take } from "rxjs";
import { import {
type Behavior, type Behavior,
createMatrixRTCClient, createRTCSlot,
E2eeType, E2eeType,
type LocalRTCMember,
ObservableScope, ObservableScope,
type RTCMember, type RemoteRTCMember,
type RTCMembership, type RTCParticipation,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { import {
callViewModelOptionsFromParams, callViewModelOptionsFromParams,
type CallViewModel,
createCallViewModel$, createCallViewModel$,
initialPublishRequests, initialPublishRequests,
} from "../src/state/CallViewModel/CallViewModel"; } from "../src/state/CallViewModel/CallViewModel";
import { getUrlParams } from "../src/UrlParams";
import { MuteStates } from "../src/state/MuteStates";
import { MediaDevices } from "../src/state/MediaDevices";
import { TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
import { initializeWidget } from "../src/widget";
import { createWidgetHostBridge } from "../src/HostBridge";
import { observeElementSize$ } from "../src/utils/elementSize";
import { import {
captureSettings, captureSettings,
matrixRTCClientOptions, rtcParticipationOptions,
rtcSlotOptions,
} from "../src/room/InCallView"; } from "../src/room/InCallView";
import { createWidgetHostBridge } from "../src/HostBridge";
import { MediaDevices } from "../src/state/MediaDevices";
import { MuteStates } from "../src/state/MuteStates";
import { getUrlParams } from "../src/UrlParams";
import { observeElementSize$ } from "../src/utils/elementSize";
import { initializeWidget } from "../src/widget";
/** /** The data channel topic this bundle has always used, so that old and new builds talk. */
* A member as this bundle has always presented it. The media transport is const TOPIC = "matrixRTC";
* the SDK's business now, so the transport objects are gone: `connection` and
* `participant` are always null and only kept so that existing code keeps
* destructuring. `membership` is what consumers read.
*/
interface MatrixRTCSdkMember {
/** @deprecated Always null: the SDK owns the transport connection. */
connection: null;
membership: RTCMembership;
/** @deprecated Always null: the SDK owns the transport participant. */
participant: null;
}
interface MatrixRTCSdk { export interface MatrixRTCSdk {
/** /** Joins the slot; `connected$` tracks the progress. A second call does nothing. */
* observe connected$ to track the state.
* @returns
*/
join: () => void; join: () => void;
/** @throws on leave errors */
leave: () => void; leave: () => void;
/** /** Ends everything. The sdk cannot be restarted; create a new one. */
* Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope.
* No updates can be received from the rtc sdk. The sdk cannot be restarted after.
* A new sdk needs to be created via createMatrixRTCSdk.
*/
stop: () => void; stop: () => void;
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
/**
* flattened list of remote members
*/
remoteMembers$: Behavior<MatrixRTCSdkMember[]>;
/**
* flattened local member
*/
localMember$: Behavior<MatrixRTCSdkMember | null>;
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
connected$: Behavior<boolean>; connected$: Behavior<boolean>;
sendData?: (data: unknown) => Promise<void>; /** Our own member, null until our membership has been seen in the room. */
sendRoomMessage?: (message: string) => Promise<void>; localMember$: Behavior<LocalRTCMember | null>;
/** The other members of the slot, with the media the participation carries for them. */
remoteMembers$: Behavior<RemoteRTCMember[]>;
/** What other members sent with `sendData`. */
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
/** Sends to every member on our transport. Rejects while not joined. */
sendData: (data: unknown) => Promise<void>;
sendRoomMessage: (message: string) => Promise<void>;
} }
export async function createMatrixRTCSdk( export async function createMatrixRTCSdk(
application: string = "m.call", application = "m.call",
id: string = "", id = "",
sticky: boolean = false, sticky = false,
): Promise<MatrixRTCSdk> { ): Promise<MatrixRTCSdk> {
const logger = rootLogger.getChild("[MatrixRTCSdk]"); const logger = rootLogger.getChild("[MatrixRTCSdk]");
const scope = new ObservableScope(); const scope = new ObservableScope();
// widget client
const widget = initializeWidget(application, true); const widget = initializeWidget(application, true);
if (!widget) throw Error("No widget. This webapp can only start as a widget"); if (!widget) throw new Error("No widget: this bundle only runs as a widget");
const client = await widget.client; const client = await widget.client;
const hostBridge = createWidgetHostBridge(widget); const hostBridge = createWidgetHostBridge(widget);
logger.info("client created");
// url params
const urlParams = getUrlParams(); const urlParams = getUrlParams();
const { roomId, controlledAudioDevices, callIntent } = urlParams; const { roomId, controlledAudioDevices, callIntent } = urlParams;
if (roomId === null) throw Error("could not get roomId from url params"); const room = roomId === null ? null : client.getRoom(roomId);
const room = client.getRoom(roomId); if (room === null) throw new Error("No room: the url needs a joined roomId");
if (room === null) throw Error("could not get room from client");
// media devices
const mediaDevices = new MediaDevices(scope, { const mediaDevices = new MediaDevices(scope, {
controlledAudioDevices, controlledAudioDevices,
callIntent, callIntent,
@@ -137,21 +92,43 @@ export async function createMatrixRTCSdk(
{ audioEnabled: false, videoEnabled: false }, { audioEnabled: false, videoEnabled: false },
hostBridge, hostBridge,
); );
// rtc client: the session, the transport and the media, as in the app
const encryptionSystem = { kind: E2eeType.PER_PARTICIPANT } as const; const encryptionSystem = { kind: E2eeType.PER_PARTICIPANT } as const;
const capture = captureSettings(); const capture = captureSettings();
const rtcClient = createMatrixRTCClient(scope, client, room, { const slot = createRTCSlot(scope, client, room, {
...matrixRTCClientOptions(urlParams, encryptionSystem), ...rtcSlotOptions(encryptionSystem),
publish: initialPublishRequests(muteStates, mediaDevices, capture),
application, application,
slot: id, id,
}); });
let vm: CallViewModel | undefined;
const failed =
(what: string) =>
(e: unknown): void =>
logger.error(what, e);
// call view model: the host bridge's hang-up handling and the leave flow const over = <T>(
const callViewModel = createCallViewModel$( pick: (participation: RTCParticipation) => Observable<T>,
fallback: T,
): Behavior<T> =>
scope.behavior(
slot.participation$.pipe(
switchMap((p) => (p === null ? of(fallback) : pick(p))),
),
);
return {
join: (): void => {
if (slot.participation$.value !== null) return;
if (sticky)
widget.api
.setAlwaysOnScreen(true)
.catch(failed("Failed to make the widget sticky"));
const participation = slot.join({
...rtcParticipationOptions(urlParams),
publish: initialPublishRequests(muteStates, mediaDevices, capture),
});
vm = createCallViewModel$(
scope, scope,
rtcClient, participation,
room, room,
mediaDevices, mediaDevices,
muteStates, muteStates,
@@ -160,113 +137,52 @@ export async function createMatrixRTCSdk(
encryptionSystem, encryptionSystem,
capture, capture,
hostBridge, hostBridge,
// The SDK owns its page, so the body is the space it has // The bundle owns its page, so the body is the space it has
windowSize$: scope.behavior(observeElementSize$(document.body)), windowSize$: scope.behavior(observeElementSize$(document.body)),
}, },
of({}), of({}),
of({}), of({}),
); );
logger.info("CallViewModelCreated"); // Once the hang-up has gone through, unstick and ask the host to close us
vm.leave$
// Data arrives from attested members only; the SDK has matched the sender .pipe(
const data$ = rtcClient.data$.pipe( switchMap(() =>
filter(({ topic }) => topic === TEXT_LK_TOPIC), participation.status$.pipe(
map(({ member, text }) => { filter((status) => status === "left"),
logger.info(`Received text from ${member.id}: ${text}`); take(1),
return { rtcBackendIdentity: member.id, data: text }; ),
}), ),
); scope.bind(),
)
const sendData = async (data: unknown): Promise<void> => { .subscribe(() => {
const dataString = JSON.stringify(data); hostBridge
logger.info("try sending: ", dataString);
try {
await rtcClient.sendData(TEXT_LK_TOPIC, dataString);
logger.info("sent text");
} catch (e) {
logger.error("failed sending: ", dataString, e);
}
};
const sendRoomMessage = async (message: string): Promise<void> => {
const messageString = JSON.stringify(message);
logger.info("try sending to room: ", messageString);
try {
await client.sendTextMessage(room.roomId, message);
} catch (e) {
logger.error("failed sending to room: ", messageString, e);
}
};
// after hangup gets called
const leaveSubs = callViewModel.leave$.subscribe(() => {
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
logger.info("waiting for RTC leave");
await new Promise<void>((resolve) => {
rtcClient.status$
.pipe(filter((status) => status === "disconnected"))
.subscribe(() => resolve());
});
logger.info("send Unstick");
await hostBridge
.setAlwaysOnScreen(false) .setAlwaysOnScreen(false)
.catch((e: unknown) => .catch(failed("Failed to unstick the widget"))
logger.error("Failed to set `alwaysOnScreen` to false", e), .then(() => hostBridge.close?.())
); .catch(failed("Failed to ask the host to close"));
logger.info("send Close");
await hostBridge
.close?.()
.catch((e: unknown) =>
logger.error("Failed to ask the host to close", e),
);
};
// schedule close first and then leave (scope.end)
void scheduleWidgetCloseOnLeave();
}); });
},
logger.info("createMatrixRTCSdk done"); leave: (): void => vm?.leave(),
stop: (): void => scope.end(),
const flatten = (member: RTCMember): Observable<MatrixRTCSdkMember> => connected$: over((p) => p.connected$, false),
member.membership$.pipe( localMember$: over((p) => p.localMember$, null),
map((membership) => ({ remoteMembers$: over((p) => p.remoteMembers$, []),
connection: null, // Attested members only: the SDK has matched the sender
membership, data$: slot.participation$.pipe(
participant: null, switchMap((p) => p?.data$ ?? EMPTY),
filter(({ topic }) => topic === TOPIC),
map(({ member, text }) => ({
rtcBackendIdentity: member.rtcBackendIdentity,
data: text,
})), })),
); ),
sendData: async (data): Promise<void> => {
return { const participation = slot.participation$.value;
join: (): void => { if (participation === null) throw new Error("Not joined");
// first lets try making the widget sticky await participation.sendData(TOPIC, JSON.stringify(data));
if (sticky) tryMakeSticky(widget);
callViewModel.join();
}, },
leave: (): void => { sendRoomMessage: async (message): Promise<void> => {
callViewModel.leave(); await client.sendTextMessage(room.roomId, message);
}, },
stop: (): void => {
leaveSubs.unsubscribe();
scope.end();
},
data$,
localMember$: scope.behavior(
rtcClient.localMember$.pipe(
switchMap((member) => (member === null ? of(null) : flatten(member))),
),
),
connected$: callViewModel.connected$,
remoteMembers$: scope.behavior(
rtcClient.remoteMembers$.pipe(
switchMap((members) =>
members.length === 0
? of([])
: combineLatest(members.map((member) => flatten(member))),
),
),
[],
),
sendData,
sendRoomMessage,
}; };
} }