mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
add support for sending data to the rtcSdk
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { startPair, tileOf } from "./harness";
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test("a message sent beside the media reaches the peer, and only the peer", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { pages, users } = await startPair(browser, "sdkdata");
|
||||
const [sender, receiver] = pages;
|
||||
// Both are in the call before anything is sent, so the receiver knows the
|
||||
// sender as a member and does not drop the message
|
||||
for (const page of pages)
|
||||
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(tileOf(receiver, users[0])).toBeVisible();
|
||||
|
||||
await sender.getByPlaceholder("message").fill("hello from the SDK");
|
||||
await sender.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
const message = receiver.getByTestId("message");
|
||||
await expect(message).toHaveText(
|
||||
`${users[0].displayName}: hello from the SDK`,
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
await expect(message).toHaveAttribute("data-topic", "chat");
|
||||
await expect(message).toHaveAttribute("data-user-id", users[0].userId);
|
||||
// Our own messages are not echoed back
|
||||
await expect(sender.getByTestId("message")).toHaveCount(0);
|
||||
});
|
||||
@@ -301,6 +301,28 @@ export interface MatrixRTCClient {
|
||||
* needs.
|
||||
*/
|
||||
connectedTransports$: Behavior<TransportMetadata[]>;
|
||||
|
||||
/**
|
||||
* Sends a short text to every member on the local transport, over a
|
||||
* reliable data channel beside the media. A packet holds about 15 KiB;
|
||||
* anything larger belongs in a room event. Rejects while the local
|
||||
* transport is not connected. Encrypted on the wire like the media, but
|
||||
* not end to end with the media key.
|
||||
*/
|
||||
sendData(topic: string, text: string): Promise<void>;
|
||||
/**
|
||||
* What remote members sent with `sendData`, on every transport the client
|
||||
* is connected to. A message from an identity that is not a member is
|
||||
* dropped, so a host only ever hears from attested members.
|
||||
*/
|
||||
data$: Observable<DataMessage>;
|
||||
}
|
||||
|
||||
/** One `sendData` call as it arrives at a remote member. */
|
||||
export interface DataMessage {
|
||||
member: RemoteRTCMember;
|
||||
topic: string;
|
||||
text: string;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -603,6 +625,13 @@ public API only.
|
||||
- **Developer panel.** `resolved$` on each connected transport covers what the
|
||||
panel shows today. Anything beyond that (LiveKit room state, connection
|
||||
quality) needs an opaque `debug$` per transport.
|
||||
- **Data beside the media.** `sendData`/`data$` ride on LiveKit's reliable data
|
||||
packets: topic-agnostic, matched to members by identity, received on every
|
||||
connection but sent only on the local transport, so a member on another
|
||||
transport does not hear us. They are transport-encrypted, not end to end:
|
||||
LiveKit can encrypt data packets with the media key, but that is not wired
|
||||
through the key provider yet. A host that needs either reach or end-to-end
|
||||
encryption uses room events.
|
||||
- **Unencrypted rooms with per-participant keys.** Keys only reach devices the
|
||||
crypto tracks, which are the members of encrypted rooms. If the SDK is to work
|
||||
in an unencrypted room, tracking the members' devices becomes its job.
|
||||
|
||||
+14
-7
@@ -8,12 +8,13 @@ symbols, so it ages with the code; the architecture document should not.
|
||||
|
||||
## Status
|
||||
|
||||
| Slice | State |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| 1. Scaffold: `sdk/` packaging, the interface, the harness, the smoke test | done, October 2026 |
|
||||
| 2. First implementation of `createMatrixRTCClient` under `sdk/src`, the media adapter, the e2e suite | done, 1 October 2026 |
|
||||
| 3. Copy the shared modules into `sdk/src/` with their tests, drop every `src/` import, turn on the import boundary | done, 2 October 2026 |
|
||||
| 4. `createCallViewModel$` consumes the client; media view models take `media$`; Element Call imports the primitives and enums from the SDK and deletes its own copies; the old demo target is removed | done, 2 October 2026 |
|
||||
| Slice | State |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| 1. Scaffold: `sdk/` packaging, the interface, the harness, the smoke test | done, October 2026 |
|
||||
| 2. First implementation of `createMatrixRTCClient` under `sdk/src`, the media adapter, the e2e suite | done, 1 October 2026 |
|
||||
| 3. Copy the shared modules into `sdk/src/` with their tests, drop every `src/` import, turn on the import boundary | done, 2 October 2026 |
|
||||
| 4. `createCallViewModel$` consumes the client; media view models take `media$`; Element Call imports the primitives and enums from the SDK and deletes its own copies | done, 2 October 2026 |
|
||||
| 5. `sendData`/`data$` on the client; the legacy bundle `sdk-target-based-on-call-view-model/` is rebuilt on the SDK with its interface unchanged, since it is in production use | done, 2 October 2026 |
|
||||
|
||||
Element Call now consumes the SDK. `InCallView` creates the client from the
|
||||
configuration, settings and URL parameters, and hands it to `createCallViewModel$`,
|
||||
@@ -336,4 +337,10 @@ document, "Vocabulary") settles the first three; the js-sdk rename is still open
|
||||
`vite-sdk.config.ts` (`pnpm build:sdk`), `vite-component.config.ts`,
|
||||
`vite-embedded.config.ts`, `vite.config.ts`. The old SDK target
|
||||
(`sdk-target-based-on-call-view-model/`) is the one whose dependency direction the
|
||||
migration flips; it goes in slice 5.
|
||||
migration flips. It is in production use, so slice 5 rebuilt it on the SDK instead of
|
||||
removing it: `createMatrixRTCSdk` keeps its signature, `join`/`leave`/`stop`,
|
||||
`connected$`, `data$` (`{ rtcBackendIdentity, data }` on the `matrixRTC` topic),
|
||||
`sendData`, `sendRoomMessage`, `localMember$` and `remoteMembers$` keep their shapes.
|
||||
The one break is inside the member objects: `connection` and `participant` are
|
||||
always `null` now, because the SDK does not hand out LiveKit objects; `membership`
|
||||
is unchanged. The bundle is removed once its consumers use the package directly.
|
||||
|
||||
@@ -63,6 +63,12 @@ Please see LICENSE in the repository root for full details.
|
||||
<button type="button" id="leave" hidden>Leave</button>
|
||||
</form>
|
||||
<p id="status" data-testid="status"></p>
|
||||
<!-- A line of text to the other members, beside the media -->
|
||||
<form id="data" hidden>
|
||||
<input name="text" placeholder="message" />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<ul id="messages"></ul>
|
||||
<main id="members"></main>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -29,6 +29,8 @@ import { createSession, joinRoom, type Login } from "./session";
|
||||
const form = document.querySelector("form")!;
|
||||
const status = document.getElementById("status")!;
|
||||
const members = document.getElementById("members")!;
|
||||
const dataForm = document.getElementById("data") as HTMLFormElement;
|
||||
const messages = document.getElementById("messages")!;
|
||||
const buttons = {
|
||||
microphone: document.getElementById("microphone") as HTMLButtonElement,
|
||||
camera: document.getElementById("camera") as HTMLButtonElement,
|
||||
@@ -89,6 +91,7 @@ async function start(
|
||||
if (error !== null) status.textContent = `Error: ${error.message}`;
|
||||
});
|
||||
showMembers(scope, rtcClient);
|
||||
showMessages(scope, rtcClient);
|
||||
rtcClient.join();
|
||||
|
||||
toggle(buttons.microphone, async (enabled) =>
|
||||
@@ -102,6 +105,8 @@ async function start(
|
||||
rtcClient.leave();
|
||||
scope.end();
|
||||
members.replaceChildren();
|
||||
messages.replaceChildren();
|
||||
dataForm.hidden = true;
|
||||
for (const button of Object.values(buttons)) button.hidden = true;
|
||||
status.textContent = "Left";
|
||||
};
|
||||
@@ -124,6 +129,31 @@ function toggle(
|
||||
};
|
||||
}
|
||||
|
||||
/** Sends what the form holds on the "chat" topic, and lists what arrives. */
|
||||
function showMessages(
|
||||
scope: ObservableScope,
|
||||
rtcClient: MatrixRTCClient,
|
||||
): void {
|
||||
dataForm.hidden = false;
|
||||
dataForm.onsubmit = (event): void => {
|
||||
event.preventDefault();
|
||||
const text = new FormData(dataForm).get("text") as string;
|
||||
rtcClient.sendData("chat", text).then(
|
||||
() => dataForm.reset(),
|
||||
(e: unknown) => {
|
||||
status.textContent = `Error: ${e}`;
|
||||
},
|
||||
);
|
||||
};
|
||||
rtcClient.data$.pipe(scope.bind()).subscribe(({ member, topic, text }) => {
|
||||
const line = messages.appendChild(document.createElement("li"));
|
||||
line.dataset.testid = "message";
|
||||
line.dataset.topic = topic;
|
||||
line.dataset.userId = member.userId;
|
||||
line.textContent = `${member.displayName$.value}: ${text}`;
|
||||
});
|
||||
}
|
||||
|
||||
function showMembers(scope: ObservableScope, rtcClient: MatrixRTCClient): void {
|
||||
const tiles = new Map<string, HTMLElement>();
|
||||
combineLatest([rtcClient.localMember$, rtcClient.remoteMembers$])
|
||||
|
||||
@@ -153,6 +153,28 @@ export interface MatrixRTCClient {
|
||||
|
||||
/** Transports the session currently holds a live connection to. */
|
||||
connectedTransports$: Behavior<TransportMetadata[]>;
|
||||
|
||||
/**
|
||||
* Sends a short text to every member on the local transport, over a
|
||||
* reliable data channel beside the media. A packet holds about 15 KiB;
|
||||
* anything larger belongs in a room event. Rejects while the local
|
||||
* transport is not connected. Encrypted on the wire like the media, but
|
||||
* not end to end with the media key.
|
||||
*/
|
||||
sendData(topic: string, text: string): Promise<void>;
|
||||
/**
|
||||
* What remote members sent with `sendData`, on every transport the client
|
||||
* is connected to. A message from an identity that is not a member is
|
||||
* dropped, so a host only ever hears from attested members.
|
||||
*/
|
||||
data$: Observable<DataMessage>;
|
||||
}
|
||||
|
||||
/** One `sendData` call as it arrives at a remote member. */
|
||||
export interface DataMessage {
|
||||
member: RemoteRTCMember;
|
||||
topic: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import { type RemoteParticipant, RoomEvent } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type DataMessage, type RemoteRTCMember } from "../api";
|
||||
import { Epoch } from "../reactive/ObservableScope";
|
||||
import { constant } from "../reactive/Behavior";
|
||||
import { type Connection, ConnectionState } from "./Connection";
|
||||
import { ConnectionManagerData } from "./ConnectionManager";
|
||||
import { createDataChannel$ } from "./DataChannel";
|
||||
import { exampleTransport, testScope } from "../utils/test";
|
||||
|
||||
const encode = (text: string): Uint8Array => new TextEncoder().encode(text);
|
||||
|
||||
function fakeConnection(
|
||||
url: string,
|
||||
state = ConnectionState.LivekitConnected,
|
||||
): {
|
||||
connection: Connection;
|
||||
room: EventEmitter;
|
||||
publishData: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const room = new EventEmitter();
|
||||
const publishData = vi.fn(async () => Promise.resolve());
|
||||
const connection = {
|
||||
transport: { ...exampleTransport, livekit_service_url: url },
|
||||
state$: constant(state),
|
||||
livekitRoom: Object.assign(room, { localParticipant: { publishData } }),
|
||||
} as unknown as Connection;
|
||||
return { connection, room, publishData };
|
||||
}
|
||||
|
||||
function member(id: string): RemoteRTCMember {
|
||||
return { id, userId: `@${id}:example.org` } as RemoteRTCMember;
|
||||
}
|
||||
|
||||
function setup(
|
||||
connections: Connection[],
|
||||
members: RemoteRTCMember[] = [member("alice")],
|
||||
): {
|
||||
received: DataMessage[];
|
||||
connections$: BehaviorSubject<Epoch<ConnectionManagerData>>;
|
||||
sendData: (topic: string, text: string) => Promise<void>;
|
||||
} {
|
||||
const data = new ConnectionManagerData();
|
||||
for (const connection of connections) data.add(connection, []);
|
||||
const connections$ = new BehaviorSubject(new Epoch(data));
|
||||
const { data$, sendData } = createDataChannel$({
|
||||
scope: testScope(),
|
||||
connectionManager: { connectionManagerData$: connections$ },
|
||||
remoteMembers$: constant(members),
|
||||
connection$: constant(connections[0] ?? null),
|
||||
logger,
|
||||
});
|
||||
const received: DataMessage[] = [];
|
||||
data$.subscribe((message) => received.push(message));
|
||||
return { received, connections$, sendData };
|
||||
}
|
||||
|
||||
describe("data$", () => {
|
||||
it("delivers a packet from a member with its topic and text", () => {
|
||||
const { connection, room } = fakeConnection("https://sfu");
|
||||
const { received } = setup([connection]);
|
||||
|
||||
room.emit(
|
||||
RoomEvent.DataReceived,
|
||||
encode("hello"),
|
||||
{ identity: "alice" } as RemoteParticipant,
|
||||
undefined,
|
||||
"chat",
|
||||
);
|
||||
|
||||
expect(received).toEqual([
|
||||
{ member: member("alice"), topic: "chat", text: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops packets from the server and from identities that are not members", () => {
|
||||
const { connection, room } = fakeConnection("https://sfu");
|
||||
const { received } = setup([connection]);
|
||||
|
||||
room.emit(RoomEvent.DataReceived, encode("server"), undefined);
|
||||
room.emit(RoomEvent.DataReceived, encode("stranger"), {
|
||||
identity: "mallory",
|
||||
} as RemoteParticipant);
|
||||
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it("listens on every connection, including ones added later", () => {
|
||||
const first = fakeConnection("https://sfu-1");
|
||||
const second = fakeConnection("https://sfu-2");
|
||||
const { received, connections$ } = setup(
|
||||
[first.connection],
|
||||
[member("alice"), member("bob")],
|
||||
);
|
||||
|
||||
const data = new ConnectionManagerData();
|
||||
data.add(first.connection, []);
|
||||
data.add(second.connection, []);
|
||||
connections$.next(new Epoch(data, 1));
|
||||
|
||||
first.room.emit(RoomEvent.DataReceived, encode("one"), {
|
||||
identity: "alice",
|
||||
} as RemoteParticipant);
|
||||
second.room.emit(RoomEvent.DataReceived, encode("two"), {
|
||||
identity: "bob",
|
||||
} as RemoteParticipant);
|
||||
|
||||
expect(received.map((m) => [m.member.id, m.text])).toEqual([
|
||||
["alice", "one"],
|
||||
["bob", "two"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendData", () => {
|
||||
it("publishes the text as a reliable packet on the local connection", async () => {
|
||||
const { connection, publishData } = fakeConnection("https://sfu");
|
||||
const { sendData } = setup([connection]);
|
||||
|
||||
await sendData("chat", "hello");
|
||||
|
||||
expect(publishData).toHaveBeenCalledWith(encode("hello"), {
|
||||
reliable: true,
|
||||
topic: "chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects while the local transport is not connected", async () => {
|
||||
const { connection, publishData } = fakeConnection(
|
||||
"https://sfu",
|
||||
ConnectionState.Initialized,
|
||||
);
|
||||
const { sendData } = setup([connection]);
|
||||
|
||||
await expect(sendData("chat", "hello")).rejects.toThrow("not connected");
|
||||
expect(publishData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type RemoteParticipant,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
} from "livekit-client";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
share,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { type DataMessage, type RemoteRTCMember } from "../api";
|
||||
import { type Behavior } from "../reactive/Behavior";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { type Connection, ConnectionState } from "./Connection";
|
||||
import { type IConnectionManager } from "./ConnectionManager";
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
connectionManager: IConnectionManager;
|
||||
/** Used to resolve the sender of each message, by its transport identity. */
|
||||
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||
/** The local member's connection, which carries what we send. */
|
||||
connection$: Behavior<Connection | null>;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface DataChannel {
|
||||
data$: Observable<DataMessage>;
|
||||
sendData: (topic: string, text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A text channel beside the media: LiveKit's reliable data packets, received
|
||||
* on every connection the client holds and sent on the local member's. The
|
||||
* sender is matched to a member by its identity, so the packet's own claim of
|
||||
* who sent it never reaches the host.
|
||||
*/
|
||||
export function createDataChannel$({
|
||||
scope,
|
||||
connectionManager,
|
||||
remoteMembers$,
|
||||
connection$,
|
||||
logger,
|
||||
}: Props): DataChannel {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const rooms$ = connectionManager.connectionManagerData$.pipe(
|
||||
map(({ value }) => value.getConnections().map((c) => c.livekitRoom)),
|
||||
distinctUntilChanged(
|
||||
(a, b) => a.length === b.length && a.every((room, i) => room === b[i]),
|
||||
),
|
||||
);
|
||||
|
||||
const data$ = rooms$.pipe(
|
||||
switchMap((rooms) => merge(...rooms.map(receivedOn))),
|
||||
map(({ payload, participant, topic }): DataMessage | null => {
|
||||
// Only the server sends without a participant; a host has no use for it
|
||||
if (participant === undefined) return null;
|
||||
const member = remoteMembers$.value.find(
|
||||
(m) => m.id === participant.identity,
|
||||
);
|
||||
if (member === undefined) {
|
||||
logger.warn(`Dropping data from ${participant.identity}: not a member`);
|
||||
return null;
|
||||
}
|
||||
return { member, topic: topic ?? "", text: decoder.decode(payload) };
|
||||
}),
|
||||
filter((message) => message !== null),
|
||||
scope.bind(),
|
||||
share(),
|
||||
);
|
||||
|
||||
const sendData = async (topic: string, text: string): Promise<void> => {
|
||||
const connection = connection$.value;
|
||||
if (connection?.state$.value !== ConnectionState.LivekitConnected)
|
||||
throw new Error("Cannot send data: the local transport is not connected");
|
||||
await connection.livekitRoom.localParticipant.publishData(
|
||||
encoder.encode(text),
|
||||
{ reliable: true, topic },
|
||||
);
|
||||
};
|
||||
|
||||
return { data$, sendData };
|
||||
}
|
||||
|
||||
interface Received {
|
||||
payload: Uint8Array;
|
||||
participant: RemoteParticipant | undefined;
|
||||
topic: string | undefined;
|
||||
}
|
||||
|
||||
function receivedOn(room: LivekitRoom): Observable<Received> {
|
||||
return fromEvent(
|
||||
room,
|
||||
RoomEvent.DataReceived,
|
||||
(
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant,
|
||||
_kind?: unknown,
|
||||
topic?: string,
|
||||
): Received => ({ payload, participant, topic }),
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
membershipKeys,
|
||||
} from "./Members";
|
||||
import { createConnectionManager$ } from "./ConnectionManager";
|
||||
import { createDataChannel$ } from "./DataChannel";
|
||||
import { type DesiredMedia, Publisher } from "./Publisher";
|
||||
import {
|
||||
createKeyRotationSuppressed$,
|
||||
@@ -248,6 +249,14 @@ export function createMatrixRTCClient(
|
||||
).pipe(map((member) => member ?? null)),
|
||||
);
|
||||
|
||||
const { data$, sendData } = createDataChannel$({
|
||||
scope,
|
||||
connectionManager,
|
||||
remoteMembers$,
|
||||
connection$: localMembership.connection$,
|
||||
logger,
|
||||
});
|
||||
|
||||
const status$ = scope.behavior(
|
||||
combineLatest(
|
||||
[
|
||||
@@ -280,6 +289,8 @@ export function createMatrixRTCClient(
|
||||
),
|
||||
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
|
||||
connectedTransports$: transports.connected$,
|
||||
sendData,
|
||||
data$,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user