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); + }, }; }