mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
sdk-target-based-on-call-view-model client -> slot/participation
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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>";
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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<MatrixRTCSdkMember[]>;
|
||||
/**
|
||||
* flattened local member
|
||||
*/
|
||||
localMember$: Behavior<MatrixRTCSdkMember | null>;
|
||||
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
|
||||
connected$: Behavior<boolean>;
|
||||
sendData?: (data: unknown) => Promise<void>;
|
||||
sendRoomMessage?: (message: string) => Promise<void>;
|
||||
/** Our own member, null until our membership has been seen in the room. */
|
||||
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(
|
||||
application: string = "m.call",
|
||||
id: string = "",
|
||||
sticky: boolean = false,
|
||||
application = "m.call",
|
||||
id = "",
|
||||
sticky = false,
|
||||
): Promise<MatrixRTCSdk> {
|
||||
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<void> => {
|
||||
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<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)
|
||||
.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<MatrixRTCSdkMember> =>
|
||||
member.membership$.pipe(
|
||||
map((membership) => ({
|
||||
connection: null,
|
||||
membership,
|
||||
participant: null,
|
||||
})),
|
||||
const over = <T>(
|
||||
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 => {
|
||||
// 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<void> => {
|
||||
const participation = slot.participation$.value;
|
||||
if (participation === null) throw new Error("Not joined");
|
||||
await participation.sendData(TOPIC, JSON.stringify(data));
|
||||
},
|
||||
sendRoomMessage: async (message): Promise<void> => {
|
||||
await client.sendTextMessage(room.roomId, message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user