diff --git a/sdk-target-based-on-call-view-model/README.md b/sdk-target-based-on-call-view-model/README.md
index 1c7e6ee23..4b7edf30e 100644
--- a/sdk-target-based-on-call-view-model/README.md
+++ b/sdk-target-based-on-call-view-model/README.md
@@ -2,8 +2,9 @@
> 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
-> keeps building as a thin layer over that package, with its interface unchanged,
-> until its consumers have moved to the package directly. It will then be removed.
+> keeps building as a thin layer over that package until its consumers have moved
+> 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.
diff --git a/sdk-target-based-on-call-view-model/helper.ts b/sdk-target-based-on-call-view-model/helper.ts
deleted file mode 100644
index 9ff8c53d6..000000000
--- a/sdk-target-based-on-call-view-model/helper.ts
+++ /dev/null
@@ -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";
diff --git a/sdk-target-based-on-call-view-model/index.html b/sdk-target-based-on-call-view-model/index.html
index 4cf8a4cc5..df07f634a 100644
--- a/sdk-target-based-on-call-view-model/index.html
+++ b/sdk-target-based-on-call-view-model/index.html
@@ -35,7 +35,7 @@
div.innerHTML = "
Members:
";
// Create member list
- const members = memberObjects.map((member) => member.membership.sender);
+ const members = memberObjects.map((member) => member.userId);
console.info("members changed", members);
for (const m of members) {
console.info("member", m);
diff --git a/sdk-target-based-on-call-view-model/main.ts b/sdk-target-based-on-call-view-model/main.ts
index a44ab2f66..1bb63f798 100644
--- a/sdk-target-based-on-call-view-model/main.ts
+++ b/sdk-target-based-on-call-view-model/main.ts
@@ -8,125 +8,80 @@ Please see LICENSE in the repository root for full details.
/**
* LEGACY, in production use
*
- * This file is the entrypoint of the legacy SDK bundle,
- * `pnpm build:sdk-target-based-on-call-view-model`, for use in widgets. It
- * exposes `createMatrixRTCSdk`, which creates the `MatrixRTCSdk` interface
- * (see below) to join an RTC session and exchange realtime data.
- *
- * 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.
+ * The entrypoint of `pnpm build:sdk-target-based-on-call-view-model`, a
+ * bundle for widgets that exposes `createMatrixRTCSdk`: join a slot and
+ * exchange realtime data, with the `@element-hq/matrixrtc-sdk` package and
+ * 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.
*/
-import {
- combineLatest,
- filter,
- map,
- type Observable,
- of,
- switchMap,
-} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
+import { EMPTY, filter, map, type Observable, of, switchMap, take } from "rxjs";
import {
type Behavior,
- createMatrixRTCClient,
+ createRTCSlot,
E2eeType,
+ type LocalRTCMember,
ObservableScope,
- type RTCMember,
- type RTCMembership,
+ type RemoteRTCMember,
+ type RTCParticipation,
} from "@element-hq/matrixrtc-sdk";
import {
callViewModelOptionsFromParams,
+ type CallViewModel,
createCallViewModel$,
initialPublishRequests,
} 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 {
captureSettings,
- matrixRTCClientOptions,
+ rtcParticipationOptions,
+ rtcSlotOptions,
} 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";
-/**
- * A member as this bundle has always presented it. The media transport is
- * 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;
-}
+/** The data channel topic this bundle has always used, so that old and new builds talk. */
+const TOPIC = "matrixRTC";
-interface MatrixRTCSdk {
- /**
- * observe connected$ to track the state.
- * @returns
- */
+export interface MatrixRTCSdk {
+ /** Joins the slot; `connected$` tracks the progress. A second call does nothing. */
join: () => void;
- /** @throws on leave errors */
leave: () => void;
- /**
- * 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.
- */
+ /** Ends everything. The sdk cannot be restarted; create a new one. */
stop: () => void;
- data$: Observable<{ rtcBackendIdentity: string; data: string }>;
- /**
- * flattened list of remote members
- */
- remoteMembers$: Behavior;
- /**
- * flattened local member
- */
- localMember$: Behavior;
- /** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
connected$: Behavior;
- sendData?: (data: unknown) => Promise;
- sendRoomMessage?: (message: string) => Promise;
+ /** Our own member, null until our membership has been seen in the room. */
+ localMember$: Behavior;
+ /** The other members of the slot, with the media the participation carries for them. */
+ remoteMembers$: Behavior;
+ /** 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;
+ sendRoomMessage: (message: string) => Promise;
}
export async function createMatrixRTCSdk(
- application: string = "m.call",
- id: string = "",
- sticky: boolean = false,
+ application = "m.call",
+ id = "",
+ sticky = false,
): Promise {
const logger = rootLogger.getChild("[MatrixRTCSdk]");
const scope = new ObservableScope();
-
- // widget client
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 hostBridge = createWidgetHostBridge(widget);
- logger.info("client created");
-
- // url params
const urlParams = getUrlParams();
const { roomId, controlledAudioDevices, callIntent } = urlParams;
- if (roomId === null) throw Error("could not get roomId from url params");
- const room = client.getRoom(roomId);
- if (room === null) throw Error("could not get room from client");
+ const room = roomId === null ? null : client.getRoom(roomId);
+ if (room === null) throw new Error("No room: the url needs a joined roomId");
- // media devices
const mediaDevices = new MediaDevices(scope, {
controlledAudioDevices,
callIntent,
@@ -137,136 +92,97 @@ export async function createMatrixRTCSdk(
{ audioEnabled: false, videoEnabled: false },
hostBridge,
);
-
- // rtc client: the session, the transport and the media, as in the app
const encryptionSystem = { kind: E2eeType.PER_PARTICIPANT } as const;
const capture = captureSettings();
- const rtcClient = createMatrixRTCClient(scope, client, room, {
- ...matrixRTCClientOptions(urlParams, encryptionSystem),
- publish: initialPublishRequests(muteStates, mediaDevices, capture),
+ const slot = createRTCSlot(scope, client, room, {
+ ...rtcSlotOptions(encryptionSystem),
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 callViewModel = createCallViewModel$(
- scope,
- rtcClient,
- room,
- mediaDevices,
- muteStates,
- {
- ...callViewModelOptionsFromParams(urlParams),
- encryptionSystem,
- capture,
- hostBridge,
- // The SDK owns its page, so the body is the space it has
- windowSize$: scope.behavior(observeElementSize$(document.body)),
- },
- of({}),
- of({}),
- );
- logger.info("CallViewModelCreated");
-
- // Data arrives from attested members only; the SDK has matched the sender
- const data$ = rtcClient.data$.pipe(
- filter(({ topic }) => topic === TEXT_LK_TOPIC),
- map(({ member, text }) => {
- logger.info(`Received text from ${member.id}: ${text}`);
- return { rtcBackendIdentity: member.id, data: text };
- }),
- );
-
- const sendData = async (data: unknown): Promise => {
- const dataString = JSON.stringify(data);
- 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 => {
- 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 => {
- logger.info("waiting for RTC leave");
- await new Promise((resolve) => {
- rtcClient.status$
- .pipe(filter((status) => status === "disconnected"))
- .subscribe(() => resolve());
- });
- logger.info("send Unstick");
- await hostBridge
- .setAlwaysOnScreen(false)
- .catch((e: unknown) =>
- logger.error("Failed to set `alwaysOnScreen` to false", e),
- );
- 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");
-
- const flatten = (member: RTCMember): Observable =>
- member.membership$.pipe(
- map((membership) => ({
- connection: null,
- membership,
- participant: null,
- })),
+ const over = (
+ pick: (participation: RTCParticipation) => Observable,
+ fallback: T,
+ ): Behavior =>
+ scope.behavior(
+ slot.participation$.pipe(
+ switchMap((p) => (p === null ? of(fallback) : pick(p))),
+ ),
);
return {
join: (): void => {
- // first lets try making the widget sticky
- if (sticky) tryMakeSticky(widget);
- callViewModel.join();
+ 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,
+ participation,
+ room,
+ mediaDevices,
+ muteStates,
+ {
+ ...callViewModelOptionsFromParams(urlParams),
+ encryptionSystem,
+ capture,
+ hostBridge,
+ // The bundle owns its page, so the body is the space it has
+ windowSize$: scope.behavior(observeElementSize$(document.body)),
+ },
+ of({}),
+ of({}),
+ );
+ // Once the hang-up has gone through, unstick and ask the host to close us
+ vm.leave$
+ .pipe(
+ switchMap(() =>
+ participation.status$.pipe(
+ filter((status) => status === "left"),
+ take(1),
+ ),
+ ),
+ scope.bind(),
+ )
+ .subscribe(() => {
+ hostBridge
+ .setAlwaysOnScreen(false)
+ .catch(failed("Failed to unstick the widget"))
+ .then(() => hostBridge.close?.())
+ .catch(failed("Failed to ask the host to close"));
+ });
},
- leave: (): void => {
- callViewModel.leave();
- },
- stop: (): void => {
- leaveSubs.unsubscribe();
- scope.end();
- },
- data$,
- localMember$: scope.behavior(
- rtcClient.localMember$.pipe(
- switchMap((member) => (member === null ? of(null) : flatten(member))),
- ),
+ leave: (): void => vm?.leave(),
+ stop: (): void => scope.end(),
+ connected$: over((p) => p.connected$, false),
+ localMember$: over((p) => p.localMember$, null),
+ remoteMembers$: over((p) => p.remoteMembers$, []),
+ // Attested members only: the SDK has matched the sender
+ data$: slot.participation$.pipe(
+ switchMap((p) => p?.data$ ?? EMPTY),
+ filter(({ topic }) => topic === TOPIC),
+ map(({ member, text }) => ({
+ rtcBackendIdentity: member.rtcBackendIdentity,
+ data: text,
+ })),
),
- connected$: callViewModel.connected$,
- remoteMembers$: scope.behavior(
- rtcClient.remoteMembers$.pipe(
- switchMap((members) =>
- members.length === 0
- ? of([])
- : combineLatest(members.map((member) => flatten(member))),
- ),
- ),
- [],
- ),
- sendData,
- sendRoomMessage,
+ sendData: async (data): Promise => {
+ const participation = slot.participation$.value;
+ if (participation === null) throw new Error("Not joined");
+ await participation.sendData(TOPIC, JSON.stringify(data));
+ },
+ sendRoomMessage: async (message): Promise => {
+ await client.sendTextMessage(room.roomId, message);
+ },
};
}