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,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