Compare commits

..
Author SHA1 Message Date
Johannes Marbach b7bc218b19 Merge pull request #4367 from RaphaelFakhri/RaphaelFakhri/shared-key-storage-key
Read and write the room shared key under its single-prefixed storage key
2026-10-28 16:21:11 +01:00
Raphael Fakhri 85424f76b1 Ignore the URL password when the parameters are for another room 2026-10-28 14:33:07 +00:00
renovate[bot] 6793a4b46e Update ghcr.io/element-hq/element-web:develop Docker digest to ee8ce55 (#4380)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-28 02:32:09 +00:00
Robin 619f5ac3a2 Merge pull request #4375 from element-hq/robin/stats-crash
Fix crash when joining call with connection stats enabled
2026-10-27 18:08:31 +01:00
Robin 5780597b83 Fix crash when joining call with connection stats enabled
As demonstrated by the test. This crashed with "Behavior failed to synchronously emit an initial value."
2026-10-27 16:17:58 +01:00
Robin 76da59be40 Merge pull request #4301 from element-hq/floating-tile
Make drag & drop of floating tiles less janky
2026-10-27 12:16:12 +01:00
Robin c6ad8b2ac7 Use the right comparators 2026-10-27 11:45:35 +01:00
Robin d33a1eb059 Merge branch 'floating-tile' of github.com:element-hq/element-call into floating-tile 2026-10-27 11:04:12 +01:00
Robin 9fcba5a704 Skip redundant tile wrapper updates 2026-10-27 10:44:33 +01:00
RaphaelFakhri dc1459e45e Prefer the stored shared key when the parameters are for another room
keyForRoom returned the password from the parameters even when they were for a different room, and useRoomSharedKey then saved it for this room. Use the stored key first in that case, so another room's password can't replace it.
2026-10-26 21:13:23 +00:00
Johannes Marbach 96333dbc3a Merge branch 'main' into floating-tile 2026-10-26 13:46:54 +01:00
Raphael Fakhri bda7b86c39 Read and write the room shared key under its single-prefixed storage key 2026-10-22 12:24:39 +00:00
Robin 4441bb5e8c Add snapshot coverage for all call layouts
Snapshotting the layout templates so that they get reviewed as a whole feels a little more straightforward than the existing tests.
2026-09-29 03:20:14 +02:00
Robin f2f49315eb Make drag & drop performant in all layouts
While also giving the logic for drag & drop of a floating tile a common home in the view model layer.
2026-09-28 19:53:30 +02:00
Robin 99256fa01c Make drag & drop of floating tiles less janky
This fixes an issue where upon dragging a floating tile to a different corner of the interface, it would briefly move toward its original position before jerking back in the intended direction.
2026-09-28 19:48:49 +02:00
57 changed files with 873 additions and 4638 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
image: ghcr.io/element-hq/element-web:develop@sha256:ee8ce55012dda2c097147859aece32802ce7a9174e40c3711c56f75ccfe1b169
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
image: ghcr.io/element-hq/element-web:develop@sha256:ee8ce55012dda2c097147859aece32802ce7a9174e40c3711c56f75ccfe1b169
-3
View File
@@ -71,9 +71,6 @@
"device_id": "Device ID: {{id}}",
"duplicate_tiles_label": "Number of additional tile copies per participant",
"environment_variables": "Environment variables",
"full_mesh_peer_connections": "Full mesh peer connections",
"full_mesh_publishing": "Publish on full mesh (MSCXXXX) instead of LiveKit",
"full_mesh_transport": "Full mesh: {{device}}",
"hostname": "Hostname: {{hostname}}",
"livekit_server_info": "LiveKit Server Info",
"livekit_sfu": "LiveKit SFU: {{url}}",
-125
View File
@@ -1,125 +0,0 @@
/*
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, type Page, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user";
import { HOST1, TestHelpers } from "./test-helpers";
/**
* Records the LiveKit signalling websockets a page opens: the SFU is reached
* with an access token in the url, which full mesh never needs.
*/
function recordSfuSockets(page: Page): string[] {
const urls: string[] = [];
page.on("websocket", (ws) => {
if (ws.url().includes("access_token")) urls.push(ws.url());
});
return urls;
}
const cases: { name: string; fullMesh: [boolean, boolean] }[] = [
// Both publish on full mesh: the media flows without any SFU
{ name: "both on full mesh", fullMesh: [true, true] },
// One on each: Florian receives Valere over LiveKit without publishing there
{ name: "full mesh and LiveKit mixed", fullMesh: [true, false] },
];
cases.forEach(({ name, fullMesh: [florianMesh, valereMesh] }) => {
widgetTest(`Full mesh call: ${name}`, async ({ addUser, browserName }) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow();
// Distinct per case: the cases run in parallel and register at once
const suffix = `${Number(florianMesh)}${Number(valereMesh)}`;
const [florian, valere] = await Promise.all([
addUser(`florian${suffix}`, HOST1),
addUser(`valere${suffix}`, HOST1),
]);
// Element Web doesn't auto-approve the full mesh signalling for Element
// Call yet, so it asks for it whenever a widget with full mesh opens
for (const { page } of [florian, valere])
await page.addLocatorHandler(
page.getByRole("heading", { name: "Approve widget permissions" }),
async () => page.getByRole("button", { name: "Approve" }).click(),
);
const sockets = {
florian: recordSfuSockets(florian.page),
valere: recordSfuSockets(valere.page),
};
const roomName = "Full Mesh Room";
await TestHelpers.createRoom(roomName, florian.page, [valere.mxId]);
await TestHelpers.acceptRoomInvite(roomName, valere.page);
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
"2_0",
{ fullMesh: florianMesh },
);
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
valere.page,
"2_0",
{ fullMesh: valereMesh },
);
await TestHelpers.startCallInCurrentRoom(florian.page, false);
await TestHelpers.joinCallFromLobby(florian.page);
await TestHelpers.joinCallInCurrentRoom(valere.page);
// A widget only asks for the full mesh signalling with full mesh turned
// on, so a LiveKit publisher in Element Web can't receive full mesh yet:
// the other's media arrives if it is on LiveKit or we have full mesh
const receives = {
florian: !valereMesh || florianMesh,
valere: !florianMesh || valereMesh,
};
// In parallel: assertions on a page also run its permission handler
await Promise.all(
(["florian", "valere"] as const).map(async (name) => {
const frame = { florian, valere }[name].page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
timeout: 15000,
});
const waiting = frame.getByText("Waiting for media...");
if (receives[name]) {
// Over whichever transport carries it
await expect(waiting).not.toBeVisible({ timeout: 20000 });
await TestHelpers.expectVisibleVideoCount(frame, 2);
} else {
await expect(waiting).toBeVisible({ timeout: 20000 });
await TestHelpers.expectVisibleVideoCount(frame, 1);
}
}),
);
if (florianMesh && valereMesh) {
// Nobody published on LiveKit, so nobody connected to an SFU
expect(sockets.florian).toEqual([]);
expect(sockets.valere).toEqual([]);
} else {
// Florian receives Valere's media from the SFU without publishing there
expect(sockets.florian.length).toBeGreaterThan(0);
}
// Florian sees Valere's camera go off: over the stream metadata for full
// mesh, without renegotiating
const valereFrame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await valereFrame.getByRole("switch", { name: "Stop video" }).click();
const florianFrame = florian.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await TestHelpers.expectVisibleVideoCount(florianFrame, 1);
});
});
+1 -7
View File
@@ -292,7 +292,6 @@ export class TestHelpers {
public static async openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
page: Page,
mode: RtcMode,
{ fullMesh = false }: { fullMesh?: boolean } = {},
): Promise<void> {
await page.getByRole("button", { name: "Video call" }).click({
timeout: 5000,
@@ -301,7 +300,7 @@ export class TestHelpers {
timeout: 10000,
});
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode, { fullMesh });
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
await page.getByRole("button", { name: "Close lobby" }).click();
}
@@ -314,7 +313,6 @@ export class TestHelpers {
public static async setEmbeddedElementCallRtcMode(
page: Page,
mode: RtcMode,
{ fullMesh = false }: { fullMesh?: boolean } = {},
): Promise<void> {
const iframe = page.locator('iframe[title="Element Call"]').contentFrame();
@@ -332,10 +330,6 @@ export class TestHelpers {
// compat
await iframe.getByText("Compatibility: state events").click();
}
// Publish on the full mesh transport (MSCXXXX) instead of LiveKit
await iframe
.getByText("Publish on full mesh", { exact: false })
.setChecked(fullMesh);
await iframe.getByTestId("modal_close").click();
}
+4 -9
View File
@@ -66,14 +66,9 @@ media backend: whatever carries the media, behind the `MediaBackend` interface i
`sdk/src/media-backend/api.ts`. The client is the join of the two: a member exists
once its membership does, and gets its media once the backend has something
for that membership on its transport; `state$` is the membership state combined
with the local media connection. Two backends exist, LiveKit (`livekit/`) and
full mesh (`full-mesh/`, MSCXXXX), and the client always runs both, combined
(`combineBackends.ts`). It publishes on the backend of the type of
`options.transport`, LiveKit without one, and hands every remote member to the
backend of that member's transport, so members on either are received. A
backend nobody is on connects nowhere. Another transport is another folder
under `sdk/src/media-backend/`, added to the combination, and nothing in the
left box changes; `options.backend` replaces the combination altogether.
with the local media connection. LiveKit is the one backend today; a
cascading SFU or full mesh is another folder under `sdk/src/media-backend/`, handed
in through `options.backend`, and nothing in the left box changes.
### How Element Call uses it
@@ -154,7 +149,7 @@ the sections below still describe `MatrixRTCClient` until that lands.
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation: LiveKit and full mesh combined, publishing on the type of `transport` in the join options, unless `backend` replaces them | "SFU", a LiveKit room, a peer connection |
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
`RTCMember`, `matrixRTCMode`.
-8
View File
@@ -61,14 +61,6 @@ export {
createLivekitBackend,
type LivekitBackendOptions,
} from "./src/media-backend/livekit/LivekitBackend";
// Join with `transport: { type: FULL_MESH_TRANSPORT_TYPE }` to publish on full mesh
export {
createFullMeshBackend,
FULL_MESH_TRANSPORT_TYPE,
type FullMeshBackendOptions,
} from "./src/media-backend/full-mesh/FullMeshBackend";
// For a widget host to allow the full mesh signalling
export { signallingEventTypes as fullMeshSignallingEventTypes } from "./src/media-backend/full-mesh/signalling";
// For a host that creates LiveKit tracks itself, such as a preview before joining
export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor";
// For a developer panel to check a transport before using it
+3 -26
View File
@@ -50,11 +50,6 @@ import {
type MediaBackendContext,
type MediaKey,
} from "./media-backend/api";
import { combineBackends } from "./media-backend/combineBackends";
import {
completeFullMeshTransport,
createFullMeshBackend,
} from "./media-backend/full-mesh/FullMeshBackend";
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
import { findRemotePublishLivekitTransport } from "./media-backend/livekit/RemotePublishTransport";
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
@@ -126,13 +121,6 @@ export function createRTCParticipation(
: `${userId}:${deviceId}`,
};
// The host names full mesh by its type alone; the device is ours
if (options.transport)
options = {
...options,
transport: completeFullMeshTransport(options.transport, deviceId),
};
const backend = createBackend(scope, client, options, {
roomId: room.roomId,
slotId: jsSdkSession.slotId,
@@ -322,26 +310,15 @@ export function createRTCParticipation(
};
}
/**
* The host's backend, or every backend the SDK has, combined: we publish on
* the type of the transport the host named (LiveKit without one) and receive
* from members on any of them.
*/
/** The host's backend, or LiveKit configured from the participation options. */
function createBackend(
scope: ObservableScope,
client: MatrixClient,
{ backend, mediaQuality, transport }: RTCParticipationOptions,
{ backend, mediaQuality }: RTCParticipationOptions,
context: MediaBackendContext,
): MediaBackend {
if (backend) return backend(scope, context);
return combineBackends(
scope,
[
createLivekitBackend(scope, context, { client, mediaQuality }),
createFullMeshBackend(scope, context, { client }),
],
transport?.type ?? "livekit",
);
return createLivekitBackend(scope, context, { client, mediaQuality });
}
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
@@ -1,91 +0,0 @@
/*
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 { describe, expect, it, vi } from "vitest";
import { BehaviorSubject, NEVER } from "rxjs";
import { type RTCMember, type TransportMetadata } from "../api";
import { type MemberMedia } from "../media-api";
import { constant } from "../reactive/Behavior";
import { ObservableScope } from "../reactive/ObservableScope";
import { type MediaBackend } from "./api";
import { combineBackends } from "./combineBackends";
function fakeBackend(type: string): MediaBackend & { ended: string[] } {
const ended: string[] = [];
return {
ended,
transportType: type,
prepareLocalTransport: vi.fn(async () =>
Promise.resolve({ canDelegateDelayedLeave: false }),
),
delegateDelayedLeave: vi.fn(),
local: {} as MediaBackend["local"],
mediaFor: (scope, member): MemberMedia => {
scope.onEnd(() => ended.push(member.memberId));
return {
tracks$: constant([{ id: `${type}:${member.memberId}` }] as never),
encryptionError$: NEVER,
};
},
connections$: constant([
{ transport: { type }, state: "connected" as never, resolved: undefined },
]),
setAudioOutputDeviceId: vi.fn(),
sendData: vi.fn(),
data$: NEVER,
};
}
function transport(type: string): TransportMetadata {
return { type, id: type, raw: { type }, resolved$: constant(undefined) };
}
describe("combineBackends", () => {
it("routes each member to the backend of its transport type", () => {
const scope = new ObservableScope();
const livekit = fakeBackend("livekit");
const mesh = fakeBackend("full_mesh");
const combined = combineBackends(scope, [livekit, mesh], "livekit");
expect(combined.transportType).toBe("livekit");
expect(combined.connections$.value).toHaveLength(2);
const transport$ = new BehaviorSubject<TransportMetadata | undefined>(
transport("full_mesh"),
);
const memberScope = new ObservableScope();
const media = combined.mediaFor(memberScope, {
memberId: "m1",
transport$,
} as unknown as RTCMember);
expect(media.tracks$.value).toEqual([{ id: "full_mesh:m1" }]);
transport$.next(transport("livekit"));
expect(mesh.ended).toEqual(["m1"]);
expect(media.tracks$.value).toEqual([{ id: "livekit:m1" }]);
transport$.next(transport("unknown"));
expect(media.tracks$.value).toBeNull();
// The publishing backend keeps every member for as long as it is one
expect(livekit.ended).toEqual([]);
memberScope.end();
expect(livekit.ended).toEqual(["m1"]);
scope.end();
});
it("publishes on the backend of the publish type", () => {
const scope = new ObservableScope();
const livekit = fakeBackend("livekit");
const mesh = fakeBackend("full_mesh");
const combined = combineBackends(scope, [livekit, mesh], "full_mesh");
expect(combined.transportType).toBe("full_mesh");
expect(() => combineBackends(scope, [livekit, mesh], "other")).toThrow(
"No media backend for transport other",
);
scope.end();
});
});
-80
View File
@@ -1,80 +0,0 @@
/*
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 { combineLatest, map, merge, NEVER, of, switchMap } from "rxjs";
import { type RTCMember } from "../api";
import { MatrixRTCError } from "../errors";
import { type MemberMedia } from "../media-api";
import { type ObservableScope } from "../reactive/ObservableScope";
import { mapScoped } from "../utils/mapScoped";
import { type MediaBackend } from "./api";
/**
* Several backends as one: we publish on the backend of `publishType`, and
* receive from every member on a transport any of the backends serves. A
* member is handed to the backend of its current transport type. A backend
* nobody is on costs nothing: it connects only for the members handed to it.
*/
export function combineBackends(
scope: ObservableScope,
all: MediaBackend[],
publishType: string,
): MediaBackend {
const byType = new Map(all.map((b) => [b.transportType, b]));
const publish = byType.get(publishType);
if (publish === undefined)
throw new MatrixRTCError(`No media backend for transport ${publishType}`);
const mediaFor = (
memberScope: ObservableScope,
member: RTCMember,
): MemberMedia => {
// The publishing backend sees every member, whatever its transport, so
// that it can tell members from strangers asking for our media (full
// mesh subscribers on other transports). It connects nowhere for them
const publishMedia = publish.mediaFor(memberScope, member);
const type$ = memberScope.behavior(
member.transport$.pipe(map((transport) => transport?.type)),
);
const media$ = mapScoped(memberScope, type$, (s, type) =>
type === publish.transportType
? publishMedia
: byType.get(type)?.mediaFor(s, member),
);
return {
tracks$: memberScope.behavior(
media$.pipe(switchMap((media) => media?.tracks$ ?? of(null))),
),
encryptionError$: media$.pipe(
switchMap((media) => media?.encryptionError$ ?? NEVER),
),
};
};
return {
transportType: publish.transportType,
prepareLocalTransport: async (transport) =>
publish.prepareLocalTransport(transport),
delegateDelayedLeave: async (delayId) =>
publish.delegateDelayedLeave(delayId),
local: publish.local,
mediaFor,
connections$: scope.behavior(
combineLatest(all.map((b) => b.connections$)).pipe(
map((lists) => lists.flat()),
),
),
setAudioOutputDeviceId: async (deviceId) => {
await Promise.all(
all.map(async (b) => b.setAudioOutputDeviceId(deviceId)),
);
},
sendData: async (topic, text) => publish.sendData(topic, text),
data$: merge(...all.map((b) => b.data$)),
};
}
@@ -1,432 +0,0 @@
/*
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "events";
import {
ClientEvent,
EventType,
type ITurnServer,
MatrixEvent,
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, NEVER } from "rxjs";
import { type RTCMember, type TransportMetadata } from "../../api";
import { defaultSessionTimings } from "../../config";
import { E2eeType } from "../../encryption";
import { constant } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type MediaBackend, MediaConnectionState } from "../api";
import {
completeFullMeshTransport,
createFullMeshBackend,
type FullMeshBackendOptions,
} from "./FullMeshBackend";
import { FULL_MESH_TRANSPORT_TYPE, SignallingEventType } from "./signalling";
import {
FakePeerConnection,
installWebRtcFakes,
lastPeerConnection,
peer,
settle,
} from "./test";
const roomId = "!room:example.org";
const slotId = "m.call#ROOM";
const own = { userId: "@me:example.org", deviceId: "MYDEVICE", memberId: "me" };
const fullMesh = (deviceId: string): Transport => ({
type: FULL_MESH_TRANSPORT_TYPE,
device_id: deviceId,
});
const livekit: Transport = { type: "livekit", livekit_service_url: "x" };
function metadata(raw: Transport): TransportMetadata {
return {
type: raw.type,
id: JSON.stringify(raw),
raw,
resolved$: constant(undefined),
};
}
describe("createFullMeshBackend", () => {
let scope: ObservableScope;
let client: EventEmitter & FullMeshBackendOptions["client"];
let send: ReturnType<typeof vi.fn>;
let turnServers: ITurnServer[];
let backend: MediaBackend;
const seq = new Map<string, number>();
/** A to-device message from `peer` (or another sender), as the sync would deliver it. */
function deliver(
type: string,
connectionId: string,
content: Record<string, unknown> = {},
from = peer,
): void {
const next = seq.get(from.memberId) ?? 0;
seq.set(from.memberId, next + 1);
const event = new MatrixEvent({
type,
sender: from.userId,
content: {
room_id: roomId,
slot_id: slotId,
member_id: from.memberId,
target_member_id: own.memberId,
device_id: from.deviceId,
connection_id: connectionId,
seq: next,
...content,
},
});
vi.spyOn(event, "getWireType").mockReturnValue(
EventType.RoomMessageEncrypted,
);
client.emit(ClientEvent.ToDeviceEvent, event);
}
/** What we sent, as [type, recipient device, content]. */
function sent(type?: string): [string, string, Record<string, unknown>][] {
return send.mock.calls
.filter(([t]) => type === undefined || t === type)
.map(([t, [target], content]) => [t, target.deviceId, content]);
}
/** A member of the slot, as the session hands it to the backend. */
function addMember(
transport: Transport,
who = peer,
): { scope: ObservableScope; tracks: () => unknown } {
const memberScope = new ObservableScope();
const media = backend.mediaFor(memberScope, {
...who,
transport$: new BehaviorSubject(metadata(transport)),
} as unknown as RTCMember);
return { scope: memberScope, tracks: () => media.tracks$.value };
}
beforeEach(() => {
vi.useFakeTimers();
installWebRtcFakes();
seq.clear();
scope = new ObservableScope();
send = vi.fn(async () => Promise.resolve());
turnServers = [];
client = Object.assign(new EventEmitter(), {
encryptAndSendToDevice: send,
getTurnServers: (): ITurnServer[] => turnServers,
checkTurnServers: vi.fn(async () => {
turnServers = [{ urls: ["turn:x"], username: "u", credential: "p" }];
return Promise.resolve(true);
}),
}) as unknown as EventEmitter & FullMeshBackendOptions["client"];
backend = createFullMeshBackend(
scope,
{
roomId,
slotId,
ownMembershipIdentity: own,
publish: [],
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
mediaKeys$: NEVER,
timings: defaultSessionTimings,
logger,
},
{ client },
);
});
afterEach(() => {
scope.end();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("only prepares a full mesh transport", async () => {
await expect(backend.prepareLocalTransport(livekit)).rejects.toThrow(
"Not a full mesh transport: livekit",
);
await expect(
backend.prepareLocalTransport(fullMesh(own.deviceId)),
).resolves.toEqual({ canDelegateDelayedLeave: false });
expect(backend.local.connectionState$.value).toBe(
MediaConnectionState.Connected,
);
});
it("fetches TURN servers when the client has none yet", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
expect(client.checkTurnServers).toHaveBeenCalledOnce();
addMember(fullMesh(peer.deviceId));
await settle();
expect(lastPeerConnection().config.iceServers).toEqual([
{ urls: ["turn:x"], username: "u", credential: "p" },
]);
});
describe("as a publisher", () => {
it("turns subscribers away while it doesn't publish", async () => {
addMember(livekit);
deliver(SignallingEventType.Subscribe, "c1");
await settle();
expect(sent()).toEqual([
[
SignallingEventType.Close,
peer.deviceId,
expect.objectContaining({
reason: "not_publishing",
connection_id: "c1",
}),
],
]);
});
it("offers to a member on any transport", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
addMember(livekit);
deliver(SignallingEventType.Subscribe, "c1");
await settle();
expect(sent(SignallingEventType.Description)).toEqual([
[
SignallingEventType.Description,
peer.deviceId,
expect.objectContaining({
connection_id: "c1",
target_member_id: peer.memberId,
description: { type: "offer", sdp: "offer-1" },
}),
],
]);
});
it("waits for the membership of a subscriber that is ahead of it", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
deliver(SignallingEventType.Subscribe, "c1");
await vi.advanceTimersByTimeAsync(5_000);
expect(sent()).toEqual([]);
addMember(livekit);
await settle();
expect(sent(SignallingEventType.Description)).toHaveLength(1);
});
it("rejects a subscriber that never becomes a member", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
deliver(SignallingEventType.Subscribe, "c1");
await vi.advanceTimersByTimeAsync(10_000);
expect(sent()).toEqual([
[
SignallingEventType.Close,
peer.deviceId,
expect.objectContaining({ reason: "not_a_member" }),
],
]);
expect(FakePeerConnection.instances).toEqual([]);
});
it("rejects a subscriber claiming another device of the member", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
addMember(livekit);
deliver(
SignallingEventType.Subscribe,
"c1",
{},
{
...peer,
deviceId: "OTHERDEVICE",
},
);
await settle();
expect(sent()).toEqual([
[
SignallingEventType.Close,
"OTHERDEVICE",
expect.objectContaining({ reason: "not_a_member" }),
],
]);
});
it("only serves the newest of several waiting subscribe requests", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
deliver(SignallingEventType.Subscribe, "c1");
deliver(SignallingEventType.Subscribe, "c2");
addMember(livekit);
await settle();
expect(
sent(SignallingEventType.Description).map(([, , c]) => c.connection_id),
).toEqual(["c2"]);
});
it("replaces the connection of a member that subscribes again", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
addMember(livekit);
deliver(SignallingEventType.Subscribe, "c1");
await settle();
const first = lastPeerConnection();
deliver(SignallingEventType.Subscribe, "c2");
await settle();
expect(first.closed).toBe(true);
expect(lastPeerConnection()).not.toBe(first);
});
it("hands the subscriber's messages to its connection", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
addMember(livekit);
deliver(SignallingEventType.Subscribe, "c1");
await settle();
deliver(SignallingEventType.Description, "c1", {
description: { type: "answer", sdp: "answer" },
});
deliver(SignallingEventType.Candidates, "c1", {
candidates: [{ candidate: "x" }],
});
// A late message of an earlier connection goes nowhere
deliver(SignallingEventType.Candidates, "c0", {
candidates: [{ candidate: "stale" }],
});
await settle();
const pc = lastPeerConnection();
expect(pc.remoteDescription).toEqual({ type: "answer", sdp: "answer" });
expect(pc.addedCandidates).toEqual([{ candidate: "x" }]);
});
it("stops sending to a member that leaves", async () => {
await backend.prepareLocalTransport(fullMesh(own.deviceId));
const member = addMember(livekit);
deliver(SignallingEventType.Subscribe, "c1");
await settle();
member.scope.end();
expect(lastPeerConnection().closed).toBe(true);
expect(sent(SignallingEventType.Close)).toEqual([
[
SignallingEventType.Close,
peer.deviceId,
expect.objectContaining({ connection_id: "c1" }),
],
]);
});
it("lists its subscribers for the developer tools", async () => {
expect(backend.connections$.value).toEqual([]);
await backend.prepareLocalTransport(fullMesh(own.deviceId));
expect(backend.connections$.value).toEqual([
{
transport: fullMesh(own.deviceId),
state: MediaConnectionState.Connected,
resolved: {
type: FULL_MESH_TRANSPORT_TYPE,
role: "publisher",
peerConnections: [],
},
},
]);
addMember(livekit);
deliver(SignallingEventType.Subscribe, "c1");
await settle();
expect(backend.connections$.value[0].resolved).toMatchObject({
peerConnections: [
{
memberId: peer.memberId,
connectionId: "c1",
connectionState: "new",
},
],
});
});
});
describe("as a subscriber", () => {
it("subscribes to a member publishing on full mesh", async () => {
const member = addMember(fullMesh(peer.deviceId));
await settle();
expect(sent()).toEqual([
[
SignallingEventType.Subscribe,
peer.deviceId,
expect.objectContaining({
room_id: roomId,
slot_id: slotId,
member_id: own.memberId,
device_id: own.deviceId,
target_member_id: peer.memberId,
seq: 0,
}),
],
]);
expect(member.tracks()).toBeNull();
expect(backend.connections$.value).toEqual([
expect.objectContaining({
transport: fullMesh(peer.deviceId),
resolved: expect.objectContaining({ role: "subscriber" }),
}),
]);
});
it("ignores a full mesh transport advertising another device", async () => {
addMember(fullMesh("OTHERDEVICE"));
await settle();
expect(sent()).toEqual([]);
});
it("leaves members on other transports alone", async () => {
const member = addMember(livekit);
await settle();
expect(sent()).toEqual([]);
expect(member.tracks()).toBeNull();
});
it("hands the publisher's messages to the subscription", async () => {
addMember(fullMesh(peer.deviceId));
await settle();
const [[, , subscribe]] = sent();
lastPeerConnection().receive("video", "user");
deliver(
SignallingEventType.Description,
subscribe.connection_id as string,
{
description: { type: "offer", sdp: "offer" },
stream_metadata: { user: { purpose: "m.usermedia" } },
},
);
await settle();
expect(sent(SignallingEventType.Description)).toEqual([
[
SignallingEventType.Description,
peer.deviceId,
expect.objectContaining({
description: { type: "answer", sdp: "answer" },
}),
],
]);
});
it("closes the subscription when the member leaves", async () => {
const member = addMember(fullMesh(peer.deviceId));
await settle();
member.scope.end();
expect(lastPeerConnection().closed).toBe(true);
expect(backend.connections$.value).toEqual([]);
});
});
it("has no data channel and doesn't take over the delayed leave", async () => {
await expect(backend.sendData("topic", "text")).rejects.toThrow();
await expect(backend.delegateDelayedLeave("id")).rejects.toThrow();
});
});
describe("completeFullMeshTransport", () => {
it("adds our device to a full mesh transport only", () => {
expect(
completeFullMeshTransport({ type: FULL_MESH_TRANSPORT_TYPE }, "DEV"),
).toEqual({ type: FULL_MESH_TRANSPORT_TYPE, device_id: "DEV" });
expect(completeFullMeshTransport(livekit, "DEV")).toBe(livekit);
});
});
@@ -1,393 +0,0 @@
/*
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.
*/
/**
* The full mesh transport (MSCXXXX): one WebRTC connection per subscriber
* and publisher, signalled over to-device messages, through the TURN server
* of each side's homeserver. Works subscribe-only too: without
* `prepareLocalTransport` it publishes nothing and still receives from every
* member on full mesh.
*/
import { type MatrixClient } from "matrix-js-sdk";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
map,
NEVER,
type Observable,
of,
Subject,
switchMap,
timeout,
} from "rxjs";
import { type RTCMember } from "../../api";
import { MatrixRTCError } from "../../errors";
import { type MemberMedia } from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import { mapScoped } from "../../utils/mapScoped";
import {
type BackendConnection,
type MediaBackend,
type MediaBackendContext,
MediaConnectionState,
} from "../api";
import { createFullMeshLocalMedia } from "./LocalMedia";
import { type PeerConnectionBase } from "./PeerConnection";
import {
createPublisherConnection,
type PublisherConnection,
} from "./PublisherConnection";
import {
createSignalling,
FULL_MESH_TRANSPORT_TYPE,
type Peer,
type SignallingClient,
SignallingEventType,
type SignallingMessage,
} from "./signalling";
import {
createSubscriberConnection,
type SubscriberConnection,
} from "./SubscriberConnection";
export { FULL_MESH_TRANSPORT_TYPE } from "./signalling";
/** How long a subscribe request waits for the subscriber's membership to arrive. */
const SUBSCRIBE_GRACE_MS = 10_000;
export interface FullMeshBackendOptions {
client: SignallingClient &
Pick<MatrixClient, "getTurnServers" | "checkTurnServers">;
}
/**
* The transport object a member publishing on full mesh advertises: a host
* passes `{ type: FULL_MESH_TRANSPORT_TYPE }`, and the device is filled in.
* Any other transport is returned as it is.
*/
export function completeFullMeshTransport(
transport: Transport,
deviceId: string,
): Transport {
return transport.type === FULL_MESH_TRANSPORT_TYPE
? { ...transport, device_id: deviceId }
: transport;
}
export function createFullMeshBackend(
scope: ObservableScope,
{
roomId,
slotId,
ownMembershipIdentity,
publish,
logger: parentLogger,
}: MediaBackendContext,
{ client }: FullMeshBackendOptions,
): MediaBackend {
const logger = parentLogger.getChild("[FullMeshBackend]");
const signalling = createSignalling({
scope,
client,
roomId,
slotId,
own: ownMembershipIdentity,
logger,
});
const iceServers = async (): Promise<RTCIceServer[]> => {
if (client.getTurnServers().length === 0)
await client
.checkTurnServers()
.catch((e: unknown) => logger.warn("No TURN servers", e));
return client.getTurnServers().map(({ urls, username, credential }) => ({
urls,
username,
credential,
}));
};
let localTransport: Transport | null = null;
const prepared$ = new BehaviorSubject(false);
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
undefined,
);
const members = new Map<string, RTCMember>();
const memberAdded$ = new Subject<RTCMember>();
const subscribers = new Map<
string,
{ transport: Transport; peer: Peer; connection: SubscriberConnection }
>();
const publishers = new Map<
string,
{ connection: PublisherConnection; peer: Peer; scope: ObservableScope }
>();
scope.onEnd(() => {
for (const { scope: s } of Array.from(publishers.values())) s.end();
});
/** The connection id of the newest subscribe request per member. */
const latestSubscribe = new Map<string, string>();
// For the developer tools: one entry per publisher we subscribe to, and one
// for our own transport with every subscriber we send to. `resolved` lists
// the peer connections, as an SFU's entry lists what it connected to
const connectionsChanged$ = new BehaviorSubject<void>(undefined);
const connections$ = scope.behavior<BackendConnection[]>(
combineLatest([connectionsChanged$, prepared$]).pipe(
switchMap(([, prepared]) => {
const entries: Observable<BackendConnection>[] = [];
for (const { transport, peer, connection } of subscribers.values()) {
const base = connection.current$.value;
if (base)
entries.push(
base.state$.pipe(
map((state) => ({
transport,
state,
resolved: {
type: FULL_MESH_TRANSPORT_TYPE,
role: "subscriber",
peerConnections: [describe(peer, base)],
},
})),
),
);
}
if (localTransport && prepared) {
const transport = localTransport;
const outgoing = [...publishers.values()];
entries.push(
(outgoing.length === 0
? of([])
: combineLatest(outgoing.map((p) => p.connection.state$))
).pipe(
map(() => ({
transport,
// Publishing needs no server, so it is up once prepared
state: MediaConnectionState.Connected,
resolved: {
type: FULL_MESH_TRANSPORT_TYPE,
role: "publisher",
peerConnections: outgoing.map(({ peer, connection }) =>
describe(peer, connection),
),
},
})),
),
);
}
return entries.length === 0 ? of([]) : combineLatest(entries);
}),
),
);
const local = createFullMeshLocalMedia({
scope,
publish,
connectionState$: scope.behavior(
prepared$.pipe(
map((prepared) =>
prepared
? MediaConnectionState.Connected
: MediaConnectionState.Initialized,
),
),
),
peerConnections: () =>
[...publishers.values()].map(({ connection }) => connection.pc),
logger,
});
const acceptSubscribe = async (message: SignallingMessage): Promise<void> => {
const peer: Peer = {
userId: message.userId,
deviceId: message.deviceId,
memberId: message.memberId,
};
// We don't advertise full mesh, so nobody should have asked
if (!prepared$.value) {
signalling.send(peer, message.connectionId, {
type: SignallingEventType.Close,
reason: "not_publishing",
});
return;
}
latestSubscribe.set(peer.memberId, message.connectionId);
// The request may overtake the subscriber's membership
const member =
members.get(peer.memberId) ??
(await firstValueFrom(
memberAdded$.pipe(
filter((m) => m.memberId === peer.memberId),
timeout(SUBSCRIBE_GRACE_MS),
),
).catch(() => undefined));
if (latestSubscribe.get(peer.memberId) !== message.connectionId) return;
if (
member === undefined ||
member.userId !== peer.userId ||
member.deviceId !== peer.deviceId
) {
logger.warn(`Rejecting a subscribe from ${peer.memberId}`);
signalling.send(peer, message.connectionId, {
type: SignallingEventType.Close,
reason: "not_a_member",
});
return;
}
// At most one connection per member pair: the new one replaces the old
publishers.get(peer.memberId)?.scope.end();
const pubScope = new ObservableScope();
const connection = createPublisherConnection({
scope: pubScope,
peer,
connectionId: message.connectionId,
signalling,
iceServers: await iceServers(),
local,
logger,
});
publishers.set(peer.memberId, { connection, peer, scope: pubScope });
pubScope.onEnd(() => {
if (publishers.get(peer.memberId)?.connection === connection) {
publishers.delete(peer.memberId);
connectionsChanged$.next();
}
});
connectionsChanged$.next();
};
signalling.messages$.pipe(scope.bind()).subscribe((message) => {
const subscriber = subscribers.get(message.memberId)?.connection;
if (subscriber?.current$.value?.connectionId === message.connectionId)
return subscriber.handle(message);
const publisher = publishers.get(message.memberId)?.connection;
if (publisher?.connectionId === message.connectionId)
return publisher.handle(message);
if (message.type === SignallingEventType.Subscribe)
return void acceptSubscribe(message);
logger.debug(`Dropping ${message.type} of a stale connection`);
});
const mediaFor = (
memberScope: ObservableScope,
member: RTCMember,
): MemberMedia => {
members.set(member.memberId, member);
memberAdded$.next(member);
memberScope.onEnd(() => {
members.delete(member.memberId);
// We stop sending to members that are no longer joined
publishers.get(member.memberId)?.scope.end();
});
// In encrypted rooms the advertised device has to be the one the
// membership came from
const fullMesh$ = memberScope.behavior(
member.transport$.pipe(
map((transport) =>
transport?.type === FULL_MESH_TRANSPORT_TYPE &&
transport.raw.device_id === member.deviceId
? transport.raw
: undefined,
),
distinctUntilChanged((a, b) => a?.device_id === b?.device_id),
),
);
const subscriber$ = mapScoped(memberScope, fullMesh$, (s, transport) => {
const connection = createSubscriberConnection({
scope: s,
peer: {
userId: member.userId,
deviceId: member.deviceId,
memberId: member.memberId,
},
signalling,
iceServers,
audioOutputDeviceId$,
logger,
});
subscribers.set(member.memberId, {
transport,
peer: {
userId: member.userId,
deviceId: member.deviceId,
memberId: member.memberId,
},
connection,
});
connection.current$
.pipe(s.bind())
.subscribe(() => connectionsChanged$.next());
s.onEnd(() => {
subscribers.delete(member.memberId);
connectionsChanged$.next();
});
return connection;
});
return {
tracks$: memberScope.behavior(
subscriber$.pipe(switchMap((s) => s?.tracks$ ?? of(null))),
),
encryptionError$: NEVER,
};
};
return {
transportType: FULL_MESH_TRANSPORT_TYPE,
prepareLocalTransport: async (transport) => {
if (transport.type !== FULL_MESH_TRANSPORT_TYPE)
throw new MatrixRTCError(
`Not a full mesh transport: ${transport.type}`,
);
localTransport = transport;
// Fetched now, so that the first subscriber isn't kept waiting
await iceServers();
prepared$.next(true);
return { canDelegateDelayedLeave: false };
},
delegateDelayedLeave: async () =>
Promise.reject(
new MatrixRTCError("Full mesh cannot take over the delayed leave"),
),
local,
mediaFor,
connections$: connections$ as Behavior<BackendConnection[]>,
setAudioOutputDeviceId: async (deviceId) => {
audioOutputDeviceId$.next(deviceId);
return Promise.resolve();
},
sendData: async () =>
Promise.reject(new MatrixRTCError("Full mesh has no data channel yet")),
data$: NEVER,
};
}
/** One peer connection as the developer tools show it. */
function describe(
{ userId, deviceId, memberId }: Peer,
{ connectionId, pc }: PeerConnectionBase,
): Record<string, string> {
return {
userId,
deviceId,
memberId,
connectionId,
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
signalingState: pc.signalingState,
};
}
@@ -1,456 +0,0 @@
/*
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 Logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
combineLatest,
filter,
firstValueFrom,
map,
NEVER,
of,
switchMap,
} from "rxjs";
import { v4 as uuidv4 } from "uuid";
import { MatrixRTCError, toMatrixRTCError } from "../../errors";
import {
type LocalAudioMediaTrack,
type LocalVideoMediaTrack,
type MediaSource,
type PublishRequest,
type VideoProcessor,
} from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type LocalMediaBackend, type MediaConnectionState } from "../api";
import { createFullMeshMediaTrack, rtpStats } from "./MediaTrack";
import { type StreamMetadata } from "./signalling";
type PublishedSource = Exclude<MediaSource, "unknown">;
/** One local track as the publisher connections send it. */
export interface LocalPublication {
source: PublishedSource;
kind: "audio" | "video";
/**
* The `MediaStream` it is sent in: one for the user media, one for the
* screen share. Its id is the `msid` the stream metadata is keyed by.
*/
stream: MediaStream;
/** What goes on the wire; null while the source is off. */
track$: Behavior<MediaStreamTrack | null>;
mediaTrack: LocalAudioMediaTrack | LocalVideoMediaTrack;
}
export interface FullMeshLocalMedia extends LocalMediaBackend {
/** Every local track, once the first `setPublishing(true)` started the capture. */
publications$: Behavior<LocalPublication[]>;
/** Whether the tracks reach the publisher connections. */
publishing$: Behavior<boolean>;
streamMetadata$: Behavior<StreamMetadata>;
}
interface Props {
scope: ObservableScope;
publish: PublishRequest[];
connectionState$: Behavior<MediaConnectionState | Error>;
/** The publisher connections, for the outbound statistics of a track. */
peerConnections: () => RTCPeerConnection[];
logger: Logger;
}
interface Capture {
/** What was captured, before any processor. */
raw: MediaStreamTrack;
out: MediaStreamTrack;
}
/** The local member's side: capture, the publication list and the controls. */
export function createFullMeshLocalMedia({
scope,
publish: initialRequests,
connectionState$,
peerConnections,
logger: parentLogger,
}: Props): FullMeshLocalMedia {
const logger = parentLogger.getChild("[LocalMedia]");
// Only their ids matter: the tracks are added per connection
const userMediaStream = new MediaStream();
const screenShareStream = new MediaStream();
const desired = new Map<PublishRequest["source"], PublishRequest>(
initialRequests.map((r) => [r.source, r]),
);
const started$ = new BehaviorSubject(false);
const publishing$ = new BehaviorSubject(false);
const publishError$ = new BehaviorSubject<Error | null>(null);
const publications$ = new BehaviorSubject<LocalPublication[]>([]);
const teardowns = new Map<PublishedSource, () => void>();
const statsFor = async (track: MediaStreamTrack | null) => {
for (const pc of peerConnections())
if (pc.getSenders().some((s) => s.track === track && track !== null))
return rtpStats(pc, track, "outbound-rtp");
return undefined;
};
/** Adds a publication whose track is managed by `control`. */
const addPublication = (
source: PublishedSource,
kind: "audio" | "video",
stream: MediaStream,
initial: MediaStreamTrack,
control: {
setEnabled: (
enabled: boolean,
track$: BehaviorSubject<MediaStreamTrack | null>,
) => Promise<boolean>;
setDevice?: (
deviceId: string,
track$: BehaviorSubject<MediaStreamTrack | null>,
) => Promise<void>;
switchFacingMode?: (
track$: BehaviorSubject<MediaStreamTrack | null>,
) => Promise<string | undefined>;
setProcessor?: (
p: VideoProcessor | undefined,
track$: BehaviorSubject<MediaStreamTrack | null>,
) => Promise<void>;
stop: () => void;
},
): LocalPublication => {
const pubScope = new ObservableScope();
const track$ = new BehaviorSubject<MediaStreamTrack | null>(initial);
const enabled$ = new BehaviorSubject(true);
const base = createFullMeshMediaTrack(pubScope, {
source,
kind,
id: uuidv4(),
track$,
muted$: pubScope.behavior(enabled$.pipe(map((e) => !e))),
stats: async () => statsFor(track$.value),
});
const local = {
setEnabled: async (enabled: boolean): Promise<boolean> => {
const result = await control.setEnabled(enabled, track$);
enabled$.next(result);
return result;
},
setDevice: async (deviceId: string): Promise<void> => {
if (!control.setDevice)
throw new MatrixRTCError(`A ${source} track has no device`);
await control.setDevice(deviceId, track$);
},
};
const mediaTrack =
base.kind === "audio"
? { ...base, ...local }
: {
...base,
...local,
facingMode$: pubScope.behavior(
track$.pipe(map((t) => facingModeOf(t))),
),
switchFacingMode: async () => control.switchFacingMode?.(track$),
setProcessor: async (p: VideoProcessor | undefined) =>
control.setProcessor?.(p, track$),
};
const publication: LocalPublication = {
source,
kind,
stream,
track$,
mediaTrack: mediaTrack as LocalAudioMediaTrack | LocalVideoMediaTrack,
};
publications$.next([...publications$.value, publication]);
teardowns.set(source, () => {
control.stop();
track$.value?.stop();
pubScope.end();
publications$.next(publications$.value.filter((p) => p !== publication));
teardowns.delete(source);
});
return publication;
};
const captureMicrophone = async (
request: Extract<PublishRequest, { source: "microphone" }>,
): Promise<void> => {
const track = await getTrack({
audio: { deviceId: exact(request.deviceId), ...request.capture },
});
addPublication("microphone", "audio", userMediaStream, track, {
// Muting a microphone keeps the device: unmuting is instant
setEnabled: async (enabled, track$) => {
if (track$.value) track$.value.enabled = enabled;
return Promise.resolve(enabled);
},
setDevice: async (deviceId, track$) => {
const next = await getTrack({
audio: { deviceId: { exact: deviceId }, ...request.capture },
});
next.enabled = track$.value?.enabled ?? true;
track$.value?.stop();
track$.next(next);
},
stop: () => {},
});
};
const captureCamera = async (
request: Extract<PublishRequest, { source: "camera" }>,
): Promise<void> => {
let deviceId = request.deviceId;
let facingMode: string | undefined;
let processor = request.processor;
let capture: Capture | null = null;
let processorStarted = false;
const constraints = (): MediaTrackConstraints => ({
deviceId: exact(deviceId),
facingMode,
width: request.capture?.resolution?.width,
height: request.capture?.resolution?.height,
frameRate: request.capture?.resolution?.frameRate,
});
const open = async (): Promise<Capture> => {
const raw = await getTrack({ video: constraints() });
if (processor === undefined) return { raw, out: raw };
const out = processorStarted
? await processor.restart(raw)
: await processor.init(raw);
processorStarted = true;
return { raw, out };
};
const close = (): void => {
capture?.raw.stop();
if (capture?.out !== capture?.raw) capture?.out.stop();
capture = null;
};
// Reopens the camera with the current settings, if it is on
const reopen = async (
track$: BehaviorSubject<MediaStreamTrack | null>,
): Promise<void> => {
if (capture === null) return;
close();
capture = await open();
track$.next(capture.out);
};
capture = await open();
addPublication("camera", "video", userMediaStream, capture.out, {
// Muting a camera releases it, so that its light goes off
setEnabled: async (enabled, track$) => {
if (enabled === (capture !== null)) return enabled;
if (!enabled) {
close();
track$.next(null);
return false;
}
try {
capture = await open();
track$.next(capture.out);
return true;
} catch (e) {
logger.warn("Could not reopen the camera", e);
return false;
}
},
setDevice: async (id, track$) => {
deviceId = id;
facingMode = undefined;
await reopen(track$);
},
switchFacingMode: async (track$) => {
const current = facingModeOf(track$.value);
if (current === undefined) return undefined;
deviceId = undefined;
facingMode = current === "user" ? "environment" : "user";
await reopen(track$);
return capture?.raw.getSettings().deviceId;
},
setProcessor: async (p, track$) => {
await processor?.destroy();
processor = p;
processorStarted = false;
if (capture === null) return;
const out = p ? await p.init(capture.raw) : capture.raw;
processorStarted = p !== undefined;
if (capture.out !== capture.raw) capture.out.stop();
capture = { raw: capture.raw, out };
track$.next(out);
},
stop: () => {
close();
void processor?.destroy();
},
});
};
const captureScreen = async (
request: Extract<PublishRequest, { source: "screenShare" }>,
): Promise<void> => {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: request.capture?.resolution?.width,
height: request.capture?.resolution?.height,
frameRate: request.capture?.resolution?.frameRate,
},
audio: request.audio ?? true,
});
const [video] = stream.getVideoTracks();
const [audio] = stream.getAudioTracks();
const toggle = async (
enabled: boolean,
track$: BehaviorSubject<MediaStreamTrack | null>,
): Promise<boolean> => {
if (track$.value) track$.value.enabled = enabled;
return Promise.resolve(enabled);
};
addPublication("screenShare", "video", screenShareStream, video, {
setEnabled: toggle,
// The audio goes with its video
stop: () => {
audio?.stop();
teardowns.get("screenShareAudio")?.();
},
});
if (audio)
addPublication("screenShareAudio", "audio", screenShareStream, audio, {
setEnabled: toggle,
stop: () => {},
});
// Stopped from the browser's own controls
video.addEventListener("ended", () => void unpublish("screenShare"));
};
const capture = async (request: PublishRequest): Promise<void> => {
teardowns.get(request.source)?.();
switch (request.source) {
case "microphone":
return captureMicrophone(request);
case "camera":
return captureCamera(request);
case "screenShare":
return captureScreen(request);
}
};
const start = async (): Promise<void> => {
for (const request of desired.values())
await capture(request).catch((e: unknown) => {
logger.warn(`Could not capture ${request.source}`, e);
if (publishError$.value === null)
publishError$.next(toMatrixRTCError(e));
});
};
const setPublishing = (publish: boolean): void => {
publishing$.next(publish);
if (publish && !started$.value) {
started$.next(true);
void start();
}
};
const publish = async (
request: PublishRequest,
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack> => {
desired.set(request.source, request);
try {
// Before the start, the start captures everything desired itself
if (started$.value) await capture(request);
return await firstValueFrom(
publications$.pipe(
map((ps) => ps.find((p) => p.source === request.source)),
filter((p) => p !== undefined),
map((p) => p.mediaTrack),
),
);
} catch (e) {
desired.delete(request.source);
throw toMatrixRTCError(e);
}
};
const unpublish = async (source: MediaSource): Promise<void> => {
const published = source === "screenShareAudio" ? "screenShare" : source;
if (published === "unknown") return;
desired.delete(published);
teardowns.get(published)?.();
return Promise.resolve();
};
scope.onEnd(() => {
// Each teardown removes itself from the map
for (const teardown of Array.from(teardowns.values())) teardown();
});
const tracks$ = scope.behavior(
combineLatest([started$, publications$]).pipe(
map(([started, ps]) => (started ? ps.map((p) => p.mediaTrack) : null)),
),
);
const streamMetadata$ = scope.behavior(
publications$.pipe(
switchMap((ps) =>
ps.length === 0
? of([])
: combineLatest(
ps.map((p) =>
p.mediaTrack.muted$.pipe(map((muted) => ({ p, muted }))),
),
),
),
map((entries) => {
const metadata: StreamMetadata = {};
for (const { p, muted } of entries) {
const entry = (metadata[p.stream.id] ??= {
purpose:
p.stream === userMediaStream ? "m.usermedia" : "m.screenshare",
});
if (p.kind === "audio") entry.audio_muted = muted;
else entry.video_muted = muted;
}
return metadata;
}),
),
);
return {
connectionState$,
tracks$,
encryptionError$: NEVER,
setPublishing,
publishError$,
publish,
unpublish,
publications$,
publishing$,
streamMetadata$,
};
}
function exact(deviceId: string | undefined): ConstrainDOMString | undefined {
return deviceId === undefined ? undefined : { exact: deviceId };
}
async function getTrack(
constraints: MediaStreamConstraints,
): Promise<MediaStreamTrack> {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const [track] = stream.getTracks();
if (track === undefined) throw new MatrixRTCError("No track was captured");
return track;
}
function facingModeOf(
track: MediaStreamTrack | null,
): "user" | "environment" | undefined {
const mode = track?.getSettings().facingMode;
return mode === "user" || mode === "environment" ? mode : undefined;
}
@@ -1,350 +0,0 @@
# MSCXXXX: Full mesh transport for MatrixRTC
[MSC4143] introduces MatrixRTC. Concrete transports are defined in seperate proposals. [MSC4195] defines an
SFU based transport (livekit). This proposal defines a peer-to-peer transport.
In one sentence:\
Every subscriber opens a direct WebRTC connection to every publisher it wants to receive media from. No server-side infrastructure is
needed apart from the TURN servers homeservers already provide for [VoIP].
The transport only covers establishing WebRTC connections between two MatrixRTC members. Calls, ringing,
invites, call IDs and membership are all handled by [MSC4143] and the respective application.
[MSC4143]: https://github.com/matrix-org/matrix-spec-proposals/pull/4143
[MSC4195]: https://github.com/matrix-org/matrix-spec-proposals/pull/4195
[VoIP]: https://spec.matrix.org/v1.18/client-server-api/#voice-over-ip
## Proposal
### Transport type
A new transport type `m.full_mesh` is introduced. A member publishing on it adds the following object
to `transports.published` in its `m.rtc.member` event:
- `type` (required, string): `m.full_mesh`.
- `device_id` (required, string): The device ID of the publishing client. Subscribers send to-device
messages to this device. Needed for unencrypted rooms (We can only resolve the device id of room events
from the olm session info)
Members able to subscribe add `m.full_mesh` to `transports.can_subscribe`.
```json5
{
type: "m.rtc.member",
content: {
slot_id: "m.call#room",
member: { id: "xyzABCDEF10123", membership: "join" },
application: { type: "m.call" },
transports: {
published: [{ type: "m.full_mesh", device_id: "ABCDEFGHIJ" }],
can_subscribe: ["m.full_mesh", "m.livekit"],
},
sticky_key: "xyzABCDEF10123",
},
}
```
In encrypted rooms, clients MUST ignore an `m.full_mesh` transport whose `device_id` does not match the
device that sent the `m.rtc.member` event.
A member MUST NOT list more than one `m.full_mesh` object in `transports.published`. Servers don't need
to advertise `m.full_mesh` in `/_matrix/client/v1/rtc/transports`.
### Connection model
Each WebRTC connection is **unidirectional** and belongs to exactly one _(subscriber, publisher)_ pair of
MatrixRTC members:
- The **subscriber** starts the connection by asking the publisher for its media.
- The **publisher** is always the SDP offerer and only sends media. The subscriber only receives media.
- If two members subscribe to each other, they use two independent connections.
This lets transports be mixed per direction. Alice can subscribe to Bob over `m.full_mesh` while Bob
subscribes to Alice over `m.livekit`. Because only the publisher creates offers, there is no glare.
At most one connection exists per (subscriber `member.id`, publisher `member.id`) pair.
### TURN servers
Each client gets ICE servers from its own homeserver via the existing
[`GET /_matrix/client/v3/voip/turnServer`][turnServer] endpoint and passes `uris`, `username` and
`password` to every `RTCPeerConnection` it creates. Clients SHOULD fetch the credentials again before
`ttl` expires and use the new ones for new connections. If the response is empty, the homeserver has no
TURN server. Clients then use host and server-reflexive candidates only. They MUST NOT fall back to a
third-party STUN/TURN server without user consent.
[turnServer]: https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv3voipturnserver
### To-device messages
All signalling uses [to-device messages] sent to the single device of the target member. Messages MUST be
Olm-encrypted. Recipients MUST discard messages sent in cleartext.
[to-device messages]: https://spec.matrix.org/v1.18/client-server-api/#send-to-device-messaging
Every message contains these common properties:
- `room_id` (required, string): The room containing the slot.
- `slot_id` (required, string): The ID of the slot.
- `member_id` (required, string): The sender's `member.id`.
- `device_id` (required, string): The sender's device ID. The publisher replies to this device, even when
the subscriber doesn't publish `m.full_mesh` and so has no `device_id` in its transport. Clients often
can't get the Olm sending device of a to-device message from their SDK or the widget API, so the sender
states it here. This is the same approach as `claimed_device_id` in the reference implementation of
[MSC4143].
- `target_member_id` (required, string): The recipient's `member.id`.
- `connection_id` (required, string): A random identifier the subscriber generates for each subscription
attempt. All messages of the connection use it.
- `seq` (required, integer): Starts at `0` for each (`member_id`, `target_member_id`) pair and goes up by
`1` with every message sent from `member_id` to `target_member_id`. It is _not_ reset when a new
`connection_id` is used, so messages are ordered across reconnections too. To-device messages are not
guaranteed to arrive in order. Recipients MUST therefore process messages in `seq` order and buffer
them until any gaps are filled. If a gap is not filled within a short timeout (RECOMMENDED: 10
seconds), recipients MUST skip the missing messages and continue with the buffered ones.
Recipients MUST discard a message if any of these is true:
- `room_id` does not match the room of an active shared (between the sender and receiver) rtc session.
- `slot_id` does not match the slot of an active shared rtc session.
- `target_member_id` is not their own current `member.id`.
- No joined `m.rtc.member` event matches `member_id`.
- That event's sender is not the sender of the to-device message.
- `device_id` is not the device that event belongs to.
- The client knows the Olm sending device of the to-device message, and it differs from `device_id`.
- The message is not `m.rtc.full_mesh.subscribe` and its `connection_id` is not the current connection
for this member pair. This drops late messages belonging to a replaced or closed connection.
A subscribe request MAY arrive before the subscriber's `m.rtc.member` event. Publishers SHOULD keep such
a request for a short grace period (RECOMMENDED: 10 seconds) before discarding it.
#### `m.rtc.full_mesh.subscribe` (subscriber → publisher)
Asks the publisher to open a connection and send its media. It contains only the common properties.
#### `m.rtc.full_mesh.description` (both directions)
Carries an SDP offer (publisher → subscriber) or answer (subscriber → publisher).
- `description` (required, object): An [`RTCSessionDescriptionInit`].
- `type` (required, string): `offer` or `answer`.
- `sdp` (required, string): The SDP.
- `stream_metadata` (object): REQUIRED on offers. See [stream metadata](#stream-metadata).
```json5
{
room_id: "!room:example.org",
slot_id: "m.call#room",
member_id: "bobMemberId",
device_id: "BOBDEVICE",
target_member_id: "aliceMemberId",
connection_id: "c0nn3ct10n1d",
seq: 0,
description: { type: "offer", sdp: "v=0\r\n..." },
stream_metadata: {
streamIdA: {
purpose: "m.usermedia",
audio_muted: false,
video_muted: true,
},
},
}
```
#### `m.rtc.full_mesh.candidates` (both directions)
Carries trickled ICE candidates.
- `candidates` (required, array): An array of [`RTCIceCandidateInit`] objects (`candidate`, `sdpMid`,
`sdpMLineIndex`, `usernameFragment`). A `candidate` set to the empty string signals end-of-candidates.
Including `usernameFragment` lets the WebRTC stack discard candidates belonging to an outdated
description.
Clients SHOULD collect candidates for a short time (for example 200ms) and send them in one message
rather than one message per candidate.
Candidates can't overtake the description they belong to. A client only gathers candidates after it
has set its local description, so its candidates always have a higher `seq` than that description.
Recipients MUST still wait for `setRemoteDescription` to complete before adding candidates.
#### `m.rtc.full_mesh.stream_metadata` (publisher → subscriber)
Updates stream metadata without renegotiating, for example when a stream is muted.
- `stream_metadata` (required, object): The full metadata for this connection. It replaces the previous
metadata.
#### `m.rtc.full_mesh.close` (both directions)
Closes the connection. The recipient closes the corresponding `RTCPeerConnection`.
- `reason` (string): An optional human-readable reason.
#### Stream metadata
`stream_metadata` maps each WebRTC `MediaStream` ID in the SDP (the `msid` stream ID) to an object:
- `purpose` (required, string): `m.usermedia` or `m.screenshare`. Subscribers SHOULD ignore streams with
an unknown purpose.
- `audio_muted` (boolean): Whether the stream's audio is muted. Defaults to `false`.
- `video_muted` (boolean): Whether the stream's video is muted. Defaults to `false`.
Following MSCs MAY define further properties.
[`RTCSessionDescriptionInit`]: https://www.w3.org/TR/webrtc/#dom-rtcsessiondescriptioninit
[`RTCIceCandidateInit`]: https://www.w3.org/TR/webrtc/#dom-rtcicecandidateinit
### Procedures
#### Subscribing
A subscriber (Alice) that wants media from a joined member (Bob) whose `transports.published` contains an
`m.full_mesh` object:
1. Alice creates a new `connection_id` (random) and sends `m.rtc.full_mesh.subscribe` to Bob's `device_id`.
2. Bob validates the request as described above. If Bob doesn't publish `m.full_mesh`, or the request is
invalid, Bob MAY reply with `m.rtc.full_mesh.close`. If Bob already has a connection for Alice's
`member.id`, he closes it and replaces it with the new one.
3. Bob creates an `RTCPeerConnection` using his own ICE servers and adds his current tracks as `sendonly`.
He then creates an offer, sets it as the local description and sends it with `stream_metadata` in an
`m.rtc.full_mesh.description` message.
4. Alice creates an `RTCPeerConnection` using her own ICE servers and sets the offer as the remote
description. She then creates an answer (`recvonly`), sets it as the local description and sends it
in an `m.rtc.full_mesh.description` message.
5. Both sides send `m.rtc.full_mesh.candidates` as they gather candidates.
```mermaid
sequenceDiagram
participant A as Alice (subscriber)
participant B as Bob (publisher)
A-->>B: subscribe
B-->>A: description (offer + stream_metadata)
A-->>B: description (answer)
A-->>B: candidates
B-->>A: candidates
B->>A: media (DTLS-SRTP)
```
#### Renegotiation
When the publisher's tracks change (for example when a screenshare is added or removed), the publisher
sends a new offer with the same `connection_id`. The subscriber answers it. The publisher MUST NOT send
a new offer while a previous one is still unanswered (signalling state not `stable`). Instead, it waits
for the answer and combines all track changes made in the meantime into one new offer. When only mute state
changes, the publisher sends `m.rtc.full_mesh.stream_metadata` instead.
#### Closing
- The subscriber sends `m.rtc.full_mesh.close` when it no longer wants the media, or when the publisher
leaves the slot or stops publishing `m.full_mesh`.
- The publisher sends `m.rtc.full_mesh.close` when it stops publishing `m.full_mesh`, or when the
subscriber is no longer considered joined as per [MSC4143]. Publishers MUST stop sending media to
members that are no longer joined.
- Leaving the slot closes all of a member's connections.
#### Failure
If a connection's ICE state becomes `failed`, the subscriber MAY send `m.rtc.full_mesh.close` and then
subscribe again with a new `connection_id`. It SHOULD use exponential backoff between attempts.
Publishers don't retry.
### End-to-end encryption
WebRTC always encrypts media with DTLS-SRTP. Each side's DTLS certificate fingerprint is part of its SDP.
The SDP is carried in Olm-encrypted to-device messages, and recipients check that the sending device
matches the member's `m.rtc.member` event. As a result, the fingerprints are authenticated end to end
between the two member devices, and the media is end-to-end encrypted. TURN servers relay ciphertext only.
DTLS uses an ephemeral key exchange, so every connection also has forward secrecy.
`m.full_mesh` therefore doesn't use the `m.per_member` key material from [MSC4143], and its required key
length is 0 bytes. A client whose published transports are all `m.full_mesh` doesn't need to send
`m.rtc.encryption_key` messages. Media received over a connection that was set up as described above
counts as encrypted for the purposes of [MSC4143].
## Potential issues
### Scalability
Every publisher uploads one copy of its media per subscriber, and the number of connections grows
quadratically with the number of members. The transport is only suitable for small sessions. Clients MAY
refuse subscriptions beyond a limit they choose.
### To-device latency
Connection setup needs about two to-device round trips, possibly over federation. This is slower than
connecting to an SFU.
### Twice the connections for bidirectional media
Using one connection per direction doubles the number of ICE and DTLS handshakes between two members who
both publish and subscribe. In return, transports can be mixed and there is no glare.
## Alternatives
### One bidirectional connection per pair
[MSC3401] used one connection per pair of members, with a tie-break rule for who calls whom. This halves
the number of connections. However, it ties both directions to the same transport and needs glare
handling for renegotiation.
[MSC3401]: https://github.com/matrix-org/matrix-spec-proposals/pull/3401
### Subscriber as offerer
The subscriber could send an offer with `recvonly` transceivers directly, saving one message. However, the
subscriber doesn't know how many streams the publisher has, so the publisher would need to make offers
for renegotiation anyway. Making the publisher the only offerer keeps a single code path.
### Reusing `m.call.*` events
The legacy `m.call.invite`, `m.call.answer` and similar events bring call IDs, party IDs, invite lifetimes
and ringing semantics. MatrixRTC already handles all of these, so the events were not reused.
### Using `m.per_member` keys with SFrame
Media could also be encrypted with the [MSC4143] key material on top of DTLS-SRTP. This adds no security
over authenticated DTLS-SRTP, so it is not required.
## Security considerations
### IP address exposure
Peers learn each other's IP addresses from ICE candidates. Clients MAY set `iceTransportPolicy: "relay"`
so that only their homeserver's TURN server is exposed, at the cost of more latency. Candidates are only
sent through Olm-encrypted to-device messages, and only to members that are joined to the slot.
### Unencrypted rooms
In unencrypted rooms, `m.rtc.member` events are not authenticated. A malicious homeserver could forge a
member event with a `device_id` it controls and receive media. Signalling itself stays Olm-encrypted, so
existing connections can't be tampered with. Clients SHOULD only send to devices that are cross-signed
by their owner, as described in [MSC4153].
[MSC4153]: https://github.com/matrix-org/matrix-spec-proposals/pull/4153
### Claimed device IDs
A recipient that can't check `device_id` against the Olm sending device only knows that the message
comes from the right user. Another device of that same user could pretend to be the device of the
member and receive its media. Only that user's own devices can do this, the same as for `claimed_device_id`
in [MSC4143]'s reference implementation. Clients SHOULD check the Olm sending device wherever their SDK
exposes it.
### Removed members
Publishers stop sending media when a subscriber is no longer joined. This way, redacting member events
or removing someone from the room also cuts their media.
## Unstable prefix
| Stable identifier | Purpose | Unstable identifier |
| --------------------------------- | -------------------- | -------------------------------------------------- |
| `m.full_mesh` | Transport type | `org.matrix.mscXXXX.full_mesh` |
| `m.rtc.full_mesh.subscribe` | To-device event type | `org.matrix.mscXXXX.rtc.full_mesh.subscribe` |
| `m.rtc.full_mesh.description` | To-device event type | `org.matrix.mscXXXX.rtc.full_mesh.description` |
| `m.rtc.full_mesh.candidates` | To-device event type | `org.matrix.mscXXXX.rtc.full_mesh.candidates` |
| `m.rtc.full_mesh.stream_metadata` | To-device event type | `org.matrix.mscXXXX.rtc.full_mesh.stream_metadata` |
| `m.rtc.full_mesh.close` | To-device event type | `org.matrix.mscXXXX.rtc.full_mesh.close` |
## Dependencies
This proposal depends on [MSC4143].
@@ -1,215 +0,0 @@
/*
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 {
combineLatest,
distinctUntilChanged,
map,
Observable,
of,
scan,
switchMap,
timer,
} from "rxjs";
import { type Behavior } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope";
import {
type AudioMediaTrack,
type MediaSource,
type MediaStreamStats,
type MediaTrack,
type VideoMediaTrack,
} from "../../media-api";
export interface TrackProps {
source: MediaSource;
kind: "audio" | "video";
id: string;
/** The track behind it; may change, as on a device switch, or be null while off. */
track$: Behavior<MediaStreamTrack | null>;
muted$: Behavior<boolean>;
/** Statistics of the RTP stream carrying it, if there is one. */
stats: () => Promise<MediaStreamStats>;
/** Where attached elements play; only for tracks that are played. */
audioOutputDeviceId$?: Behavior<string | undefined>;
}
/** Above this level a track turns active; below `INACTIVE_LEVEL` for `INACTIVE_AFTER_MS` it turns inactive. */
const ACTIVE_LEVEL = 0.04;
const INACTIVE_LEVEL = 0.02;
const LEVEL_INTERVAL_MS = 100;
const INACTIVE_AFTER_MS = 500;
let levelContext: AudioContext | undefined;
/**
* A `MediaStreamTrack` as a `MediaTrack`: the same object for the life of a
* publication, with every attached element following the track behind it.
*/
export function createFullMeshMediaTrack(
scope: ObservableScope,
props: TrackProps,
): AudioMediaTrack | VideoMediaTrack {
const { source, kind, id, track$, muted$, stats, audioOutputDeviceId$ } =
props;
const attached = new Set<HTMLMediaElement>();
let audioContext: AudioContext | undefined;
let audioPlugins: AudioNode[] = [];
let volume = 1;
let webAudio: { nodes: AudioNode[]; gain: GainNode } | undefined;
const routeWebAudio = (track: MediaStreamTrack | null): void => {
for (const node of webAudio?.nodes ?? []) node.disconnect();
webAudio = undefined;
if (audioContext === undefined || track === null) return;
const input = audioContext.createMediaStreamSource(
new MediaStream([track]),
);
const gain = audioContext.createGain();
gain.gain.value = volume;
const chain: AudioNode[] = [input, ...audioPlugins, gain];
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
gain.connect(audioContext.destination);
webAudio = { nodes: chain, gain };
};
const apply = (element: HTMLMediaElement): void => {
const track = track$.value;
element.srcObject = track ? new MediaStream([track]) : null;
if (kind === "audio") {
// Played through Web Audio instead, where it is routed there
element.muted = audioContext !== undefined;
element.volume = volume;
const sinkId = audioOutputDeviceId$?.value;
if (sinkId !== undefined && "setSinkId" in element)
void element.setSinkId(sinkId).catch(() => {});
}
if (element.paused) void element.play().catch(() => {});
};
track$.pipe(scope.bind()).subscribe((track) => {
for (const element of attached) apply(element);
if (kind === "audio") routeWebAudio(track);
});
audioOutputDeviceId$?.pipe(scope.bind()).subscribe(() => {
for (const element of attached) apply(element);
});
scope.onEnd(() => {
for (const element of attached) element.srcObject = null;
attached.clear();
routeWebAudio(null);
});
const base: MediaTrack = {
source,
kind,
id,
muted$,
// DTLS-SRTP, its fingerprints exchanged over Olm
encrypted: true,
stats$: (intervalMs): Observable<MediaStreamStats> =>
timer(0, intervalMs).pipe(
switchMap(async () => stats()),
scope.bind(),
),
attach: (element) => {
if (attached.has(element)) return;
attached.add(element);
apply(element);
},
detach: (element) => {
if (!attached.delete(element)) return;
element.srcObject = null;
},
};
if (kind === "video") return { ...base, kind: "video" };
const audioLevel$ = scope.behavior(
combineLatest([track$, muted$]).pipe(
switchMap(([track, muted]) =>
track === null || muted ? of(0) : level$(track),
),
),
0,
);
const isActive$ = scope.behavior(
audioLevel$.pipe(
scan(
({ active, quietMs }, level) => {
if (level >= ACTIVE_LEVEL) return { active: true, quietMs: 0 };
if (level >= INACTIVE_LEVEL) return { active, quietMs: 0 };
const quiet = quietMs + LEVEL_INTERVAL_MS;
return {
active: active && quiet < INACTIVE_AFTER_MS,
quietMs: quiet,
};
},
{ active: false, quietMs: 0 },
),
map(({ active }) => active),
distinctUntilChanged(),
),
false,
);
return {
...base,
kind: "audio",
audioLevel$,
isActive$,
setAudioContext: (ctx, plugins = []) => {
audioContext = ctx;
audioPlugins = plugins;
routeWebAudio(track$.value);
for (const element of attached) apply(element);
},
setVolume: (v) => {
volume = v;
if (webAudio) webAudio.gain.gain.value = v;
for (const element of attached) element.volume = v;
},
};
}
/** The RMS level of an audio track, 0 to 1, sampled while subscribed. */
function level$(track: MediaStreamTrack): Observable<number> {
return new Observable<number>((subscriber) => {
levelContext ??= new AudioContext();
const ctx = levelContext;
const input = ctx.createMediaStreamSource(new MediaStream([track]));
const analyser = ctx.createAnalyser();
analyser.fftSize = 512;
input.connect(analyser);
const samples = new Float32Array(analyser.fftSize);
const sub = timer(0, LEVEL_INTERVAL_MS).subscribe(() => {
analyser.getFloatTimeDomainData(samples);
let sum = 0;
for (const s of samples) sum += s * s;
subscriber.next(Math.min(1, Math.sqrt(sum / samples.length) * 4));
});
return () => {
sub.unsubscribe();
input.disconnect();
};
});
}
/** The inbound or outbound RTP statistics of one track on a peer connection. */
export async function rtpStats(
pc: RTCPeerConnection | undefined,
track: MediaStreamTrack | null,
direction: "inbound-rtp" | "outbound-rtp",
): Promise<MediaStreamStats> {
if (pc === undefined || track === null) return undefined;
const report = await pc.getStats(track);
for (const stats of report.values())
if ((stats as RTCStats).type === direction)
return stats as RTCInboundRtpStreamStats & RTCOutboundRtpStreamStats;
return undefined;
}
@@ -1,153 +0,0 @@
/*
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { ObservableScope } from "../../reactive/ObservableScope";
import { MediaConnectionState } from "../api";
import {
CANDIDATE_BATCH_MS,
createPeerConnectionBase,
type PeerConnectionBase,
} from "./PeerConnection";
import { SignallingEventType } from "./signalling";
import {
type FakeSignalling,
fakeSignalling,
fromPeer,
installWebRtcFakes,
lastPeerConnection,
peer,
settle,
} from "./test";
describe("createPeerConnectionBase", () => {
let scope: ObservableScope;
let signalling: FakeSignalling;
let base: PeerConnectionBase;
beforeEach(() => {
vi.useFakeTimers();
installWebRtcFakes();
scope = new ObservableScope();
signalling = fakeSignalling();
base = createPeerConnectionBase({
scope,
peer,
connectionId: "c1",
signalling,
iceServers: [{ urls: ["turn:example.org"] }],
logger,
});
});
afterEach(() => {
scope.end();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("uses the ICE servers it was given", () => {
expect(lastPeerConnection().config.iceServers).toEqual([
{ urls: ["turn:example.org"] },
]);
});
it("sends the candidates gathered within a batch in one message", () => {
const pc = lastPeerConnection();
pc.gather({ candidate: "a", sdpMid: "0", sdpMLineIndex: 0 });
pc.gather({ candidate: "b", sdpMid: "0", sdpMLineIndex: 0 });
pc.gather(null);
expect(signalling.send).not.toHaveBeenCalled();
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
expect(signalling.sent()).toEqual([
{
type: SignallingEventType.Candidates,
candidates: [
{ candidate: "a", sdpMid: "0", sdpMLineIndex: 0 },
{ candidate: "b", sdpMid: "0", sdpMLineIndex: 0 },
// The end of the gathering
{ candidate: "", sdpMid: null, sdpMLineIndex: null },
],
},
]);
expect(signalling.sentConnectionIds()).toEqual(["c1"]);
});
it("drops pending candidates when it closes", () => {
lastPeerConnection().gather({ candidate: "a" });
base.close("bye", false);
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
expect(signalling.send).not.toHaveBeenCalled();
});
it("runs queued operations in order, past a failing one", async () => {
const order: string[] = [];
base.enqueue(async () => {
order.push("first");
return Promise.reject(new Error("failed"));
});
base.enqueue(async () => {
order.push("second");
return Promise.resolve();
});
await settle();
expect(order).toEqual(["first", "second"]);
});
it("applies received candidates, ignoring one that fails", async () => {
base.addCandidates(
fromPeer("c1", {
type: SignallingEventType.Candidates,
candidates: [{ candidate: "bad" }, { candidate: "good" }],
}) as Parameters<PeerConnectionBase["addCandidates"]>[0],
);
await settle();
expect(lastPeerConnection().addedCandidates).toEqual([
{ candidate: "good" },
]);
});
it("tells the peer once when it closes", () => {
base.close("done", true);
base.close("again", true);
expect(lastPeerConnection().closed).toBe(true);
expect(signalling.sent()).toEqual([
{ type: SignallingEventType.Close, reason: "done" },
]);
});
it("doesn't tell a peer that closed first", () => {
base.close("theirs", false);
expect(lastPeerConnection().closed).toBe(true);
expect(signalling.send).not.toHaveBeenCalled();
});
it("closes when its scope ends", () => {
scope.end();
expect(lastPeerConnection().closed).toBe(true);
expect(signalling.sent()).toEqual([
{ type: SignallingEventType.Close, reason: "ended" },
]);
});
it("maps the connection state", () => {
const pc = lastPeerConnection();
expect(base.state$.value).toBe(MediaConnectionState.Initialized);
pc.setConnectionState("connecting");
expect(base.state$.value).toBe(MediaConnectionState.Connecting);
pc.setConnectionState("connected");
expect(base.state$.value).toBe(MediaConnectionState.Connected);
pc.setConnectionState("disconnected");
expect(base.state$.value).toBe(MediaConnectionState.Reconnecting);
pc.setConnectionState("failed");
expect(base.state$.value).toBeInstanceOf(Error);
pc.setConnectionState("closed");
expect(base.state$.value).toBe(MediaConnectionState.Stopped);
});
});
@@ -1,136 +0,0 @@
/*
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.
*/
/** What the publisher and the subscriber side of a connection share. */
import { type Logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject, fromEvent, map, startWith } from "rxjs";
import { type Behavior } from "../../reactive/Behavior";
import { type ObservableScope } from "../../reactive/ObservableScope";
import { MediaConnectionState } from "../api";
import {
type Peer,
type Signalling,
SignallingEventType,
type SignallingMessage,
} from "./signalling";
/** How long trickled candidates are collected before they are sent in one message. */
export const CANDIDATE_BATCH_MS = 200;
export interface PeerConnectionBase {
readonly connectionId: string;
readonly pc: RTCPeerConnection;
readonly state$: Behavior<MediaConnectionState | Error>;
/** Runs `op` after every operation queued before it, so that SDP and candidates apply in order. */
enqueue(op: () => Promise<void>): void;
/** Applies a received batch of candidates. */
addCandidates(
message: SignallingMessage & {
type: typeof SignallingEventType.Candidates;
},
): void;
/** Closes the peer connection, telling the peer unless it closed first. */
close(reason: string | undefined, notifyPeer: boolean): void;
}
export function createPeerConnectionBase({
scope,
peer,
connectionId,
signalling,
iceServers,
logger,
}: {
scope: ObservableScope;
peer: Peer;
connectionId: string;
signalling: Signalling;
iceServers: RTCIceServer[];
logger: Logger;
}): PeerConnectionBase {
const pc = new RTCPeerConnection({ iceServers });
const closed$ = new BehaviorSubject(false);
let queue = Promise.resolve();
const enqueue = (op: () => Promise<void>): void => {
queue = queue.then(op).catch((e: unknown) => {
logger.warn("Signalling operation failed", e);
});
};
// Trickled, in batches
let pending: RTCIceCandidateInit[] = [];
let batchTimer: ReturnType<typeof setTimeout> | undefined;
const flushCandidates = (): void => {
batchTimer = undefined;
if (pending.length === 0 || closed$.value) return;
signalling.send(peer, connectionId, {
type: SignallingEventType.Candidates,
candidates: pending,
});
pending = [];
};
pc.addEventListener("icecandidate", ({ candidate }) => {
// A null candidate ends the gathering: an empty one says so on the wire
pending.push(
candidate
? candidate.toJSON()
: { candidate: "", sdpMid: null, sdpMLineIndex: null },
);
batchTimer ??= setTimeout(flushCandidates, CANDIDATE_BATCH_MS);
});
const addCandidates: PeerConnectionBase["addCandidates"] = ({ candidates }) =>
enqueue(async () => {
for (const candidate of candidates)
await pc
.addIceCandidate(candidate)
// A candidate of an outdated description, or a bad one: not fatal
.catch((e: unknown) => logger.debug("Ignored a candidate", e));
});
const close: PeerConnectionBase["close"] = (reason, notifyPeer) => {
if (closed$.value) return;
clearTimeout(batchTimer);
if (notifyPeer)
signalling.send(peer, connectionId, {
type: SignallingEventType.Close,
reason,
});
pc.close();
closed$.next(true);
};
scope.onEnd(() => close("ended", true));
const state$ = scope.behavior(
fromEvent(pc, "connectionstatechange").pipe(
startWith(null),
map(() => toState(pc.connectionState)),
),
);
return { connectionId, pc, state$, enqueue, addCandidates, close };
}
function toState(state: RTCPeerConnectionState): MediaConnectionState | Error {
switch (state) {
case "new":
return MediaConnectionState.Initialized;
case "connecting":
return MediaConnectionState.Connecting;
case "connected":
return MediaConnectionState.Connected;
case "disconnected":
return MediaConnectionState.Reconnecting;
case "failed":
return new Error("The peer connection failed");
case "closed":
return MediaConnectionState.Stopped;
}
}
@@ -1,200 +0,0 @@
/*
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type FullMeshLocalMedia } from "./LocalMedia";
import {
createPublisherConnection,
type PublisherConnection,
} from "./PublisherConnection";
import { SignallingEventType } from "./signalling";
import {
type FakeSignalling,
fakeLocalMedia,
fakePublication,
fakeSignalling,
fakeTrack,
fromPeer,
installWebRtcFakes,
lastPeerConnection,
peer,
settle,
} from "./test";
describe("createPublisherConnection", () => {
let scope: ObservableScope;
let signalling: FakeSignalling;
let local: ReturnType<typeof fakeLocalMedia>;
function create(): PublisherConnection {
return createPublisherConnection({
scope,
peer,
connectionId: "c1",
signalling,
iceServers: [],
local: local as unknown as FullMeshLocalMedia,
logger,
});
}
const answer = (): ReturnType<typeof fromPeer> =>
fromPeer("c1", {
type: SignallingEventType.Description,
description: { type: "answer", sdp: "answer" },
});
beforeEach(() => {
installWebRtcFakes();
scope = new ObservableScope();
signalling = fakeSignalling();
local = fakeLocalMedia();
});
afterEach(() => {
scope.end();
vi.unstubAllGlobals();
});
it("offers right away, even without tracks, with the stream metadata", async () => {
local.streamMetadata$.next({ s: { purpose: "m.usermedia" } });
create();
await settle();
expect(signalling.sent(SignallingEventType.Description)).toEqual([
{
type: SignallingEventType.Description,
description: { type: "offer", sdp: "offer-1" },
stream_metadata: { s: { purpose: "m.usermedia" } },
},
]);
});
it("sends each publication on a sendonly transceiver in its stream", async () => {
const mic = fakePublication("audio");
const camera = fakePublication("video", mic.stream);
local.publications$.next([mic, camera]);
create();
await settle();
const [a, v] = lastPeerConnection().transceivers;
expect(a).toMatchObject({ kind: "audio", direction: "sendonly" });
expect(a.streams).toEqual([mic.stream]);
expect(a.sender.track).toBe(mic.track$.value);
expect(v).toMatchObject({ kind: "video", direction: "sendonly" });
expect(v.sender.track).toBe(camera.track$.value);
// One offer for both
expect(signalling.sent(SignallingEventType.Description)).toHaveLength(1);
});
it("follows a track change without renegotiating", async () => {
const camera = fakePublication("video");
local.publications$.next([camera]);
create();
await settle();
const next = fakeTrack("video");
camera.track$.next(next);
expect(lastPeerConnection().transceivers[0].sender.track).toBe(next);
expect(signalling.sent(SignallingEventType.Description)).toHaveLength(1);
});
it("sends nothing while not publishing", async () => {
const camera = fakePublication("video");
local.publications$.next([camera]);
local.publishing$.next(false);
create();
await settle();
const { sender } = lastPeerConnection().transceivers[0];
expect(sender.track).toBeNull();
local.publishing$.next(true);
expect(sender.track).toBe(camera.track$.value);
});
it("holds changes while an offer is unanswered, then offers once for all", async () => {
const connection = create();
const pc = lastPeerConnection();
await settle();
expect(pc.offers).toBe(1);
local.publications$.next([fakePublication("audio")]);
local.publications$.next([
...local.publications$.value,
fakePublication("video"),
]);
await settle();
expect(pc.offers).toBe(1);
connection.handle(answer());
await settle();
expect(pc.offers).toBe(2);
expect(pc.transceivers).toHaveLength(2);
// Nothing changed since: the next answer doesn't lead to another offer
connection.handle(answer());
await settle();
expect(pc.offers).toBe(2);
});
it("stops the transceiver of a removed publication and renegotiates", async () => {
const camera = fakePublication("video");
local.publications$.next([camera]);
const connection = create();
await settle();
connection.handle(answer());
await settle();
local.publications$.next([]);
await settle();
const pc = lastPeerConnection();
expect(pc.transceivers[0].stopped).toBe(true);
expect(pc.offers).toBe(2);
});
it("signals mute changes without renegotiating", async () => {
create();
await settle();
local.streamMetadata$.next({
s: { purpose: "m.usermedia", audio_muted: true },
});
expect(signalling.sent(SignallingEventType.StreamMetadata)).toEqual([
{
type: SignallingEventType.StreamMetadata,
stream_metadata: { s: { purpose: "m.usermedia", audio_muted: true } },
},
]);
expect(lastPeerConnection().offers).toBe(1);
});
it("applies the subscriber's candidates", async () => {
const connection = create();
await settle();
connection.handle(answer());
connection.handle(
fromPeer("c1", {
type: SignallingEventType.Candidates,
candidates: [{ candidate: "x" }],
}),
);
await settle();
expect(lastPeerConnection().addedCandidates).toEqual([{ candidate: "x" }]);
});
it("ends without answering when the subscriber closes", async () => {
const connection = create();
await settle();
connection.handle(
fromPeer("c1", { type: SignallingEventType.Close, reason: "bye" }),
);
expect(lastPeerConnection().closed).toBe(true);
expect(signalling.sent(SignallingEventType.Close)).toEqual([]);
// Its scope ended: no more metadata goes out
local.streamMetadata$.next({ s: { purpose: "m.usermedia" } });
expect(signalling.sent(SignallingEventType.StreamMetadata)).toEqual([]);
});
});
@@ -1,157 +0,0 @@
/*
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 Logger } from "matrix-js-sdk/lib/logger";
import { combineLatest, skip, type Subscription } from "rxjs";
import { type ObservableScope } from "../../reactive/ObservableScope";
import { type FullMeshLocalMedia, type LocalPublication } from "./LocalMedia";
import {
createPeerConnectionBase,
type PeerConnectionBase,
} from "./PeerConnection";
import {
type Peer,
type Signalling,
SignallingEventType,
type SignallingMessage,
} from "./signalling";
export interface PublisherConnection extends PeerConnectionBase {
/** A message of this connection from the subscriber. */
handle(message: SignallingMessage): void;
}
/**
* Our side of a connection a subscriber asked for: we only send, and we are
* the only side that makes offers, so there is no glare.
*/
export function createPublisherConnection({
scope,
peer,
connectionId,
signalling,
iceServers,
local,
logger: parentLogger,
}: {
scope: ObservableScope;
peer: Peer;
connectionId: string;
signalling: Signalling;
iceServers: RTCIceServer[];
local: FullMeshLocalMedia;
logger: Logger;
}): PublisherConnection {
const logger = parentLogger.getChild(
`[Publisher → ${peer.memberId} ${connectionId}]`,
);
const base = createPeerConnectionBase({
scope,
peer,
connectionId,
signalling,
iceServers,
logger,
});
const { pc, enqueue } = base;
// A new offer has to wait while one is unanswered; changes made meanwhile
// go out together in the next one
let offerPending = false;
let renegotiate = false;
const negotiate = (): void => {
if (offerPending) {
renegotiate = true;
return;
}
offerPending = true;
renegotiate = false;
enqueue(async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalling.send(peer, connectionId, {
type: SignallingEventType.Description,
description: { type: "offer", sdp: pc.localDescription!.sdp },
stream_metadata: local.streamMetadata$.value,
});
});
};
// One sendonly transceiver per publication, for as long as it exists
const transceivers = new Map<
LocalPublication,
{ transceiver: RTCRtpTransceiver; tracks: Subscription }
>();
scope.onEnd(() => {
for (const { tracks } of transceivers.values()) tracks.unsubscribe();
});
local.publications$.pipe(scope.bind()).subscribe((publications) => {
let changed = false;
for (const [publication, { transceiver, tracks }] of transceivers)
if (!publications.includes(publication)) {
tracks.unsubscribe();
if (pc.signalingState !== "closed") transceiver.stop();
transceivers.delete(publication);
changed = true;
}
for (const publication of publications)
if (!transceivers.has(publication)) {
const transceiver = pc.addTransceiver(publication.kind, {
direction: "sendonly",
streams: [publication.stream],
});
// Paused while not publishing, so that the local preview stays live
const tracks = combineLatest([
publication.track$,
local.publishing$,
]).subscribe(([track, publishing]) => {
void transceiver.sender
.replaceTrack(publishing ? track : null)
.catch((e: unknown) => logger.warn("replaceTrack failed", e));
});
transceivers.set(publication, { transceiver, tracks });
changed = true;
}
if (changed) negotiate();
});
// Even without tracks: the subscriber learns that we are there
if (transceivers.size === 0) negotiate();
// Mute changes without renegotiating; the offer carries the state at its time
local.streamMetadata$.pipe(skip(1), scope.bind()).subscribe((metadata) =>
signalling.send(peer, connectionId, {
type: SignallingEventType.StreamMetadata,
stream_metadata: metadata,
}),
);
const handle = (message: SignallingMessage): void => {
switch (message.type) {
case SignallingEventType.Description:
if (message.description.type !== "answer") return;
enqueue(async () => {
await pc.setRemoteDescription(message.description);
offerPending = false;
if (renegotiate) negotiate();
});
break;
case SignallingEventType.Candidates:
base.addCandidates(message);
break;
case SignallingEventType.Close:
logger.info(`Closed by the subscriber: ${message.reason}`);
base.close(message.reason, false);
scope.end();
break;
default:
logger.debug(`Ignoring ${message.type}`);
}
};
return { ...base, handle };
}
@@ -1,214 +0,0 @@
/*
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { constant } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type StreamMetadata, SignallingEventType } from "./signalling";
import {
createSubscriberConnection,
type SubscriberConnection,
} from "./SubscriberConnection";
import {
type FakeSignalling,
fakeSignalling,
fromPeer,
installWebRtcFakes,
lastPeerConnection,
peer,
settle,
} from "./test";
describe("createSubscriberConnection", () => {
let scope: ObservableScope;
let signalling: FakeSignalling;
let subscriber: SubscriberConnection;
const offer = (
connectionId: string,
metadata: StreamMetadata,
): ReturnType<typeof fromPeer> =>
fromPeer(connectionId, {
type: SignallingEventType.Description,
description: { type: "offer", sdp: "offer" },
stream_metadata: metadata,
});
/** The connection id of the current attempt. */
const current = (): string => {
const base = subscriber.current$.value;
if (base === null) throw new Error("No current attempt");
return base.connectionId;
};
beforeEach(async () => {
vi.useFakeTimers();
installWebRtcFakes();
scope = new ObservableScope();
signalling = fakeSignalling();
subscriber = createSubscriberConnection({
scope,
peer,
signalling,
iceServers: async () => Promise.resolve([{ urls: ["turn:x"] }]),
audioOutputDeviceId$: constant(undefined),
logger,
});
await settle();
});
afterEach(() => {
scope.end();
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("asks the publisher for its media on a fresh connection", () => {
expect(signalling.sent()).toEqual([
{ type: SignallingEventType.Subscribe },
]);
expect(signalling.sentConnectionIds()).toEqual([current()]);
expect(lastPeerConnection().config.iceServers).toEqual([
{ urls: ["turn:x"] },
]);
});
it("answers an offer and shows the tracks it brings", async () => {
expect(subscriber.tracks$.value).toBeNull();
const pc = lastPeerConnection();
pc.receive("video", "user");
pc.receive("video", "screen");
subscriber.handle(
offer(current(), {
user: { purpose: "m.usermedia", video_muted: true },
screen: { purpose: "m.screenshare" },
}),
);
await settle();
expect(pc.remoteDescription).toEqual({ type: "offer", sdp: "offer" });
expect(signalling.sent(SignallingEventType.Description)).toEqual([
{
type: SignallingEventType.Description,
description: { type: "answer", sdp: "answer" },
},
]);
const tracks = subscriber.tracks$.value!;
expect(tracks.map((t) => [t.source, t.kind, t.muted$.value])).toEqual([
["camera", "video", true],
["screenShare", "video", false],
]);
});
it("names audio by its stream's purpose", async () => {
const pc = lastPeerConnection();
pc.receive("audio", "user");
pc.receive("audio", "screen");
subscriber.handle(
offer(current(), {
user: { purpose: "m.usermedia" },
screen: { purpose: "m.screenshare" },
}),
);
await settle();
expect(subscriber.tracks$.value!.map((t) => t.source)).toEqual([
"microphone",
"screenShareAudio",
]);
});
it("leaves out streams without metadata or with an unknown purpose", async () => {
const pc = lastPeerConnection();
pc.receive("video", "unknown");
pc.receive("video", "other");
subscriber.handle(
offer(current(), {
other: { purpose: "m.whiteboard" as "m.usermedia" },
}),
);
await settle();
expect(subscriber.tracks$.value).toEqual([]);
});
it("follows mute changes, keeping the same track", async () => {
lastPeerConnection().receive("video", "user");
subscriber.handle(offer(current(), { user: { purpose: "m.usermedia" } }));
await settle();
const [track] = subscriber.tracks$.value!;
expect(track.muted$.value).toBe(false);
subscriber.handle(
fromPeer(current(), {
type: SignallingEventType.StreamMetadata,
stream_metadata: {
user: { purpose: "m.usermedia", video_muted: true },
},
}),
);
await settle();
expect(subscriber.tracks$.value).toEqual([track]);
expect(track.muted$.value).toBe(true);
});
it("drops a track whose transceiver no longer receives", async () => {
const pc = lastPeerConnection();
const transceiver = pc.receive("video", "user");
subscriber.handle(offer(current(), { user: { purpose: "m.usermedia" } }));
await settle();
expect(subscriber.tracks$.value).toHaveLength(1);
transceiver.currentDirection = "inactive";
subscriber.handle(offer(current(), { user: { purpose: "m.usermedia" } }));
await settle();
expect(subscriber.tracks$.value).toEqual([]);
});
it("ignores messages of another connection", async () => {
subscriber.handle(offer("other", {}));
await settle();
expect(lastPeerConnection().remoteDescription).toBeNull();
});
it("subscribes anew after the publisher closes, backing off", async () => {
const first = current();
subscriber.handle(
fromPeer(first, { type: SignallingEventType.Close, reason: "nope" }),
);
expect(subscriber.current$.value).toBeNull();
expect(subscriber.tracks$.value).toBeNull();
// It doesn't echo the publisher's close
expect(signalling.sent(SignallingEventType.Close)).toEqual([]);
await vi.advanceTimersByTimeAsync(1_000);
const second = current();
expect(second).not.toBe(first);
expect(signalling.sentConnectionIds(SignallingEventType.Subscribe)).toEqual(
[first, second],
);
// A failed connection is closed and retried after twice the wait
lastPeerConnection().setConnectionState("failed");
expect(signalling.sent(SignallingEventType.Close)).toEqual([
{ type: SignallingEventType.Close, reason: "ice_failed" },
]);
await vi.advanceTimersByTimeAsync(1_000);
expect(signalling.sent(SignallingEventType.Subscribe)).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1_000);
expect(signalling.sent(SignallingEventType.Subscribe)).toHaveLength(3);
});
it("stops retrying once its scope ends", async () => {
subscriber.handle(
fromPeer(current(), { type: SignallingEventType.Close, reason: "nope" }),
);
scope.end();
await vi.advanceTimersByTimeAsync(60_000);
expect(signalling.sent(SignallingEventType.Subscribe)).toHaveLength(1);
});
});
@@ -1,297 +0,0 @@
/*
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 Logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
distinctUntilChanged,
filter,
map,
of,
switchMap,
} from "rxjs";
import { v4 as uuidv4 } from "uuid";
import {
type AudioMediaTrack,
type MediaSource,
type VideoMediaTrack,
} from "../../media-api";
import { type Behavior } from "../../reactive/Behavior";
import { ObservableScope } from "../../reactive/ObservableScope";
import { MediaConnectionState } from "../api";
import { createFullMeshMediaTrack, rtpStats } from "./MediaTrack";
import {
createPeerConnectionBase,
type PeerConnectionBase,
} from "./PeerConnection";
import {
type Peer,
type Signalling,
SignallingEventType,
type SignallingMessage,
type StreamMetadata,
} from "./signalling";
type RemoteTrack = AudioMediaTrack | VideoMediaTrack;
const RETRY_MIN_MS = 1_000;
const RETRY_MAX_MS = 30_000;
export interface SubscriberConnection {
/** The current attempt; null while waiting to retry. */
current$: Behavior<PeerConnectionBase | null>;
/** The publisher's tracks; null until its first offer is applied. */
tracks$: Behavior<RemoteTrack[] | null>;
/** A message from the publisher for the current attempt. */
handle(message: SignallingMessage): void;
}
/**
* Our subscription to one publisher: asks it for its media and answers its
* offers. A failed or closed attempt is retried with a fresh connection id,
* backing off, for as long as `scope` lives.
*/
export function createSubscriberConnection({
scope,
peer,
signalling,
iceServers,
audioOutputDeviceId$,
logger: parentLogger,
}: {
scope: ObservableScope;
peer: Peer;
signalling: Signalling;
iceServers: () => Promise<RTCIceServer[]>;
audioOutputDeviceId$: Behavior<string | undefined>;
logger: Logger;
}): SubscriberConnection {
const logger = parentLogger.getChild(`[Subscriber ← ${peer.memberId}]`);
const attempt$ = new BehaviorSubject<Attempt | null>(null);
let retryMs = RETRY_MIN_MS;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let ended = false;
scope.onEnd(() => (ended = true));
const startAttempt = async (): Promise<void> => {
const servers = await iceServers();
if (ended) return;
const attemptScope = new ObservableScope();
const attempt = createAttempt({
scope: attemptScope,
peer,
signalling,
iceServers: servers,
audioOutputDeviceId$,
onFailed: (reason) => {
logger.info(`Attempt ${attempt.connectionId} ended: ${reason}`);
attemptScope.end();
if (attempt$.value === attempt) attempt$.next(null);
if (ended) return;
retryTimer = setTimeout(() => void startAttempt(), retryMs);
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
},
onConnected: () => (retryMs = RETRY_MIN_MS),
logger,
});
attempt$.next(attempt);
};
void startAttempt();
scope.onEnd(() => {
clearTimeout(retryTimer);
attempt$.value?.end();
attempt$.next(null);
});
return {
current$: scope.behavior(attempt$.pipe(map((a) => a?.base ?? null))),
tracks$: scope.behavior(
attempt$.pipe(switchMap((a) => a?.tracks$ ?? of(null))),
),
handle: (message) => attempt$.value?.handle(message),
};
}
interface Attempt {
connectionId: string;
base: PeerConnectionBase;
tracks$: Behavior<RemoteTrack[] | null>;
handle(message: SignallingMessage): void;
end(): void;
}
function createAttempt({
scope,
peer,
signalling,
iceServers,
audioOutputDeviceId$,
onFailed,
onConnected,
logger,
}: {
scope: ObservableScope;
peer: Peer;
signalling: Signalling;
iceServers: RTCIceServer[];
audioOutputDeviceId$: Behavior<string | undefined>;
onFailed: (reason: string) => void;
onConnected: () => void;
logger: Logger;
}): Attempt {
const connectionId = uuidv4();
let failed = false;
const fail = (reason: string): void => {
if (failed) return;
failed = true;
onFailed(reason);
};
const base = createPeerConnectionBase({
scope,
peer,
connectionId,
signalling,
iceServers,
logger,
});
const { pc, enqueue } = base;
const metadata$ = new BehaviorSubject<StreamMetadata>({});
const tracks$ = new BehaviorSubject<RemoteTrack[] | null>(null);
// The stream each received track arrived in, which the metadata is keyed by
const streamOf = new Map<MediaStreamTrack, string>();
const wrappers = new Map<
RTCRtpTransceiver,
{ track: RemoteTrack; scope: ObservableScope; source: MediaSource }
>();
pc.addEventListener("track", ({ track, streams }) => {
if (streams[0]) streamOf.set(track, streams[0].id);
});
/** The tracks of the transceivers that receive, after each negotiation. */
const updateTracks = (): void => {
const metadata = metadata$.value;
const live = new Set<RTCRtpTransceiver>();
for (const transceiver of pc.getTransceivers()) {
const track = transceiver.receiver.track;
const streamId = streamOf.get(track);
const entry = streamId === undefined ? undefined : metadata[streamId];
if (
transceiver.currentDirection !== "recvonly" ||
entry === undefined ||
(entry.purpose !== "m.usermedia" && entry.purpose !== "m.screenshare")
)
continue;
const kind = track.kind === "audio" ? "audio" : "video";
const source = sourceOf(entry.purpose, kind);
live.add(transceiver);
const existing = wrappers.get(transceiver);
if (existing?.source === source) continue;
existing?.scope.end();
const trackScope = new ObservableScope();
wrappers.set(transceiver, {
scope: trackScope,
source,
track: createFullMeshMediaTrack(trackScope, {
source,
kind,
id: `${connectionId}:${transceiver.mid}`,
track$: trackScope.behavior(of(track)),
muted$: trackScope.behavior(
metadata$.pipe(
map((m) => {
const e = streamId === undefined ? undefined : m[streamId];
return (
(kind === "audio" ? e?.audio_muted : e?.video_muted) ?? false
);
}),
distinctUntilChanged(),
),
),
stats: async () => rtpStats(pc, track, "inbound-rtp"),
audioOutputDeviceId$,
}),
});
}
for (const [transceiver, { scope: s }] of wrappers)
if (!live.has(transceiver)) {
s.end();
wrappers.delete(transceiver);
}
tracks$.next([...wrappers.values()].map(({ track }) => track));
};
base.state$
.pipe(
filter((s) => s instanceof Error || s === MediaConnectionState.Connected),
scope.bind(),
)
.subscribe((s) => {
if (s instanceof Error) {
base.close("ice_failed", true);
fail(s.message);
} else onConnected();
});
const handle = (message: SignallingMessage): void => {
if (message.connectionId !== connectionId) return;
switch (message.type) {
case SignallingEventType.Description:
if (message.description.type !== "offer") return;
enqueue(async () => {
if (message.stream_metadata) metadata$.next(message.stream_metadata);
await pc.setRemoteDescription(message.description);
await pc.setLocalDescription(await pc.createAnswer());
signalling.send(peer, connectionId, {
type: SignallingEventType.Description,
description: { type: "answer", sdp: pc.localDescription!.sdp },
});
updateTracks();
});
break;
case SignallingEventType.Candidates:
base.addCandidates(message);
break;
case SignallingEventType.StreamMetadata:
metadata$.next(message.stream_metadata);
// A stream may only become known with its metadata
enqueue(async () => Promise.resolve(updateTracks()));
break;
case SignallingEventType.Close:
base.close(message.reason, false);
fail(`closed by the publisher: ${message.reason}`);
break;
default:
logger.debug(`Ignoring ${message.type}`);
}
};
scope.onEnd(() => {
for (const { scope: s } of wrappers.values()) s.end();
wrappers.clear();
});
signalling.send(peer, connectionId, { type: SignallingEventType.Subscribe });
return {
connectionId,
base,
tracks$: scope.behavior(tracks$),
handle,
end: () => scope.end(),
};
}
function sourceOf(
purpose: "m.usermedia" | "m.screenshare",
kind: "audio" | "video",
): MediaSource {
if (purpose === "m.usermedia")
return kind === "audio" ? "microphone" : "camera";
return kind === "audio" ? "screenShareAudio" : "screenShare";
}
@@ -1,152 +0,0 @@
/*
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "events";
import { ClientEvent, EventType, MatrixEvent } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { ObservableScope } from "../../reactive/ObservableScope";
import {
createSignalling,
SEQ_GAP_TIMEOUT_MS,
SeqOrderer,
SignallingEventType,
type SignallingClient,
type SignallingMessage,
} from "./signalling";
describe("SeqOrderer", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("delivers in seq order", () => {
const delivered: string[] = [];
const orderer = new SeqOrderer<string>((s) => delivered.push(s));
orderer.push(1, "b");
orderer.push(2, "c");
expect(delivered).toEqual([]);
orderer.push(0, "a");
expect(delivered).toEqual(["a", "b", "c"]);
});
it("skips a gap that isn't filled in time, then drops the late message", () => {
const delivered: string[] = [];
const orderer = new SeqOrderer<string>((s) => delivered.push(s));
orderer.push(0, "a");
orderer.push(2, "c");
vi.advanceTimersByTime(SEQ_GAP_TIMEOUT_MS);
expect(delivered).toEqual(["a", "c"]);
orderer.push(1, "b");
expect(delivered).toEqual(["a", "c"]);
});
});
describe("createSignalling", () => {
const own = { userId: "@me:x", deviceId: "MINE", memberId: "me" };
function setup(): {
client: SignallingClient & EventEmitter;
received: SignallingMessage[];
scope: ObservableScope;
} {
const client = Object.assign(new EventEmitter(), {
encryptAndSendToDevice: vi.fn(async () => Promise.resolve()),
}) as unknown as SignallingClient & EventEmitter;
const scope = new ObservableScope();
const signalling = createSignalling({
scope,
client,
roomId: "!room:x",
slotId: "m.call#ROOM",
own,
logger,
});
const received: SignallingMessage[] = [];
signalling.messages$.subscribe((m) => received.push(m));
return { client, received, scope };
}
function toDevice(
type: string,
content: Record<string, unknown>,
encrypted = true,
): MatrixEvent {
const event = new MatrixEvent({ type, sender: "@peer:x", content });
if (encrypted)
vi.spyOn(event, "getWireType").mockReturnValue(
EventType.RoomMessageEncrypted,
);
return event;
}
const common = {
room_id: "!room:x",
slot_id: "m.call#ROOM",
member_id: "peer",
target_member_id: "me",
connection_id: "c1",
device_id: "PEER",
};
it("passes valid messages on and drops cleartext or misaddressed ones", () => {
const { client, received, scope } = setup();
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.Subscribe, { ...common, seq: 0 }, false),
);
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.Subscribe, {
...common,
target_member_id: "someone else",
seq: 0,
}),
);
expect(received).toEqual([]);
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.Subscribe, { ...common, seq: 0 }),
);
expect(received).toEqual([
{
type: SignallingEventType.Subscribe,
memberId: "peer",
userId: "@peer:x",
deviceId: "PEER",
connectionId: "c1",
},
]);
scope.end();
});
it("orders by seq across connection ids", () => {
const { client, received, scope } = setup();
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.Subscribe, {
...common,
connection_id: "c2",
seq: 2,
}),
);
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.Close, { ...common, seq: 1 }),
);
client.emit(
ClientEvent.ToDeviceEvent,
toDevice(SignallingEventType.Subscribe, { ...common, seq: 0 }),
);
expect(received.map((m) => [m.type, m.connectionId])).toEqual([
[SignallingEventType.Subscribe, "c1"],
[SignallingEventType.Close, "c1"],
[SignallingEventType.Subscribe, "c2"],
]);
scope.end();
});
});
@@ -1,304 +0,0 @@
/*
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.
*/
/**
* The to-device signalling of the full mesh transport (MSCXXXX): five event
* types, the fields every one carries, and the ordering by `seq`.
*/
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
ClientEvent,
EventType,
type MatrixClient,
type MatrixEvent,
} from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { fromEvent, Observable, share } from "rxjs";
import { type ObservableScope } from "../../reactive/ObservableScope";
export const FULL_MESH_TRANSPORT_TYPE = "org.matrix.mscXXXX.full_mesh";
const prefix = "org.matrix.mscXXXX.rtc.full_mesh";
export const SignallingEventType = {
Subscribe: `${prefix}.subscribe`,
Description: `${prefix}.description`,
Candidates: `${prefix}.candidates`,
StreamMetadata: `${prefix}.stream_metadata`,
Close: `${prefix}.close`,
} as const;
export type SignallingEventType =
(typeof SignallingEventType)[keyof typeof SignallingEventType];
export const signallingEventTypes: string[] =
Object.values(SignallingEventType);
/** How long a gap in `seq` is waited for before the missing messages are skipped. */
export const SEQ_GAP_TIMEOUT_MS = 10_000;
export interface StreamMetadataEntry {
purpose: "m.usermedia" | "m.screenshare";
audio_muted?: boolean;
video_muted?: boolean;
}
/** By `MediaStream` id, as it appears in the SDP's `msid`. */
export type StreamMetadata = Record<string, StreamMetadataEntry>;
interface CommonContent {
room_id: string;
slot_id: string;
member_id: string;
target_member_id: string;
connection_id: string;
seq: number;
/**
* Not in the MSC, which takes the device from the Olm session. The js-sdk
* does not expose that for to-device events, so, like the key transport's
* `claimed_device_id`, the sender states it.
*/
device_id: string;
}
export type SignallingPayload =
| { type: typeof SignallingEventType.Subscribe }
| {
type: typeof SignallingEventType.Description;
description: { type: "offer" | "answer"; sdp: string };
stream_metadata?: StreamMetadata;
}
| {
type: typeof SignallingEventType.Candidates;
candidates: RTCIceCandidateInit[];
}
| {
type: typeof SignallingEventType.StreamMetadata;
stream_metadata: StreamMetadata;
}
| { type: typeof SignallingEventType.Close; reason?: string };
export type SignallingMessage = SignallingPayload & {
/** The member that sent it, as named in `member_id`. */
memberId: string;
userId: string;
deviceId: string;
connectionId: string;
};
/** Another member, as far as signalling needs to know it. */
export interface Peer {
userId: string;
deviceId: string;
memberId: string;
}
export interface Signalling {
send(peer: Peer, connectionId: string, payload: SignallingPayload): void;
/** Every valid message for us, per sender in `seq` order. */
messages$: Observable<SignallingMessage>;
}
export type SignallingClient = Pick<
MatrixClient,
"encryptAndSendToDevice" | "on" | "off"
>;
interface Props {
scope: ObservableScope;
client: SignallingClient;
roomId: string;
slotId: string;
own: CallMembershipIdentityParts;
logger: Logger;
}
export function createSignalling({
scope,
client,
roomId,
slotId,
own,
logger: parentLogger,
}: Props): Signalling {
const logger = parentLogger.getChild("[Signalling]");
const nextSeqTo = new Map<string, number>();
const send = (
peer: Peer,
connectionId: string,
{ type, ...payload }: SignallingPayload,
): void => {
const seq = nextSeqTo.get(peer.memberId) ?? 0;
nextSeqTo.set(peer.memberId, seq + 1);
const content: CommonContent & Record<string, unknown> = {
...payload,
room_id: roomId,
slot_id: slotId,
member_id: own.memberId,
target_member_id: peer.memberId,
connection_id: connectionId,
seq,
device_id: own.deviceId,
};
client
.encryptAndSendToDevice(
type,
[{ userId: peer.userId, deviceId: peer.deviceId }],
content,
)
.catch((e: unknown) => logger.warn(`Failed to send ${type}`, e));
};
const messages$ = new Observable<SignallingMessage>((subscriber) => {
const orderers = new Map<string, SeqOrderer<SignallingMessage>>();
const sub = fromEvent<MatrixEvent>(client, ClientEvent.ToDeviceEvent)
.pipe(scope.bind())
.subscribe((event) => {
const parsed = parse(event, roomId, slotId, own.memberId, logger);
if (parsed === null) return;
let orderer = orderers.get(parsed.message.memberId);
if (orderer === undefined) {
orderer = new SeqOrderer((m) => subscriber.next(m), logger);
orderers.set(parsed.message.memberId, orderer);
}
orderer.push(parsed.seq, parsed.message);
});
return () => {
sub.unsubscribe();
for (const orderer of orderers.values()) orderer.dispose();
};
}).pipe(share());
return { send, messages$ };
}
function parse(
event: MatrixEvent,
roomId: string,
slotId: string,
ownMemberId: string,
logger: Logger,
): { seq: number; message: SignallingMessage } | null {
const type = event.getType();
if (!signallingEventTypes.includes(type)) return null;
// Via the widget API, to-device events are reconstructed as encrypted
if (event.getWireType() !== EventType.RoomMessageEncrypted) {
logger.warn(`Dropping ${type} sent in clear from ${event.getSender()}`);
return null;
}
const c = event.getContent<
Partial<CommonContent> & Record<string, unknown>
>();
const userId = event.getSender();
if (
userId === undefined ||
c.room_id !== roomId ||
c.slot_id !== slotId ||
c.target_member_id !== ownMemberId ||
typeof c.member_id !== "string" ||
typeof c.connection_id !== "string" ||
typeof c.device_id !== "string" ||
typeof c.seq !== "number"
)
return null;
const common = {
memberId: c.member_id,
userId,
deviceId: c.device_id,
connectionId: c.connection_id,
};
let payload: SignallingPayload;
switch (type) {
case SignallingEventType.Subscribe:
payload = { type };
break;
case SignallingEventType.Description: {
const d = c.description as { type?: unknown; sdp?: unknown } | undefined;
if (
(d?.type !== "offer" && d?.type !== "answer") ||
typeof d.sdp !== "string"
)
return null;
payload = {
type,
description: { type: d.type, sdp: d.sdp },
stream_metadata: c.stream_metadata as StreamMetadata | undefined,
};
break;
}
case SignallingEventType.Candidates:
if (!Array.isArray(c.candidates)) return null;
payload = { type, candidates: c.candidates as RTCIceCandidateInit[] };
break;
case SignallingEventType.StreamMetadata:
if (typeof c.stream_metadata !== "object" || c.stream_metadata === null)
return null;
payload = { type, stream_metadata: c.stream_metadata as StreamMetadata };
break;
case SignallingEventType.Close:
payload = {
type,
reason: typeof c.reason === "string" ? c.reason : undefined,
};
break;
default:
return null;
}
return { seq: c.seq, message: { ...payload, ...common } };
}
/**
* Delivers the messages of one sender in `seq` order. A gap is waited for
* `SEQ_GAP_TIMEOUT_MS`, then skipped. A message below the next expected `seq`
* came after its gap was skipped, and is dropped.
*/
export class SeqOrderer<T> {
private next = 0;
private readonly buffer = new Map<number, T>();
private gapTimer: ReturnType<typeof setTimeout> | undefined;
public constructor(
private readonly deliver: (item: T) => void,
private readonly logger?: Logger,
private readonly gapTimeoutMs = SEQ_GAP_TIMEOUT_MS,
) {}
public push(seq: number, item: T): void {
if (seq < this.next) {
this.logger?.warn(`Dropping seq ${seq}, already past it`);
return;
}
this.buffer.set(seq, item);
this.flush();
}
public dispose(): void {
clearTimeout(this.gapTimer);
this.buffer.clear();
}
private flush(): void {
let item = this.buffer.get(this.next);
while (item !== undefined) {
this.buffer.delete(this.next);
this.next++;
this.deliver(item);
item = this.buffer.get(this.next);
}
clearTimeout(this.gapTimer);
this.gapTimer = undefined;
if (this.buffer.size > 0)
this.gapTimer = setTimeout(() => this.skipGap(), this.gapTimeoutMs);
}
private skipGap(): void {
const lowest = Math.min(...this.buffer.keys());
this.logger?.warn(`Skipping seq ${this.next} to ${lowest - 1}`);
this.next = lowest;
this.flush();
}
}
-286
View File
@@ -1,286 +0,0 @@
/*
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.
*/
/**
* Fakes for the parts of WebRTC that jsdom lacks, and for the signalling, so
* that the full mesh connections can run in unit tests.
*/
import { vi } from "vitest";
import { BehaviorSubject, Subject } from "rxjs";
import { type LocalPublication } from "./LocalMedia";
import {
type Peer,
type Signalling,
type SignallingMessage,
type SignallingPayload,
type StreamMetadata,
} from "./signalling";
let nextId = 0;
export class FakeMediaStream {
public readonly id: string;
public constructor(public readonly tracks: MediaStreamTrack[] = []) {
this.id = `stream-${nextId++}`;
}
public getTracks(): MediaStreamTrack[] {
return this.tracks;
}
}
export function fakeTrack(kind: "audio" | "video"): MediaStreamTrack {
return {
id: `track-${nextId++}`,
kind,
enabled: true,
stop: vi.fn(),
getSettings: () => ({}),
} as unknown as MediaStreamTrack;
}
interface FakeTransceiver {
kind: string;
mid: string;
direction: RTCRtpTransceiverDirection;
currentDirection: RTCRtpTransceiverDirection | null;
streams: MediaStream[];
stopped: boolean;
stop(): void;
sender: {
track: MediaStreamTrack | null;
replaceTrack: ReturnType<typeof vi.fn>;
};
receiver: { track: MediaStreamTrack };
}
/** Enough of `RTCPeerConnection` for one side of a connection, driven by the test. */
export class FakePeerConnection extends EventTarget {
public static instances: FakePeerConnection[] = [];
public signalingState: RTCSignalingState = "stable";
public connectionState: RTCPeerConnectionState = "new";
public iceConnectionState: RTCIceConnectionState = "new";
public localDescription: RTCSessionDescriptionInit | null = null;
public remoteDescription: RTCSessionDescriptionInit | null = null;
public readonly transceivers: FakeTransceiver[] = [];
public readonly addedCandidates: RTCIceCandidateInit[] = [];
public offers = 0;
public closed = false;
public constructor(public readonly config: RTCConfiguration) {
super();
FakePeerConnection.instances.push(this);
}
public addTransceiver(
kind: string,
init: RTCRtpTransceiverInit,
): FakeTransceiver {
const transceiver: FakeTransceiver = {
kind,
mid: String(this.transceivers.length),
direction: init.direction ?? "sendrecv",
currentDirection: null,
streams: init.streams ?? [],
stopped: false,
stop(): void {
this.stopped = true;
},
sender: {
track: null,
replaceTrack: vi.fn(async (track: MediaStreamTrack | null) => {
transceiver.sender.track = track;
return Promise.resolve();
}),
},
receiver: { track: fakeTrack(kind === "audio" ? "audio" : "video") },
};
this.transceivers.push(transceiver);
return transceiver;
}
public getTransceivers(): FakeTransceiver[] {
return this.transceivers;
}
public getSenders(): FakeTransceiver["sender"][] {
return this.transceivers.map((t) => t.sender);
}
public async createOffer(): Promise<RTCSessionDescriptionInit> {
return Promise.resolve({ type: "offer", sdp: `offer-${++this.offers}` });
}
public async createAnswer(): Promise<RTCSessionDescriptionInit> {
return Promise.resolve({ type: "answer", sdp: "answer" });
}
public async setLocalDescription(
description: RTCSessionDescriptionInit,
): Promise<void> {
this.localDescription = description;
this.signalingState =
description.type === "offer" ? "have-local-offer" : "stable";
return Promise.resolve();
}
public async setRemoteDescription(
description: RTCSessionDescriptionInit,
): Promise<void> {
this.remoteDescription = description;
this.signalingState =
description.type === "offer" ? "have-remote-offer" : "stable";
return Promise.resolve();
}
public async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
if (candidate.candidate === "bad")
return Promise.reject(new Error("bad candidate"));
this.addedCandidates.push(candidate);
return Promise.resolve();
}
public async getStats(): Promise<RTCStatsReport> {
return Promise.resolve(new Map() as unknown as RTCStatsReport);
}
public close(): void {
this.closed = true;
this.signalingState = "closed";
this.connectionState = "closed";
}
/** Gathers a local candidate; null ends the gathering. */
public gather(candidate: RTCIceCandidateInit | null): void {
this.dispatchEvent(
Object.assign(new Event("icecandidate"), {
candidate: candidate && { toJSON: () => candidate },
}),
);
}
public setConnectionState(state: RTCPeerConnectionState): void {
this.connectionState = state;
this.dispatchEvent(new Event("connectionstatechange"));
}
/** A track the remote side sends, as the next negotiation would bring it. */
public receive(kind: "audio" | "video", streamId: string): FakeTransceiver {
const transceiver = this.addTransceiver(kind, { direction: "recvonly" });
transceiver.currentDirection = "recvonly";
this.dispatchEvent(
Object.assign(new Event("track"), {
track: transceiver.receiver.track,
streams: [{ id: streamId }],
}),
);
return transceiver;
}
}
class FakeAudioContext {
public destination = {};
public createMediaStreamSource(): object {
return { connect: vi.fn(), disconnect: vi.fn() };
}
public createAnalyser(): object {
return {
fftSize: 512,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: (samples: Float32Array): void => {
samples.fill(0);
},
};
}
public createGain(): object {
return { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
}
}
/** Replaces the WebRTC and Web Audio globals jsdom doesn't have. Undo with `vi.unstubAllGlobals()`. */
export function installWebRtcFakes(): void {
FakePeerConnection.instances = [];
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
vi.stubGlobal("MediaStream", FakeMediaStream);
vi.stubGlobal("AudioContext", FakeAudioContext);
}
/** The last peer connection created. */
export function lastPeerConnection(): FakePeerConnection {
const pc = FakePeerConnection.instances.at(-1);
if (pc === undefined) throw new Error("No peer connection was created");
return pc;
}
/** Lets every queued signalling operation run. */
export async function settle(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
export const peer: Peer = {
userId: "@peer:example.org",
deviceId: "PEERDEVICE",
memberId: "peerMember",
};
export interface FakeSignalling extends Signalling {
send: ReturnType<typeof vi.fn<Signalling["send"]>>;
messages$: Subject<SignallingMessage>;
/** The payloads sent so far, optionally only those of one type. */
sent(type?: string): SignallingPayload[];
/** The connection ids sent with the payloads, in the same order. */
sentConnectionIds(type?: string): string[];
}
export function fakeSignalling(): FakeSignalling {
const send = vi.fn<Signalling["send"]>();
const calls = (type?: string): Parameters<Signalling["send"]>[] =>
send.mock.calls.filter(([, , p]) => type === undefined || p.type === type);
return {
send,
messages$: new Subject(),
sent: (type) => calls(type).map(([, , p]) => p),
sentConnectionIds: (type) => calls(type).map(([, id]) => id),
};
}
/** A message from `peer` on a connection. */
export function fromPeer(
connectionId: string,
payload: SignallingPayload,
): SignallingMessage {
return { ...payload, ...peer, connectionId };
}
/** The local media a publisher connection reads. */
export function fakeLocalMedia(): {
publications$: BehaviorSubject<LocalPublication[]>;
publishing$: BehaviorSubject<boolean>;
streamMetadata$: BehaviorSubject<StreamMetadata>;
} {
return {
publications$: new BehaviorSubject<LocalPublication[]>([]),
publishing$: new BehaviorSubject(true),
streamMetadata$: new BehaviorSubject<StreamMetadata>({}),
};
}
export function fakePublication(
kind: "audio" | "video",
stream = new FakeMediaStream() as unknown as MediaStream,
): LocalPublication & { track$: BehaviorSubject<MediaStreamTrack | null> } {
return {
source: kind === "audio" ? "microphone" : "camera",
kind,
stream,
track$: new BehaviorSubject<MediaStreamTrack | null>(fakeTrack(kind)),
mediaTrack: {} as LocalPublication["mediaTrack"],
};
}
+4 -11
View File
@@ -101,11 +101,8 @@ export class ConnectionMap {
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
/**
* The transport we publish on; the one connection with the publisher role.
* Null for a backend that only subscribes, while another backend publishes.
*/
localTransport: TransportLocator | null;
/** The transport we publish on; the one connection with the publisher role. */
localTransport: TransportLocator;
/** Every transport some member is on, deduplicated against the local one. */
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
logger: Logger;
@@ -131,11 +128,7 @@ export function createConnectionMap$({
scope.behavior(
remoteTransports$.pipe(
mapEpoch((remoteTransports) =>
removeDuplicateTransports(
localTransport
? [localTransport, ...remoteTransports]
: remoteTransports,
),
removeDuplicateTransports([localTransport, ...remoteTransports]),
),
tap((transports) =>
logger.debug("localAndRemoteTransports$ = ", transports),
@@ -143,7 +136,7 @@ export function createConnectionMap$({
),
);
const localTransportKey = localTransport && keyFor(localTransport);
const localTransportKey = keyFor(localTransport);
const connections$ = scope.behavior(
localAndRemoteTransports$.pipe(
@@ -115,14 +115,9 @@ export function createLivekitBackend(
);
// A new map, in a scope of its own, whenever the local transport changes:
// the publisher role sits on one connection, and which one is fixed per map.
// Without a local transport the map only subscribes, for when another
// backend publishes; boxed so that `mapScoped` builds a map for null too
const boxedLocalTransport$ = scope.behavior(
localTransport$.pipe(map((localTransport) => ({ localTransport }))),
);
// the publisher role sits on one connection, and which one is fixed per map
const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
mapScoped(scope, boxedLocalTransport$, (mapScope, { localTransport }) =>
mapScoped(scope, localTransport$, (mapScope, localTransport) =>
createConnectionMap$({
scope: mapScope,
connectionFactory,
@@ -144,15 +139,12 @@ export function createLivekitBackend(
logger,
});
// Probed once, when first needed: the answer cannot change within a
// session, and a backend that only subscribes never needs it
let homeserverProbe: Promise<boolean> | undefined;
const homeserverSupportsDelegation = async (): Promise<boolean> =>
(homeserverProbe ??= supportsDelegation(
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
"homeserver",
logger,
));
// Probed once: the answer cannot change within a session
const homeserverSupportsDelegation = supportsDelegation(
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
"homeserver",
logger,
);
const ownServerName = serverNameOf(ownMembershipIdentity.userId);
@@ -184,7 +176,7 @@ export function createLivekitBackend(
`transport ${flow.serviceUrl}`,
logger,
)
: homeserverSupportsDelegation();
: homeserverSupportsDelegation;
}
localTransport$.next(locator);
// The membership is only sent once the publisher connection holds its
+1 -6
View File
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types";
import type { Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
@@ -65,10 +64,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
const gridLayout: Layout = {
type: "grid",
grid: [],
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: (_: number) => {},
};
+69 -2
View File
@@ -5,11 +5,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { E2eeType } from "@element-hq/matrixrtc-sdk";
import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement";
import {
getKeyForRoom,
saveKeyForRoom,
useRoomEncryptionSystem,
} from "./sharedKeyManagement";
vi.mock("../ClientContext", () => {
const room = { hasEncryptionStateEvent: (): boolean => false };
const client = { getRoom: (): typeof room => room };
return { useClient: (): { client: typeof client } => ({ client }) };
});
const roomId = "!room:example.org";
const otherRoomId = "!other:example.org";
describe("getKeyForRoom", () => {
afterEach(() => {
@@ -33,4 +46,58 @@ describe("getKeyForRoom", () => {
test("has no key to offer for a room it has never seen", () => {
expect(getKeyForRoom(roomId)).toBeNull();
});
test("prefers the stored key when the parameters are for another room", () => {
saveKeyForRoom(roomId, "stored");
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
expect(getKeyForRoom(roomId)).toBe("stored");
});
test("ignores the password in the parameters for another room", () => {
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
expect(getKeyForRoom(roomId)).toBeNull();
});
});
describe("useRoomEncryptionSystem", () => {
afterEach(() => {
window.location.hash = "#";
localStorage.clear();
});
test("picks up a key saved for the room after it rendered", () => {
const { result } = renderHook(() => useRoomEncryptionSystem(roomId));
expect(result.current).toEqual({ kind: E2eeType.NONE });
act(() => saveKeyForRoom(roomId, "stored"));
expect(result.current).toEqual({
kind: E2eeType.SHARED_KEY,
secret: "stored",
});
});
test("stores a key from the parameters under the room's key only", () => {
window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`;
renderHook(() => useRoomEncryptionSystem(roomId));
expect(Object.keys(localStorage)).toEqual([`room-shared-key-${roomId}`]);
expect(getKeyForRoom(roomId)).toBe("from-the-link");
});
test("keeps the stored key when the parameters are for another room", () => {
saveKeyForRoom(roomId, "stored");
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
const { result } = renderHook(() => useRoomEncryptionSystem(roomId));
expect(result.current).toEqual({
kind: E2eeType.SHARED_KEY,
secret: "stored",
});
expect(localStorage.getItem(`room-shared-key-${roomId}`)).toBe("stored");
});
});
+11 -10
View File
@@ -59,22 +59,27 @@ const useRoomSharedKey = (
/**
* The shared key for a room, preferring one supplied in the parameters Element
* Call was started with over whatever is in local storage.
* Call was started with over whatever is in local storage. If the parameters
* are for another room, only the stored key is used, because the password in
* the parameters belongs to that other room.
*/
function keyForRoom(
roomId: string,
paramsRoomId: string | null,
password: string | null,
): string | null {
if (roomId !== paramsRoomId)
const storedKey = localStorage.getItem(
getRoomSharedKeyLocalStorageKey(roomId),
);
if (roomId !== paramsRoomId) {
logger.warn(
"requested key for a roomId which is not the current call room id (from the URL)",
roomId,
paramsRoomId,
);
return (
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
);
return storedKey;
}
return password ?? storedKey;
}
export function getKeyForRoom(roomId: string): string | null {
@@ -89,11 +94,7 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { roomId: paramsRoomId, password } = useUrlParams();
const [storedPassword] = useRoomSharedKey(
// TODO: this passes an already-prefixed key where a room ID is expected, so
// the local storage key ends up prefixed twice and never matches what
// saveKeyForRoom writes. Preserved as-is here to keep this commit a pure
// refactor; the reactive read is effectively dead until it is fixed.
getRoomSharedKeyLocalStorageKey(roomId),
roomId,
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
);
+100 -150
View File
@@ -5,28 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { act, render, screen } from "@testing-library/react";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { arrangeTiles, type Bounds } from "./CallLayout";
import { type CallLayout } from "./CallLayout";
import { makeGridLayout } from "./GridLayout";
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
import { type DragCallback, type LayoutProps } from "./Grid";
import { type LayoutProps } from "./Grid";
import { constant } from "../state/Behavior";
import {
type Alignment,
type GridLayout,
type SpotlightLandscapeLayout,
type SpotlightPortraitLayout,
} from "../state/layout-types";
import { type Alignment } from "../state/layout-types";
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
import {
GridTileViewModel,
SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel";
import { makeOneOnOneMobileLayout } from "./OneOnOneMobileLayout";
import { makeSpotlightExpandedLayout } from "./SpotlightExpandedLayout";
import { makeOneOnOneDesktopLayout } from "./OneOnOneDesktopLayout";
// Grid supplies these hooks to the layouts it renders; on their own the
// layouts have no grid to report to
@@ -36,152 +33,105 @@ vi.mock("./Grid", async (importOriginal) => ({
useVisibleTiles: (): void => {},
}));
describe("grid layout", () => {
it("sizes the tiles from the minimum bounds", () => {
const minBounds$ = new BehaviorSubject<Bounds>({ width: 800, height: 600 });
const { scrolling: Scrolling } = makeGridLayout({ minBounds$ });
render(<Scrolling model={gridModel()} Slot={Slot} />);
const layer = screen.getByTestId("slot-0").parentElement!;
expect(layer.style.width).toBe("800px");
expect(layer.style.getPropertyValue("--width")).toBe(
`${Math.floor(arrangeTiles(800, 600, 3).tileWidth)}px`,
);
const minBounds$ = constant({ width: 800, height: 800 });
act(() => minBounds$.next({ width: 400, height: 300 }));
expect(layer.style.width).toBe("400px");
expect(layer.style.getPropertyValue("--width")).toBe(
`${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`,
);
});
it("moves the spotlight only when a drag reaches another corner", () => {
const model = gridModel();
const { fixed: Fixed } = makeGridLayout({ minBounds$: bounds });
render(<Fixed model={model} Slot={Slot} />);
const alignments: Alignment[] = [];
model.spotlightAlignment$.subscribe((a) => alignments.push(a));
const drag = drags.get("spotlight")!;
// Still in the bottom right corner
act(() => drag({ x: 0, y: 0, xRatio: 0.9, yRatio: 0.9 }));
// Over to the bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.1, yRatio: 0.9 }));
// And a little further, still bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.8 }));
// Up to the top left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.1 }));
expect(alignments).toEqual([
{ inline: "end", block: "end" },
{ inline: "start", block: "end" },
{ inline: "start", block: "start" },
]);
const slot = screen.getByTestId("slot-spotlight");
expect(slot.getAttribute("data-inline-alignment")).toBe("start");
expect(slot.getAttribute("data-block-alignment")).toBe("start");
});
});
describe("spotlight landscape layout", () => {
it("puts the spotlight in the fixed layer and the grid in the scrolling one", () => {
const { fixed: Fixed, scrolling: Scrolling } = makeSpotlightLandscapeLayout(
{ minBounds$: bounds },
);
const model = landscapeModel();
const { unmount } = render(<Fixed model={model} Slot={Slot} />);
expect(screen.getByTestId("slot-spotlight")).toBeInTheDocument();
unmount();
render(<Scrolling model={model} Slot={Slot} />);
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
});
});
describe("spotlight portrait layout", () => {
it("sizes the grid tiles from the minimum width", () => {
const minBounds$ = new BehaviorSubject<Bounds>({ width: 300, height: 700 });
const { scrolling: Scrolling } = makeSpotlightPortraitLayout({
minBounds$,
});
const model = portraitModel();
render(<Scrolling model={model} Slot={Slot} />);
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
const layer = screen.getByTestId(`slot-${model.grid[0].id}`).parentElement!
.parentElement!;
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
`${Math.floor(arrangeTiles(300, 300, 3).tileWidth)}px`,
);
act(() => minBounds$.next({ width: 500, height: 700 }));
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
`${Math.floor(arrangeTiles(500, 500, 3).tileWidth)}px`,
);
});
});
const bounds = constant<Bounds>({ width: 800, height: 600 });
// The layouts only pass tile view models through to their slots
const media = {} as UserMediaViewModel;
const spotlight = new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
const spotlight = {
vm: new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
),
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
onDrag: () => {},
};
const grid = Array.from(
{ length: 3 },
() => new GridTileViewModel(constant({} as UserMediaViewModel)),
);
const tiles = (): GridTileViewModel[] =>
Array.from({ length: 3 }, () => new GridTileViewModel(constant(media)));
const pip = {
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
onDrag: () => {},
vm: { id: "pip" } as GridTileViewModel,
size$: constant("sm" as const),
};
function gridModel(): GridLayout {
return {
type: "grid",
spotlight,
grid: tiles(),
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: () => {},
};
}
function landscapeModel(): SpotlightLandscapeLayout {
return {
type: "spotlight-landscape",
spotlight,
grid: tiles(),
setVisibleTiles: () => {},
};
}
function portraitModel(): SpotlightPortraitLayout {
return {
type: "spotlight-portrait",
spotlight,
grid: tiles(),
setVisibleTiles: () => {},
};
}
// Slots record their drag callback so a test can drag them, and show the id
// of the tile they hold
const drags = new Map<string, DragCallback | undefined>();
const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({
id,
model,
onDrag,
children,
...props
}) => {
drags.set(id, onDrag);
return (
<div data-testid={`slot-${id}`} {...props}>
{model instanceof GridTileViewModel ? model.id : "spotlight"}
</div>
);
};
}) => (
<div data-testid={`slot-${id}`} {...props}>
{children}
</div>
);
function slotIds(): string[] {
return screen
.getAllByTestId(/^slot-/)
.map((slot) => slot.textContent ?? "")
.filter((id) => id !== "spotlight");
function snapshotLayout<Model>(layout: CallLayout<Model>, model: Model): void {
const {
foreground,
scrolling: Scrolling,
fixed: Fixed,
} = layout({ minBounds$ });
const scrollingLayer = <Scrolling model={model} Slot={Slot} />;
const fixedLayer = <Fixed model={model} Slot={Slot} />;
const { container } = render(
foreground === "fixed" ? (
<>
{scrollingLayer}
{fixedLayer}
</>
) : (
<>
{fixedLayer}
{scrollingLayer}
</>
),
);
expect(container).toMatchSnapshot();
}
test("grid layout", () =>
snapshotLayout(makeGridLayout, {
type: "grid",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight landscape layout", () =>
snapshotLayout(makeSpotlightLandscapeLayout, {
type: "spotlight-landscape",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight portrait layout", () =>
snapshotLayout(makeSpotlightPortraitLayout, {
type: "spotlight-portrait",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight expanded layout", () =>
snapshotLayout(makeSpotlightExpandedLayout, {
type: "spotlight-expanded",
spotlight: spotlight.vm,
pip,
}));
test("one-on-one desktop layout", () =>
snapshotLayout(makeOneOnOneDesktopLayout, {
type: "one-on-one-desktop",
spotlight: { id: "spotlight" } as GridTileViewModel,
pip,
}));
test("one-on-one mobile layout", () =>
snapshotLayout(makeOneOnOneMobileLayout, {
type: "one-on-one-mobile",
spotlight: spotlight.vm,
pip,
}));
+12 -29
View File
@@ -40,6 +40,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { useInitial } from "../useInitial";
import { type DragCallback } from "../state/layout-types";
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
@@ -83,7 +84,10 @@ interface DragState {
cursorY: number;
}
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
export interface SlotProps<Model> extends Omit<
ComponentProps<"div">,
"onDrag"
> {
id: string;
model: Model;
onDrag?: DragCallback;
@@ -181,27 +185,6 @@ export interface TileProps<Model, R extends HTMLElement> {
model: Model;
}
interface Drag {
/**
* The X coordinate of the dragged tile in grid space.
*/
x: number;
/**
* The Y coordinate of the dragged tile in grid space.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the grid width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the grid height.
*/
yRatio: number;
}
export type DragCallback = (drag: Drag) => void;
interface LayoutMemoProps<
LayoutModel,
TileModel,
@@ -464,13 +447,13 @@ export function Grid<
)
.catch(logger.error);
if (endOfGesture)
callback({
x: tileX,
y: tileY,
xRatio: tileX / (gridBounds.width - tile.width),
yRatio: tileY / (gridBounds.height - tile.height),
});
callback({
x: tileX,
y: tileY,
xRatio: tileX / (gridBounds.width - tile.width),
yRatio: tileY / (gridBounds.height - tile.height),
endOfGesture,
});
};
// Callback for useDrag. We could call useDrag here, but the default
+30 -25
View File
@@ -8,15 +8,21 @@ Please see LICENSE in the repository root for full details.
import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
type FC,
type ComponentType,
} from "react";
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
import styles from "./GridLayout.module.css";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
import { type SlotProps, useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior";
import {
type SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type FloatingTile } from "../state/FloatingTile.ts";
interface GridCSSProperties extends CSSProperties {
"--gap": string;
@@ -24,6 +30,27 @@ interface GridCSSProperties extends CSSProperties {
"--height": string;
}
interface SpotlightSlotProps {
model: FloatingTile & { vm: SpotlightTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const SpotlightSlot: FC<SpotlightSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.slot}
id="spotlight"
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "grid" layout, in which all participants are shown
* together in a scrolling grid.
@@ -37,32 +64,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// lives
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const alignment = useBehavior(model.spotlightAlignment$);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) => {
const block = yRatio < 0.5 ? "start" : "end";
const inline = xRatio < 0.5 ? "start" : "end";
// A drag reports the same alignment on every move; only a change is
// worth a re-render
const current = model.spotlightAlignment$.value;
if (current.block !== block || current.inline !== inline)
model.spotlightAlignment$.next({ block, inline });
},
[model.spotlightAlignment$],
);
return (
<div ref={ref} className={styles.fixed}>
{model.spotlight && (
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
onDrag={onDragSpotlight}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
<SpotlightSlot model={model.spotlight} Slot={Slot} />
)}
</div>
);
+6 -15
View File
@@ -6,13 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback, useMemo } from "react";
import { type ReactNode, useMemo } from "react";
import classNames from "classnames";
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneDesktopLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
@@ -37,21 +37,12 @@ export const makeOneOnOneDesktopLayout: CallLayout<
}): ReactNode {
useUpdateLayout();
const { width, height } = useBehavior(minBounds$);
const pipAlignment = useBehavior(model.pipAlignment$);
const pipAlignment = useBehavior(model.pip.alignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
<Slot
@@ -62,9 +53,9 @@ export const makeOneOnOneDesktopLayout: CallLayout<
>
<Slot
className={classNames(styles.slot, styles.local)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
id={model.pip.vm.id}
model={model.pip.vm}
onDrag={model.pip.onDrag}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
+32 -24
View File
@@ -6,14 +6,43 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { type ComponentType, type FC, type ReactNode } from "react";
import classNames from "classnames";
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import styles from "./OneOnOneMobileLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type SlotProps, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type Behavior } from "../state/Behavior.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const size = useBehavior(model.size$);
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={classNames(styles.pip)}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-size={size}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "one-on-one" layout for mobile platforms, in which
@@ -44,30 +73,9 @@ export const makeOneOnOneMobileLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const pipSize = useBehavior(model.pipSize$);
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={classNames(styles.pip)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
data-size={pipSize}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
</div>
);
},
+29 -24
View File
@@ -5,13 +5,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { type ReactNode, type ComponentType, type FC } from "react";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type SlotProps, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css";
import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.pip}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "expanded spotlight" layout, in which the spotlight
@@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
return (
<div ref={ref} className={styles.layer}>
<Slot
@@ -46,29 +71,9 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={styles.pip}
id={model.pip.id}
model={model.pip}
onDrag={onDragPip}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
</div>
);
},
+2 -2
View File
@@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
model={model.spotlight.vm}
/>
</div>
<div className={styles.grid} />
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
return (
<div ref={ref} className={styles.layer}>
<SpotlightSlot media$={model.spotlight.media$} />
<SpotlightSlot media$={model.spotlight.vm.media$} />
<div className={styles.grid}>
{model.grid.map((m) => (
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
+2 -2
View File
@@ -43,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
model={model.spotlight.vm}
/>
</div>
</div>
@@ -65,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
width,
model.grid.length,
);
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1;
return (
<div
@@ -0,0 +1,186 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`grid layout 1`] = `
<div>
<div
class="_scrolling_e8555d"
style="width: 800px; --gap: 20px; --width: 370px; --height: 277px;"
>
<div
class="_slot_e8555d"
data-testid="slot-0"
/>
<div
class="_slot_e8555d"
data-testid="slot-1"
/>
<div
class="_slot_e8555d"
data-testid="slot-2"
/>
</div>
<div
class="_fixed_e8555d"
>
<div
class="_slot_e8555d"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-spotlight"
/>
</div>
</div>
`;
exports[`one-on-one desktop layout 1`] = `
<div>
<div
class="_layer_185815"
>
<div
class="_container_185815"
data-testid="slot-spotlight"
style="width: 760px; height: 570px;"
>
<div
class="_slot_185815 _local_185815"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-pip"
/>
</div>
</div>
<div />
</div>
`;
exports[`one-on-one mobile layout 1`] = `
<div>
<div
class="_layer_c81c30"
>
<div
class="_spotlight_c81c30"
data-testid="slot-spotlight"
/>
</div>
<div
class="_layer_c81c30"
>
<div
class="_pip_c81c30"
data-block-alignment="end"
data-inline-alignment="end"
data-size="sm"
data-testid="slot-pip"
/>
</div>
</div>
`;
exports[`spotlight expanded layout 1`] = `
<div>
<div
class="_layer_e70191"
>
<div
class="_spotlight_e70191"
data-testid="slot-spotlight"
/>
</div>
<div
class="_layer_e70191"
>
<div
class="_pip_e70191"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-pip"
/>
</div>
</div>
`;
exports[`spotlight landscape layout 1`] = `
<div>
<div
class="_layer_29809c"
>
<div
class="_spotlight_29809c"
>
<div
class="_slot_29809c"
data-testid="slot-spotlight"
/>
</div>
<div
class="_grid_29809c"
/>
</div>
<div
class="_layer_29809c"
>
<div
class="_spotlight_29809c"
/>
<div
class="_grid_29809c"
>
<div
class="_slot_29809c"
data-testid="slot-0"
/>
<div
class="_slot_29809c"
data-testid="slot-1"
/>
<div
class="_slot_29809c"
data-testid="slot-2"
/>
</div>
</div>
</div>
`;
exports[`spotlight portrait layout 1`] = `
<div>
<div
class="_layer_065bbc"
style="--grid-gap: 20px; --grid-tile-width: 370px; --grid-tile-height: 277px;"
>
<div
class="_spotlight_065bbc"
/>
<div
class="_grid_065bbc"
>
<div
class="_slot_065bbc"
data-testid="slot-0"
/>
<div
class="_slot_065bbc"
data-testid="slot-1"
/>
<div
class="_slot_065bbc"
data-testid="slot-2"
/>
</div>
</div>
<div
class="_layer_065bbc"
>
<div
class="_spotlight_065bbc"
>
<div
class="_slot_065bbc"
data-testid="slot-spotlight"
/>
</div>
</div>
</div>
`;
+1 -5
View File
@@ -70,7 +70,6 @@ import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
createRTCSlot,
FULL_MESH_TRANSPORT_TYPE,
type RTCParticipationOptions,
type RTCSlotOptions,
constant,
@@ -85,7 +84,6 @@ import {
cameraFramerate,
cameraResolution,
customTransport,
fullMeshPublishing,
echoCancellationSetting,
matrixRTCMode as matrixRTCModeSetting,
noiseSuppressionSetting,
@@ -825,9 +823,7 @@ export function rtcParticipationOptions(
delegatedDelayedLeave: session.delegated_delayed_leave,
},
mediaQuality: config.media_quality,
transport: fullMeshPublishing.value$.value
? { type: FULL_MESH_TRANSPORT_TYPE }
: (customTransport.value$.value ?? undefined),
transport: customTransport.value$.value ?? undefined,
fallbackTransport: config.livekit && {
type: "livekit",
livekit_service_url: config.livekit.livekit_service_url,
+2 -28
View File
@@ -45,7 +45,6 @@ import {
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting,
customTransport as customTransportSetting,
fullMeshPublishing as fullMeshPublishingSetting,
advancedScreenShare as advancedScreenShareSetting,
screenShareResolution as screenShareResolutionSetting,
screenShareFramerate as screenShareFramerateSetting,
@@ -69,7 +68,6 @@ import { Slider } from "../Slider";
import { useUrlParams } from "../UrlParams";
import {
authenticateWithTransport,
FULL_MESH_TRANSPORT_TYPE,
type TransportMetadata,
} from "@element-hq/matrixrtc-sdk";
import { useBehavior } from "../useBehavior";
@@ -177,9 +175,6 @@ export const DeveloperSettingsTab: FC<Props> = ({
const [customTransportUpdateError, setCustomTransportUpdateError] = useState<
string | null
>(null);
const [fullMeshPublishing, setFullMeshPublishing] = useSetting(
fullMeshPublishingSetting,
);
const [customTransport, setCustomTransport] = useSetting(
customTransportSetting,
);
@@ -422,17 +417,6 @@ export const DeveloperSettingsTab: FC<Props> = ({
)}
/>
</FieldRow>
<FieldRow>
<InputField
id="fullMeshPublishing"
type="checkbox"
checked={fullMeshPublishing}
label={t("developer_mode.full_mesh_publishing")}
onChange={(event: ChangeEvent<HTMLInputElement>): void =>
setFullMeshPublishing(event.target.checked)
}
/>
</FieldRow>
<FieldRow>
<InputField
id="debugTileLayout"
@@ -681,21 +665,11 @@ function transportText(transport: UnstableLivekitTransport | null): string {
const TransportBox: FC<TransportInfo> = ({ transport, local }) => {
const { t } = useTranslation();
const resolved = useBehavior(transport.resolved$);
const fullMesh = transport.type === FULL_MESH_TRANSPORT_TYPE;
return (
<div className={styles.livekit_room_box}>
<h4>
{fullMesh
? t("developer_mode.full_mesh_transport", {
device: transport.raw.device_id,
})
: t("developer_mode.livekit_sfu", { url: transport.id })}
</h4>
<h4>{t("developer_mode.livekit_sfu", { url: transport.id })}</h4>
<p>
{fullMesh
? t("developer_mode.full_mesh_peer_connections")
: t("developer_mode.livekit_server_info")}
({local ? "local" : "remote"})
{t("developer_mode.livekit_server_info")}({local ? "local" : "remote"})
</p>
<pre className={styles.pre}>
{resolved === undefined
@@ -51,43 +51,6 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<input
aria-describedby="_r_2_"
id="fullMeshPublishing"
type="checkbox"
/>
<label
for="fullMeshPublishing"
>
<div
class="_checkbox_1bd8c0"
>
<svg
fill="none"
height="24"
stroke="#000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20 6-11 11-5-5"
/>
</svg>
</div>
Publish on full mesh (MSCXXXX) instead of LiveKit
</label>
</div>
</div>
<div
class="_fieldRow_1bd8c0"
>
<div
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_3_"
id="debugTileLayout"
type="checkbox"
/>
@@ -124,7 +87,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_4_"
aria-describedby="_r_3_"
id="showConnectionStats"
type="checkbox"
/>
@@ -161,7 +124,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_5_"
aria-describedby="_r_4_"
id="muteAllAudio"
type="checkbox"
/>
@@ -199,7 +162,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_6_"
aria-describedby="_r_5_"
id="alwaysShowIphoneEarpiece"
type="checkbox"
/>
@@ -236,7 +199,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_7_"
aria-describedby="_r_6_"
id="enableLivekitExtendedLogs"
type="checkbox"
/>
@@ -274,7 +237,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_8_"
for="radix-_r_7_"
>
Custom transport JSON
</label>
@@ -282,9 +245,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_controls_17lij_8"
>
<input
aria-describedby="radix-_r_9_"
aria-describedby="radix-_r_8_"
class="_control_d83jn_10"
id="radix-_r_8_"
id="radix-_r_7_"
name="input"
title=""
value=""
@@ -292,7 +255,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_9_"
id="radix-_r_8_"
>
Currently, no overwrite is set. Url from config is used.
</span>
@@ -322,10 +285,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_b_ radix-_r_d_"
aria-describedby="radix-_r_a_ radix-_r_c_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_a_"
id="radix-_r_9_"
name="_r_0_"
title=""
type="radio"
@@ -341,13 +304,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_a_"
for="radix-_r_9_"
>
Compatibility: state events & multi SFU
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_b_"
id="radix-_r_a_"
>
Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)
</span>
@@ -363,9 +326,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_b_ radix-_r_d_"
aria-describedby="radix-_r_a_ radix-_r_c_"
class="_input_1ug7n_18"
id="radix-_r_c_"
id="radix-_r_b_"
name="_r_0_"
title=""
type="radio"
@@ -381,13 +344,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_c_"
for="radix-_r_b_"
>
Matrix 2.0: sticky events & multi SFU
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_d_"
id="radix-_r_c_"
>
Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later
</span>
@@ -452,7 +415,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_j_"
aria-describedby="_r_i_"
id="cameraToggle"
type="checkbox"
/>
@@ -482,7 +445,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</label>
<p
class="_description_1bd8c0"
id="_r_j_"
id="_r_i_"
>
Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.
</p>
@@ -504,7 +467,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_k_"
aria-describedby="_r_j_"
id="screenShareToggle"
type="checkbox"
/>
@@ -534,7 +497,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</label>
<p
class="_description_1bd8c0"
id="_r_k_"
id="_r_j_"
>
Configure resolution, framerate, bitrate, and codec for screen sharing
</p>
@@ -559,7 +522,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_l_"
aria-describedby="_r_k_"
checked=""
id="echoCancellation"
type="checkbox"
@@ -597,7 +560,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_m_"
aria-describedby="_r_l_"
checked=""
id="noiseSuppression"
type="checkbox"
@@ -635,7 +598,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_n_"
aria-describedby="_r_m_"
checked=""
id="autoGainControl"
type="checkbox"
-9
View File
@@ -159,15 +159,6 @@ export const customTransport = new Setting<UnstableLivekitTransport | null>(
null,
);
/**
* Publish on the full mesh transport (MSCXXXX) instead of LiveKit. Members on
* either transport are received whatever this says.
*/
export const fullMeshPublishing = new Setting<boolean>(
"full-mesh-publishing",
false,
);
export type VideoCodec = "vp8" | "vp9" | "h264" | "av1";
/**
+10 -10
View File
@@ -155,7 +155,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "grid":
return combineLatest(
[
l.spotlight?.media$ ?? constant(undefined),
l.spotlight?.vm.media$ ?? constant(undefined),
...l.grid.map((vm) => vm.media$),
],
(spotlight, ...grid) => ({
@@ -167,7 +167,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "spotlight-landscape":
case "spotlight-portrait":
return combineLatest(
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
[l.spotlight.vm.media$, ...l.grid.map((vm) => vm.media$)],
(spotlight, ...grid) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -176,7 +176,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
case "spotlight-expanded":
return combineLatest(
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
[l.spotlight.media$, l.pip?.vm.media$ ?? constant(undefined)],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -185,7 +185,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
case "one-on-one-desktop":
return combineLatest(
[l.spotlight.media$, l.pip.media$],
[l.spotlight.media$, l.pip.vm.media$],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.id,
@@ -196,8 +196,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
return combineLatest(
[
l.spotlight.media$,
l.pip?.media$ ?? constant(undefined),
l.pipSize$,
l.pip?.vm.media$ ?? constant(undefined),
l.pip?.size$ ?? constant(undefined),
],
(spotlight, pip, pipSize) => ({
type: l.type,
@@ -506,7 +506,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
b: {
type: "one-on-one-mobile",
@@ -544,7 +544,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
});
// It wouldn't be clear whether Alice or Bob is the remote video tile,
@@ -592,7 +592,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "sm",
pipSize: undefined,
},
b: {
type: "spotlight-landscape",
@@ -791,7 +791,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
b: {
// In a larger window, expect the one-on-one desktop layout
+32 -32
View File
@@ -78,14 +78,13 @@ import {
type ReactionInfo,
type ReactionOption,
} from "../../reactions";
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
import { shallowEquals as shallowObjectEquals } from "../../utils/object";
import { shallowEquals as arrayShallowEquals } from "../../utils/array";
import { type MediaDevice, type MediaDevices } from "../MediaDevices";
import { type Handler, type MuteStates } from "../MuteStates";
import { HeaderStyle, type UrlParams } from "../../UrlParams";
import { type HostBridge, nullHostBridge } from "../../HostBridge";
import {
type Alignment,
layoutShallowEquals,
type Layout,
type LayoutMedia,
type WindowMode,
@@ -126,6 +125,7 @@ import {
type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
import { createFloatingTile } from "../FloatingTile.ts";
/** How a call captures and encodes the tracks it publishes. */
export interface CaptureSettings {
@@ -830,7 +830,7 @@ export function createCallViewModel$(
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
);
}),
distinctUntilChanged(shallowArrayEquals),
distinctUntilChanged(arrayShallowEquals),
),
);
@@ -862,13 +862,18 @@ export function createCallViewModel$(
[ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
(ringing, screenShares, speaker, localPip) =>
computeSpotlight({ ringing, screenShares, speaker, localPip }),
).pipe(distinctUntilChanged(shallowObjectEquals)),
).pipe(
distinctUntilChanged(
(a, b) =>
arrayShallowEquals(a.spotlight, b.spotlight) && a.pip === b.pip,
),
),
);
const spotlight$ = scope.behavior<MediaViewModel[]>(
spotlightAndPip$.pipe(
map(({ spotlight }) => spotlight),
distinctUntilChanged<MediaViewModel[]>(shallowArrayEquals),
distinctUntilChanged<MediaViewModel[]>(arrayShallowEquals),
),
);
@@ -1029,7 +1034,7 @@ export function createCallViewModel$(
desktop: platform === "desktop",
}),
),
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
distinctUntilChanged(layoutShallowEquals),
),
);
@@ -1221,32 +1226,28 @@ export function createCallViewModel$(
);
/**
* The alignment of the floating spotlight tile, if present.
* State of the floating spotlight tile, if present.
*/
const spotlightAlignment$ = new BehaviorSubject<Alignment>({
const floatingSpotlightTile = createFloatingTile(scope, {
inline: "end",
block: "end",
});
/**
* The size of the small picture-in-picture tile, if present, when in portrait.
* State of the small picture-in-picture tile, if present, when in landscape.
*/
const portraitPipSize$ = scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
);
/**
* The alignment of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
/**
* The alignment of the small picture-in-picture tile, if present, when in landscape.
*/
const landscapePipAlignment$ = new BehaviorSubject<Alignment>({
const landscapePipTile = createFloatingTile(scope, {
inline: "end",
block: "start",
});
/**
* State of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipTile = {
...createFloatingTile(scope, { inline: "end", block: "end" }),
size$: scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
),
};
// There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to
@@ -1276,7 +1277,7 @@ export function createCallViewModel$(
case "spotlight-portrait":
[layout, newTiles] = gridLikeLayout(
media,
spotlightAlignment$,
floatingSpotlightTile,
visibleTiles,
setVisibleTiles,
prevTiles,
@@ -1286,26 +1287,25 @@ export function createCallViewModel$(
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(
media,
landscapePipAlignment$,
landscapePipTile,
prevTiles,
);
break;
case "one-on-one-desktop":
[layout, newTiles] = oneOnOneDesktopLayout(
media,
landscapePipAlignment$,
landscapePipTile,
prevTiles,
);
pip = layout.pip;
pip = layout.pip.vm;
break;
case "one-on-one-mobile":
[layout, newTiles] = oneOnOneMobileLayout(
media,
portraitPipSize$,
portraitPipAlignment$,
portraitPipTile,
prevTiles,
);
pip = layout.pip;
pip = layout.pip?.vm;
break;
case "pip":
[layout, newTiles] = pipLayout(media, prevTiles);
@@ -1330,7 +1330,7 @@ export function createCallViewModel$(
layoutInternals$.pipe(
map(({ layout }) => layout),
// Drop redundant layout updates before they would hit React.
distinctUntilChanged(shallowObjectEquals<Layout>),
distinctUntilChanged(layoutShallowEquals),
),
);
+38
View File
@@ -0,0 +1,38 @@
/*
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 { test } from "vitest";
import { withTestScheduler } from "../utils/test";
import { createFloatingTile } from "./FloatingTile";
test("FloatingTile changes alignment on drag", () =>
withTestScheduler(({ scope, expectObservable, schedule }) => {
// Tile starts in the top left
const { alignment$, onDrag } = createFloatingTile(scope, {
inline: "start",
block: "start",
});
// Drag to all four corners, and then repeatedly into the top left.
schedule("-↗↘↙↖↖", {
"↗": () =>
onDrag({ x: 100, y: 0, xRatio: 1, yRatio: 0, endOfGesture: false }),
"↘": () =>
onDrag({ x: 100, y: 100, xRatio: 1, yRatio: 1, endOfGesture: false }),
"↙": () =>
onDrag({ x: 0, y: 100, xRatio: 0, yRatio: 1, endOfGesture: false }),
"↖": () =>
onDrag({ x: 0, y: 0, xRatio: 0, yRatio: 0, endOfGesture: false }),
});
// Alignment should change accordingly. Notably, the alignment should *not*
// update when repeatedly dragged into the same corner, for performance.
expectObservable(alignment$).toBe("↖↗↘↙↖-", {
"↗": { inline: "end", block: "start" },
"↘": { inline: "end", block: "end" },
"↙": { inline: "start", block: "end" },
"↖": { inline: "start", block: "start" },
});
}));
+43
View File
@@ -0,0 +1,43 @@
/*
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 { distinctUntilChanged, map, startWith, Subject } from "rxjs";
import { type Behavior } from "./Behavior";
import { type DragCallback, type Alignment, type Drag } from "./layout-types";
import { type ObservableScope } from "./ObservableScope";
/**
* Represents a floating tile which may be dragged to different corners of the
* call interface.
*/
export interface FloatingTile {
alignment$: Behavior<Alignment>;
onDrag: DragCallback;
}
export function createFloatingTile(
scope: ObservableScope,
defaultAlignment: Alignment,
): FloatingTile {
const dragEvents$ = new Subject<Drag>();
return {
alignment$: scope.behavior(
dragEvents$.pipe(
map<Drag, Alignment>(({ xRatio, yRatio }) => ({
inline: xRatio < 0.5 ? "start" : "end",
block: yRatio < 0.5 ? "start" : "end",
})),
startWith(defaultAlignment),
distinctUntilChanged(
(a1, a2) => a1.inline === a2.inline && a1.block === a2.block,
),
),
),
onDrag: (drag) => dragEvents$.next(drag),
};
}
+7 -10
View File
@@ -5,14 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type Layout,
type LayoutMedia,
} from "./layout-types.ts";
import { type Layout, type LayoutMedia } from "./layout-types.ts";
import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile.ts";
export type GridLikeLayoutType =
| "grid"
@@ -25,7 +20,7 @@ export type GridLikeLayoutType =
*/
export function gridLikeLayout(
media: LayoutMedia & { type: GridLikeLayoutType },
spotlightAlignment$: BehaviorSubject<Alignment>,
floatingSpotlightTile: FloatingTile,
visibleTiles: number,
setVisibleTiles: (value: number) => void,
prevTiles: TileStore,
@@ -42,9 +37,11 @@ export function gridLikeLayout(
return [
{
type: media.type,
spotlight: tiles.spotlightTile,
spotlight: tiles.spotlightTile && {
vm: tiles.spotlightTile,
...floatingSpotlightTile,
},
grid: tiles.gridTiles,
spotlightAlignment$,
setVisibleTiles,
} as Layout & { type: GridLikeLayoutType },
tiles,
+3 -6
View File
@@ -6,21 +6,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type OneOnOneDesktopLayout,
type OneOnOneDesktopLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile";
/**
* Produces a one-on-one desktop layout with the given media.
*/
export function oneOnOneDesktopLayout(
media: OneOnOneDesktopLayoutMedia,
pipAlignment$: BehaviorSubject<Alignment>,
pipTile: FloatingTile,
prevTiles: TileStore,
): [OneOnOneDesktopLayout, TileStore] {
const update = prevTiles.from(2);
@@ -32,8 +30,7 @@ export function oneOnOneDesktopLayout(
{
type: media.type,
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
pip: tiles.gridTilesByMedia.get(media.pip)!,
pipAlignment$,
pip: { vm: tiles.gridTilesByMedia.get(media.pip)!, ...pipTile },
},
tiles,
];
+4 -8
View File
@@ -6,23 +6,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type OneOnOneMobileLayout,
type OneOnOneMobileLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore";
import { type Behavior } from "./Behavior";
import { type FloatingTile } from "./FloatingTile";
/**
* Produces a one-on-one mobile layout with the given media.
*/
export function oneOnOneMobileLayout(
media: OneOnOneMobileLayoutMedia,
pipSize$: Behavior<"sm" | "lg">,
pipAlignment$: BehaviorSubject<Alignment>,
pipTile: FloatingTile & { size$: Behavior<"sm" | "lg"> },
prevTiles: TileStore,
): [OneOnOneMobileLayout, TileStore] {
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
@@ -30,13 +27,12 @@ export function oneOnOneMobileLayout(
if (media.pip !== undefined) update.registerGridTile(media.pip);
const tiles = update.build();
const pipVm = media.pip && tiles.gridTilesByMedia.get(media.pip);
return [
{
type: media.type,
spotlight: tiles.spotlightTile!,
pip: media.pip && tiles.gridTilesByMedia.get(media.pip),
pipSize$,
pipAlignment$,
pip: pipVm && { vm: pipVm, ...pipTile },
},
tiles,
];
+4 -6
View File
@@ -5,21 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type SpotlightExpandedLayout,
type SpotlightExpandedLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile";
/**
* Produces an expanded spotlight layout with the given media.
*/
export function spotlightExpandedLayout(
media: SpotlightExpandedLayoutMedia,
pipAlignment$: BehaviorSubject<Alignment>,
pipTile: FloatingTile,
prevTiles: TileStore,
): [SpotlightExpandedLayout, TileStore] {
const update = prevTiles.from(1);
@@ -27,12 +25,12 @@ export function spotlightExpandedLayout(
if (media.pip !== undefined) update.registerPipTile(media.pip);
const tiles = update.build();
const pipVm = tiles.gridTiles.at(0);
return [
{
type: media.type,
spotlight: tiles.spotlightTile!,
pip: tiles.gridTiles.at(0),
pipAlignment$,
pip: pipVm && { vm: pipVm, ...pipTile },
},
tiles,
];
+82
View File
@@ -0,0 +1,82 @@
/*
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 { test, expect } from "vitest";
import { layoutShallowEquals, type Layout } from "./layout-types";
import {
type SpotlightTileViewModel,
type GridTileViewModel,
} from "./TileViewModel";
import { type FloatingTile } from "./FloatingTile";
const spotlightTile = {} as unknown as SpotlightTileViewModel;
const gridTile = {} as unknown as GridTileViewModel;
const pipTile = {} as unknown as FloatingTile & { vm: GridTileViewModel };
const spotlightExpanded: Layout = {
type: "spotlight-expanded",
spotlight: spotlightTile,
};
const spotlightPortrait: Layout = {
type: "spotlight-portrait",
spotlight: { vm: spotlightTile },
grid: [gridTile],
setVisibleTiles: () => {},
};
test("layoutShallowEquals considers a layout to be equal to its shallow clone", () =>
expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe(
true,
));
test("layoutShallowEquals detects a missing key", () => {
expect(
layoutShallowEquals(spotlightExpanded, {
...spotlightExpanded,
pip: pipTile,
}),
).toBe(false);
expect(
layoutShallowEquals(
{ ...spotlightExpanded, pip: pipTile },
spotlightExpanded,
),
).toBe(false);
});
test("layoutShallowEquals considers tile wrappers with equal contents to be equal", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
spotlight: { vm: spotlightTile },
}),
).toBe(true));
test("layoutShallowEquals detects tile wrappers with different contents", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
spotlight: { vm: {} as unknown as SpotlightTileViewModel },
}),
).toBe(false));
test("layoutShallowEquals considers grid arrays with equal contents to be equal", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
grid: [...spotlightPortrait.grid],
}),
).toBe(true));
test("layoutShallowEquals detects grid arrays with different contents", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
grid: [...spotlightPortrait.grid, gridTile],
}),
).toBe(false));
+65 -13
View File
@@ -5,8 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
import { type MediaViewModel } from "./media/MediaViewModel.ts";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
@@ -16,6 +14,9 @@ import {
type SpotlightTileViewModel,
} from "./TileViewModel.ts";
import { type Behavior } from "./Behavior.ts";
import { type FloatingTile } from "./FloatingTile.ts";
import { shallowEquals } from "../utils/object.ts";
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
/**
* The general shape of the space the call is drawn in. Called a window because
@@ -86,24 +87,48 @@ export interface Alignment {
block: "start" | "end";
}
export interface Drag {
/**
* The X offset of the dragged tile relative to its container.
*/
x: number;
/**
* The Y offset of the dragged tile relative to its container.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the container's width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the container's height.
*/
yRatio: number;
/**
* Whether this event ends the drag gesture.
*/
endOfGesture: boolean;
}
export type DragCallback = (drag: Drag) => void;
export interface GridLayout {
type: "grid";
spotlight?: SpotlightTileViewModel;
spotlight?: FloatingTile & { vm: SpotlightTileViewModel };
grid: GridTileViewModel[];
spotlightAlignment$: BehaviorSubject<Alignment>;
setVisibleTiles: (value: number) => void;
}
export interface SpotlightLandscapeLayout {
type: "spotlight-landscape";
spotlight: SpotlightTileViewModel;
spotlight: { vm: SpotlightTileViewModel };
grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
}
export interface SpotlightPortraitLayout {
type: "spotlight-portrait";
spotlight: SpotlightTileViewModel;
spotlight: { vm: SpotlightTileViewModel };
grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
}
@@ -111,23 +136,19 @@ export interface SpotlightPortraitLayout {
export interface SpotlightExpandedLayout {
type: "spotlight-expanded";
spotlight: SpotlightTileViewModel;
pip?: GridTileViewModel;
pipAlignment$: BehaviorSubject<Alignment>;
pip?: FloatingTile & { vm: GridTileViewModel };
}
export interface OneOnOneDesktopLayout {
type: "one-on-one-desktop";
spotlight: GridTileViewModel;
pip: GridTileViewModel;
pipAlignment$: BehaviorSubject<Alignment>;
pip: FloatingTile & { vm: GridTileViewModel };
}
export interface OneOnOneMobileLayout {
type: "one-on-one-mobile";
spotlight: SpotlightTileViewModel;
pip?: GridTileViewModel;
pipSize$: Behavior<"sm" | "lg">;
pipAlignment$: BehaviorSubject<Alignment>;
pip?: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
}
export interface PipLayout {
@@ -147,3 +168,34 @@ export type Layout =
| OneOnOneDesktopLayout
| OneOnOneMobileLayout
| PipLayout;
/**
* Tests whether the top-level properties, tile wrapper properties, and array
* elements of layout `a` are equal to those of layout `b`. Useful for deduping
* redundant layout updates.
*/
export function layoutShallowEquals<T extends Layout | LayoutMedia>(
a: T,
b: T,
): boolean {
return shallowEquals<T>(a, b, (aValue, bValue) => {
// Special case for tile wrapper objects so we can detect when the
// spotlight or PiP tile is essentially the same
if (
typeof aValue === "object" &&
aValue !== null &&
"vm" in aValue &&
typeof bValue === "object" &&
bValue !== null &&
"vm" in bValue
)
return shallowEquals(aValue, bValue);
// Special case for arrays so we can detect when the grid tile arrays are
// essentially the same
if (Array.isArray(aValue) && Array.isArray(bValue))
return arrayShallowEquals(aValue, bValue);
return aValue === bValue;
});
}
+24 -11
View File
@@ -235,22 +235,35 @@ test("audio and video follow the tracks' mute state", () => {
test("user media polls stream stats only while the setting is on", () => {
onTestFinished(() => showConnectionStats.setValue(false));
withTestScheduler(({ cold, expectObservable, schedule }) => {
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
const vm = mockRemoteMedia(rtcMembership, {}, [
mockAudioTrack({
stats$: () => cold("-s", { s: stats }),
}),
mockAudioTrack({ stats$: () => cold("-s", { s: { ...stats } }) }),
]);
schedule("-a-b", {
a() {
showConnectionStats.setValue(true);
},
b() {
showConnectionStats.setValue(false);
},
schedule("-t-f-t", {
t: () => showConnectionStats.setValue(true),
f: () => showConnectionStats.setValue(false),
});
expectObservable(vm.audioStreamStats$).toBe("u-su", {
expectObservable(vm.audioStreamStats$).toBe("u-s---s", {
u: undefined,
s: stats,
});
});
});
test("stream stats can be enabled before creating media", () => {
showConnectionStats.setValue(true);
onTestFinished(() => showConnectionStats.setValue(false));
withTestScheduler(({ cold, expectObservable }) => {
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
const vm = mockRemoteMedia(rtcMembership, {}, [
mockAudioTrack({ stats$: () => cold("-s", { s: stats }) }),
]);
expectObservable(vm.audioStreamStats$).toBe("us", {
u: undefined,
s: stats,
});
+4 -3
View File
@@ -16,7 +16,9 @@ import {
BehaviorSubject,
combineLatest,
map,
NEVER,
of,
startWith,
Subject,
switchMap,
} from "rxjs";
@@ -105,10 +107,9 @@ export function createBaseUserMedia(
showConnectionStats.value$,
]).pipe(
switchMap(([track, show]) =>
track !== undefined && show
? track.stats$(statsIntervalMs)
: of(undefined),
track !== undefined && show ? track.stats$(statsIntervalMs) : NEVER,
),
startWith<MediaStreamStats>(undefined),
),
);
+19 -4
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import { test, expect } from "vitest";
import { shallowEquals } from "./object";
import { shallowEquals as arrayShallowEquals } from "./array";
interface Thing {
type: string;
@@ -29,12 +30,26 @@ test("shallowEquals detects a missing key", () => {
test("shallowEquals detects a differing value", () =>
expect(shallowEquals(withArray, { ...withArray, value: 2 })).toBe(false));
test("shallowEquals considers arrays with equal contents to be equal", () =>
const comparator = (aValue: unknown, bValue: unknown): boolean => {
if (Array.isArray(aValue) && Array.isArray(bValue))
return arrayShallowEquals(aValue, bValue);
return aValue === bValue;
};
test("shallowEquals with comparator considers arrays with equal contents to be equal", () =>
expect(
shallowEquals(withArray, { ...withArray, items: [...withArray.items] }),
shallowEquals(
withArray,
{ ...withArray, items: [...withArray.items] },
comparator,
),
).toBe(true));
test("shallowEquals detects arrays with different contents", () =>
test("shallowEquals with comparator detects arrays with different contents", () =>
expect(
shallowEquals(withArray, { ...withArray, items: [...withArray.items, {}] }),
shallowEquals(
withArray,
{ ...withArray, items: [...withArray.items, {}] },
comparator,
),
).toBe(false));
+6 -8
View File
@@ -5,17 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { shallowEquals as arrayShallowEquals } from "./array.ts";
/**
* Determine whether two objects are equal by shallow comparison of their
* top-level properties. Array-valued properties are themselves compared
* shallowly, so two objects holding distinct arrays with the same elements are
* considered equal.
* top-level properties, using the given `comparator`.
*/
export function shallowEquals<T extends object>(
a: Readonly<T>,
b: Readonly<T>,
comparator: (aValue: unknown, bValue: unknown) => boolean = (
aValue,
bValue,
) => aValue === bValue,
): boolean {
// If a and b have the same number of keys and every key in a is also in b,
// then they have the same keys.
@@ -29,9 +29,7 @@ export function shallowEquals<T extends object>(
// Now check that they have the same values.
const aValue = (a as any)[key];
const bValue = (b as any)[key];
if (Array.isArray(aValue) && Array.isArray(bValue)) {
if (!arrayShallowEquals(aValue, bValue)) return false;
} else if (aValue !== bValue) return false;
if (!comparator(aValue, bValue)) return false;
}
return true;
+1 -1
View File
@@ -109,7 +109,7 @@ describe("widget", () => {
receiveState,
sendToDevice: sendRecvToDevice,
receiveToDevice: sendRecvToDevice,
turnServers: true,
turnServers: false,
rtcTransports: true,
rtcLivekitGetToken: true,
rtcLivekitDelegateDelayedLeave: true,
+2 -11
View File
@@ -21,12 +21,8 @@ import type { IWidgetApiRequest } from "matrix-widget-api";
import { LazyEventEmitter } from "./LazyEventEmitter";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import {
fullMeshPublishing,
seedSettingsFromConfig,
} from "./settings/settings";
import { seedSettingsFromConfig } from "./settings/settings";
import { ElementCallReactionEventType } from "./reactions";
import { fullMeshSignallingEventTypes } from "@element-hq/matrixrtc-sdk";
// Subset of the actions in element-web
export enum ElementWidgetActions {
@@ -166,10 +162,6 @@ export const initializeWidget = (
EventType.CallSDPStreamMetadataChangedPrefix,
EventType.CallReplaces,
EventType.CallEncryptionKeysPrefix,
// Full mesh signalling (MSCXXXX), only where it is turned on: Element
// Web doesn't auto-approve these for Element Call yet, and would ask
// the user on every call
...(fullMeshPublishing.getValue() ? fullMeshSignallingEventTypes : []),
];
const client = createRoomWidgetClient(
@@ -181,8 +173,7 @@ export const initializeWidget = (
receiveState,
sendToDevice: sendRecvToDevice,
receiveToDevice: sendRecvToDevice,
// Full mesh connects through the homeserver's TURN server
turnServers: true,
turnServers: false,
rtcTransports: true,
rtcLivekitGetToken: true,
rtcLivekitDelegateDelayedLeave: true,