Compare commits

...
Author SHA1 Message Date
Johannes Marbach 08f58cce35 Key connections on rooms 2026-10-27 17:59:27 +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 ed6a0e1341 Merge pull request #4371 from element-hq/ellipsize-title
Ellipsise app bar text on mobile
2026-10-26 13:45:06 +01:00
renovate[bot]andJohannes Marbach 90ccf62d5c Update ghcr.io/element-hq/element-web:develop Docker digest to c9ffe3e (#4356)
* Update ghcr.io/element-hq/element-web:develop Docker digest to 79e47d8

* Adapt to new recovery popup button title

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-10-26 12:44:39 +00:00
Robin dde07f6d54 Ellipsise app bar text on mobile
We already had text-overflow: ellipsis set on the title and subtitle elements of the app bar, but due to an annoying quirk of place-items: center, this had no effect. place-items: center stretch seems to give the text elements the right size constraints.
2026-10-26 13:25:13 +01:00
13 changed files with 729 additions and 18 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:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
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:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
+6 -2
View File
@@ -159,7 +159,7 @@ export class TestHelpers {
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Dismiss" },
{ title: "Back up your chats", button: "Not now" },
{ title: "Turn on key storage", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
@@ -178,7 +178,11 @@ export class TestHelpers {
);
if (toastConfig) {
await toast.getByRole("button", { name: toastConfig.button }).click();
// Bounded so that a renamed button leaves the toast in place and the
// next step reports it, instead of hanging until the test timeout.
await toast
.getByRole("button", { name: toastConfig.button })
.click({ timeout: 5000 });
} else {
// Unknown toast. We don't want to act on unknown toasts
break;
@@ -44,6 +44,7 @@ import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
import { MatrixRTCError } from "../../errors";
import { testJWTToken } from "../../utils/test-fixtures";
import { ownMemberMock } from "../../utils/test";
import { LivekitRooms, livekitRoomKey } from "./LivekitRooms";
import { mockRemoteParticipant } from "./test";
let testScope: ObservableScope;
@@ -74,6 +75,7 @@ function connectionOpts(): ConnectionOpts {
serverName: "m.localhost",
scope: testScope,
ownMembershipIdentity: ownMemberMock,
rooms: new LivekitRooms(logger),
livekitRoomFactory: () => fakeLivekitRoom,
};
}
@@ -711,3 +713,161 @@ describe("remote track logging", () => {
// });
// });
// });
describe("LiveKit rooms", () => {
const roomKey = livekitRoomKey(transport.url!, "!example_room_id");
const otherConnection = (): Connection =>
({ role: "subscriber", stop: vi.fn() }) as unknown as Connection;
it("waits for the room its token is for, and connects with a fresh token once it holds it", async () => {
setupTest();
const rooms = new LivekitRooms(logger);
const holder = otherConnection();
await rooms.claim(holder, roomKey);
fakeLivekitRoom.connect.mockResolvedValue(undefined);
const connection = new Connection({ ...connectionOpts(), rooms }, logger);
const started = connection.start();
await vi.waitFor(() =>
expect(rooms.holders$.value.get(connection)).toBe(holder),
);
expect(fakeLivekitRoom.connect).not.toHaveBeenCalled();
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(1);
rooms.release(holder);
await started;
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(2);
expect(fakeLivekitRoom.connect).toHaveBeenCalledWith(
transport.url,
testJWTToken,
);
expect(rooms.holders$.value.get(connection)).toBe(connection);
});
it("connects at once with the token it has where the room is free", async () => {
setupTest();
const rooms = new LivekitRooms(logger);
fakeLivekitRoom.connect.mockResolvedValue(undefined);
const connection = new Connection({ ...connectionOpts(), rooms }, logger);
await connection.start();
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(1);
expect(fakeLivekitRoom.connect).toHaveBeenCalled();
});
it("gives the room up when stopped, without connecting", async () => {
setupTest();
const rooms = new LivekitRooms(logger);
const holder = otherConnection();
await rooms.claim(holder, roomKey);
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
const connection = new Connection({ ...connectionOpts(), rooms }, logger);
const started = connection.start();
await vi.waitFor(() =>
expect(rooms.holders$.value.get(connection)).toBe(holder),
);
await connection.stop();
await started;
expect(fakeLivekitRoom.connect).not.toHaveBeenCalled();
expect(connection.state$.value).toEqual({ state: "stopped" });
rooms.release(holder);
expect(rooms.holders$.value.get(connection)).toBeUndefined();
});
it("hands the room on only once it has left the SFU", async () => {
setupTest();
const rooms = new LivekitRooms(logger);
fakeLivekitRoom.connect.mockResolvedValue(undefined);
const disconnected = Promise.withResolvers<void>();
fakeLivekitRoom.disconnect.mockReturnValue(disconnected.promise);
const connection = new Connection({ ...connectionOpts(), rooms }, logger);
await connection.start();
const waiter = otherConnection();
const claim = rooms.claim(waiter, roomKey);
const stopping = connection.stop();
await Promise.resolve();
expect(rooms.holderOf(roomKey)).toBe(connection);
disconnected.resolve();
await stopping;
await claim;
expect(rooms.holderOf(roomKey)).toBe(waiter);
});
it("gives the room up when the connection fails", async () => {
setupTest();
const rooms = new LivekitRooms(logger);
fakeLivekitRoom.connect.mockRejectedValue(new Error("no"));
const connection = new Connection({ ...connectionOpts(), rooms }, logger);
await expect(connection.start()).rejects.toThrow();
expect(rooms.holders$.value.size).toBe(0);
});
it("connects once the connection holding its room stops", async () => {
const rooms = new LivekitRooms(logger);
setupTest();
const holderRoom = fakeLivekitRoom;
holderRoom.connect.mockResolvedValue(undefined);
holderRoom.disconnect.mockResolvedValue(undefined);
const holder = new Connection(
{ ...connectionOpts(), rooms, livekitRoomFactory: () => holderRoom },
logger,
);
await holder.start();
setupTest();
const waiterRoom = fakeLivekitRoom;
waiterRoom.connect.mockResolvedValue(undefined);
const waiter = new Connection(
{ ...connectionOpts(), rooms, livekitRoomFactory: () => waiterRoom },
logger,
);
const started = waiter.start();
await vi.waitFor(() =>
expect(rooms.holders$.value.get(waiter)).toBe(holder),
);
expect(waiterRoom.connect).not.toHaveBeenCalled();
await holder.stop();
await started;
expect(waiterRoom.connect).toHaveBeenCalled();
expect(rooms.holderOf(roomKey)).toBe(waiter);
});
it("lets a publisher take the room over, stopping the subscriber on it", async () => {
const rooms = new LivekitRooms(logger);
setupTest();
const subscriberRoom = fakeLivekitRoom;
subscriberRoom.connect.mockResolvedValue(undefined);
subscriberRoom.disconnect.mockResolvedValue(undefined);
const subscriber = new Connection(
{ ...connectionOpts(), rooms, livekitRoomFactory: () => subscriberRoom },
logger,
);
await subscriber.start();
setupTest();
const publisherRoom = fakeLivekitRoom;
publisherRoom.connect.mockResolvedValue(undefined);
const publisher = new Connection(
{
...connectionOpts(),
role: "publisher",
rooms,
livekitRoomFactory: () => publisherRoom,
},
logger,
);
await publisher.start();
expect(publisherRoom.connect).toHaveBeenCalled();
expect(subscriberRoom.disconnect).toHaveBeenCalled();
await vi.waitFor(() =>
expect(subscriber.state$.value).toEqual({ state: "stopped" }),
);
expect(rooms.holders$.value.get(subscriber)).toBe(publisher);
expect(rooms.holders$.value.get(publisher)).toBe(publisher);
});
});
+24 -2
View File
@@ -45,6 +45,7 @@ import {
SFURoomCreationRestrictedError,
UnknownRTCError,
} from "../../errors";
import { type LivekitRooms, livekitRoomKey } from "./LivekitRooms";
export interface ConnectionOpts {
/** The identity parts to use on this connection */
@@ -63,6 +64,8 @@ export interface ConnectionOpts {
slotId: string;
/** The observable scope to use for this connection. */
scope: ObservableScope;
/** The room / connection mapping. */
rooms: LivekitRooms;
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
livekitRoomFactory: () => LivekitRoom;
@@ -137,6 +140,7 @@ export class Connection {
private readonly slotId: string;
private readonly logger: Logger;
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
private readonly rooms: LivekitRooms;
/**
* Creates a new connection to a matrix RTC LiveKit backend.
*
@@ -160,6 +164,7 @@ export class Connection {
this.transport = transport;
this.serverName = serverName;
this.client = client;
this.rooms = opts.rooms;
this.remoteParticipants$ = scope.behavior(
// Only tracks remote participants
@@ -287,7 +292,9 @@ export class Connection {
* This will:
* 1. Authenticate with the transport for our role, through the homeserver
* or the JWT service.
* 2. Connect to the LiveKit room the token is for.
* 2. Claim the LiveKit room the token is for, waiting while another
* connection holds it.
* 3. Connect to the room.
*
* The errors are also represented as a state in the `state$` observable.
* It is safe to ignore those errors and handle them accordingly via the `state$` observable.
@@ -300,7 +307,7 @@ export class Connection {
this.stopped = false;
try {
this._state$.next({ state: "authenticating" });
const { url, jwt, livekitAlias } = await this.getSFUConfig();
let { url, jwt, livekitAlias } = await this.getSFUConfig();
this.logger.debug(
"Starting Connection - jwt: ",
jwt,
@@ -313,6 +320,18 @@ export class Connection {
// If we were stopped while fetching the config, don't proceed to connect
if (this.stopped) return;
// Try to claim the room.
const waited = await this.rooms.claim(
this,
livekitRoomKey(url, livekitAlias),
);
if (this.stopped) return;
if (waited) {
// Get a new token since it may have expired in the meantime.
({ url, jwt, livekitAlias } = await this.getSFUConfig());
if (this.stopped) return;
}
// Setup observer once we are done with getSFUConfig
connectionStateObserver(this.livekitRoom)
.pipe(this.scope.bind())
@@ -368,6 +387,7 @@ export class Connection {
return;
}
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
this.rooms.release(this);
this._state$.next({
state: "error",
error:
@@ -408,6 +428,8 @@ export class Connection {
// disconnect sees the flag and does not report the abort as an error.
this.stopped = true;
await this.livekitRoom.disconnect();
// Release the room after disconnecting so that the next holder never meets us on the SFU.
this.rooms.release(this);
this._state$.next({ state: "stopped" });
this.logger.debug("stop: DONE disconnecting from lk room");
}
@@ -23,6 +23,7 @@ import {
LivekitConnectionFactory,
type ResolvedConnection,
} from "./ConnectionFactory";
import { LivekitRooms } from "./LivekitRooms";
import { exampleTransport } from "./test";
const urlTransport: UnstableLivekitTransport = {
@@ -56,6 +57,7 @@ function create(
constant(audioOutputDeviceId),
undefined,
mediaQuality,
new LivekitRooms(logger),
);
const connection = factory.createConnection(
testScope(),
@@ -31,6 +31,7 @@ import {
} from "./auth";
import { Connection, type ConnectionOpts } from "./Connection";
import { buildLiveKitOptions } from "./livekitOptions";
import { type LivekitRooms } from "./LivekitRooms";
import {
audioCaptureOptions,
type PublishRequestFor,
@@ -108,6 +109,7 @@ export class LivekitConnectionFactory implements ConnectionFactory {
private readonly audioOutputDeviceId$: Behavior<string | undefined>,
private readonly keyProvider: BaseKeyProvider | undefined,
private readonly mediaQuality: MediaQuality | undefined,
private readonly rooms: LivekitRooms,
) {}
public createConnection(
@@ -127,6 +129,7 @@ export class LivekitConnectionFactory implements ConnectionFactory {
serverName,
client: this.client,
scope,
rooms: this.rooms,
livekitRoomFactory: () =>
new LivekitRoom(
roomOptions(
@@ -22,6 +22,7 @@ import {
keyFor,
} from "./ConnectionMap.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { LivekitRooms, livekitRoomKey } from "./LivekitRooms";
import { type Connection } from "./Connection.ts";
import { ownMemberMock, withTestScheduler } from "../../utils/test";
import { type Behavior } from "../../reactive/Behavior";
@@ -101,6 +102,7 @@ describe("connections$ stream", () => {
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -138,6 +140,7 @@ describe("connections$ stream", () => {
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_2], 0),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -180,6 +183,7 @@ describe("connections$ stream", () => {
remoteTransports$: behavior("a", {
a: new Epoch([sameServiceElsewhere], 0),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -224,6 +228,7 @@ describe("connections$ stream", () => {
remoteTransports$: behavior("a", {
a: new Epoch([sameUrlElsewhere], 0),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -257,6 +262,7 @@ describe("connections$ stream", () => {
e: new Epoch([TRANSPORT_1], 4),
f: new Epoch([TRANSPORT_1, TRANSPORT_2], 5),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -297,6 +303,7 @@ describe("connections$ stream", () => {
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
c: new Epoch([TRANSPORT_1], 2),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -436,6 +443,7 @@ describe("connectionMap$", () => {
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}),
rooms: new LivekitRooms(logger),
logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -491,3 +499,58 @@ describe("connectionMap$", () => {
});
});
});
describe("one connection per LiveKit room", () => {
const room = livekitRoomKey("wss://sfu.example.org", "alias");
test("reads a transport through the connection holding its room", async () => {
const rooms = new LivekitRooms(logger);
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: new BehaviorSubject(new Epoch([TRANSPORT_2], 0)),
rooms,
logger,
ownMembershipIdentity: ownMemberMock,
});
const [conn1, conn2] = allCreatedConnections;
const participant = { identity: "a" } as RemoteParticipant;
(conn1.remoteParticipants$ as BehaviorSubject<RemoteParticipant[]>).next([
participant,
]);
expect(
connectionMap$.value.value.getParticipantsForTransport(TRANSPORT_2),
).toEqual([]);
// Both tokens name the same room: the second connection waits for it
await rooms.claim(conn1, room);
void rooms.claim(conn2, room);
const map = connectionMap$.value.value;
expect(map.getConnectionForTransport(TRANSPORT_2)).toBe(conn1);
expect(map.getParticipantsForTransport(TRANSPORT_2)).toEqual([participant]);
expect(map.getConnections()).toEqual([conn1, conn2]);
});
test("forgets a connection with its transport", async () => {
const rooms = new LivekitRooms(logger);
const remoteTransports$ = new BehaviorSubject(new Epoch([TRANSPORT_2], 0));
createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$,
rooms,
logger,
ownMembershipIdentity: ownMemberMock,
});
const [conn1, conn2] = allCreatedConnections;
await rooms.claim(conn1, room);
void rooms.claim(conn2, room);
expect(rooms.holders$.value.size).toBe(2);
remoteTransports$.next(new Epoch([], 1));
expect(rooms.holders$.value).toEqual(new Map([[conn1, conn1]]));
});
});
+39 -8
View File
@@ -21,6 +21,7 @@ import { generateItemsWithEpoch } from "../../reactive/observable";
import { type ConnectionRole, type TransportLocator } from "./auth";
import { type Connection } from "./Connection";
import { type ConnectionFactory } from "./ConnectionFactory";
import { type LivekitRooms } from "./LivekitRooms";
// Branded, so that only values returned by `keyFor` can be used as keys
export type TransportKey = string & { __transportKey: true };
@@ -57,12 +58,17 @@ function removeDuplicateTransports(
return [...map.values()];
}
/** The connections at one moment, with the remote participants seen on each. */
/**
* The connections at one moment, with the remote participants seen on each.
* A transport whose connection waits for a room another connection holds
* reads that room through the holder.
*/
export class ConnectionMap {
private readonly map = new Map<
TransportKey,
{ connection: Connection; participants: RemoteParticipant[] }
>();
private readonly holders = new Map<TransportKey, TransportKey>();
public constructor(private readonly logger?: Logger) {}
@@ -81,20 +87,33 @@ export class ConnectionMap {
}
}
/** Routes a transport's lookups through the connection holding its room. */
public alias(connection: Connection, holder: Connection): void {
this.holders.set(keyFor(connection), keyFor(holder));
}
public getConnections(): Connection[] {
return Array.from(this.map.values()).map(({ connection }) => connection);
}
/** The connection a transport's media arrives on or null while it has none. */
public getConnectionForTransport(
transport: TransportLocator,
): Connection | null {
return this.map.get(keyFor(transport))?.connection ?? null;
return this.entryFor(transport)?.connection ?? null;
}
public getParticipantsForTransport(
transport: TransportLocator,
): RemoteParticipant[] {
return this.map.get(keyFor(transport))?.participants ?? [];
return this.entryFor(transport)?.participants ?? [];
}
private entryFor(
transport: TransportLocator,
): { connection: Connection; participants: RemoteParticipant[] } | undefined {
const key = keyFor(transport);
return this.map.get(this.holders.get(key) ?? key);
}
}
@@ -105,6 +124,8 @@ interface Props {
localTransport: TransportLocator;
/** Every transport some member is on, deduplicated against the local one. */
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
/** The room / connection mapping. */
rooms: LivekitRooms;
logger: Logger;
ownMembershipIdentity: CallMembershipIdentityParts;
}
@@ -112,13 +133,15 @@ interface Props {
/**
* One connection per transport in use by one or more session members, with
* the remote participants each one sees. The connections start as they are
* created and stop as their transport drops out of the list.
* created and stop as their transport drops out of the list. Transports that
* resolve to one LiveKit room share the connection holding it.
*/
export function createConnectionMap$({
scope,
connectionFactory,
localTransport,
remoteTransports$,
rooms,
logger: parentLogger,
ownMembershipIdentity,
}: Props): Behavior<Epoch<ConnectionMap>> {
@@ -162,6 +185,7 @@ export function createConnectionMap$({
ownMembershipIdentity,
logger,
);
scope.onEnd(() => rooms.forget(connection));
// The state carries the progress, errors included
void connection.start();
return connection;
@@ -175,16 +199,23 @@ export function createConnectionMap$({
switchMap(({ value: connections, epoch }) => {
if (connections.length === 0)
return of(new Epoch(new ConnectionMap(), epoch));
return combineLatest(
const participants$ = combineLatest(
connections.map((connection) =>
connection.remoteParticipants$.pipe(
map((participants) => ({ connection, participants })),
),
),
(...list) => {
);
return combineLatest(
[participants$, rooms.holders$],
(list, holders) => {
const map = new ConnectionMap(logger);
for (const item of list)
map.add(item.connection, item.participants);
for (const { connection, participants } of list) {
map.add(connection, participants);
const holder = holders.get(connection);
if (holder !== undefined && holder !== connection)
map.alias(connection, holder);
}
return new Epoch(map, epoch);
},
);
@@ -39,6 +39,7 @@ import {
createTransportRegistry,
supportsDelegation,
} from "./LivekitBackend";
import { LivekitRooms, livekitRoomKey } from "./LivekitRooms";
import {
exampleServiceUrl,
exampleTransport,
@@ -173,11 +174,14 @@ describe("createLivekitBackend", () => {
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
};
/** The registry the backend handed its factory, for the test to resolve rooms in. */
let rooms: LivekitRooms;
/** Stands in for the factory: connections whose state and participants the test drives. */
function stubFactory(initialState: ConnectionState): void {
vi.spyOn(connectionFactory, "LivekitConnectionFactory").mockImplementation(
function (this: unknown) {
function (this: unknown, ...args: unknown[]) {
rooms = args.find((arg) => arg instanceof LivekitRooms) as LivekitRooms;
return {
createConnection: (
scope: ObservableScope,
@@ -506,6 +510,64 @@ describe("createLivekitBackend", () => {
expect(remote?.connection.serverName).toBe("sample.com");
});
it("shares one connection between transports that resolve to the same room", async () => {
// Two members of one homeserver, one of them advertising the SFU's url:
// two transports, one JWT service, one room, and the same subscriber
// identity on both tokens, which the SFU would not admit twice
const b = await prepared();
const withUrl: UnstableLivekitTransport = {
type: "livekit",
url: "wss://sfu.sample.com",
livekit_service_url: "https://lk.sample.com",
};
const withoutUrl: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.sample.com",
};
const bobMedia = b.mediaFor(testScope(), {
...bob(withUrl, ["@bob:sample.com:DEV1"]),
userId: "@bob:sample.com",
deviceId: "DEV1",
});
const carolMedia = b.mediaFor(testScope(), {
...bob(withoutUrl, ["@carol:sample.com:DEV2"]),
userId: "@carol:sample.com",
deviceId: "DEV2",
});
await flushPromises();
const [, first, second] = connections;
expect(first.connection.transport).toEqual(withUrl);
expect(second.connection.transport).toEqual(withoutUrl);
const room = livekitRoomKey("wss://sfu.sample.com", "alias");
await rooms.claim(first.connection, room);
void rooms.claim(second.connection, room);
first.participants$.next([
mockRemoteParticipant({
identity: "@bob:sample.com:DEV1",
trackPublications: new Map(),
}),
mockRemoteParticipant({
identity: "@carol:sample.com:DEV2",
trackPublications: new Map(),
}),
]);
await flushPromises();
expect(bobMedia.tracks$.value).toEqual([]);
expect(carolMedia.tracks$.value).toEqual([]);
// The waiting transport reports the state of the connection carrying it
second.state$.next({ state: "authenticating" });
await flushPromises();
expect(
b.connections$.value.map(({ transport, state }) => [transport, state]),
).toEqual([
[bothTransport, MediaConnectionState.Connected],
[withUrl, MediaConnectionState.Connected],
[withoutUrl, MediaConnectionState.Connected],
]);
});
it("connects to a remote publisher's transport on the SFU's homeserver", async () => {
const b = await prepared();
// Dave has no SFU of his own and publishes on sample.com's
@@ -61,6 +61,7 @@ import { toMediaConnectionState } from "./connectionState";
import { createDataChannel$ } from "./DataChannel";
import { createKeyProvider } from "./KeyProvider";
import { createLivekitMemberMedia } from "./LivekitMemberMedia";
import { LivekitRooms } from "./LivekitRooms";
import { createLivekitLocalMedia } from "./LocalMedia";
export interface LivekitBackendOptions {
@@ -104,6 +105,7 @@ export function createLivekitBackend(
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
undefined,
);
const rooms = new LivekitRooms(logger);
const connectionFactory = new LivekitConnectionFactory(
client,
roomId,
@@ -112,6 +114,7 @@ export function createLivekitBackend(
audioOutputDeviceId$,
keyProvider,
mediaQuality,
rooms,
);
// A new map, in a scope of its own, whenever the local transport changes:
@@ -123,6 +126,7 @@ export function createLivekitBackend(
connectionFactory,
localTransport,
remoteTransports$: transports.transports$,
rooms,
logger,
ownMembershipIdentity,
}),
@@ -259,7 +263,14 @@ export function createLivekitBackend(
switchMap(({ value }) => {
const connections = value.getConnections();
if (connections.length === 0) return of([]);
return combineLatest(connections.map(backendConnection$));
return combineLatest(
connections.map((connection) =>
backendConnection$(
connection,
value.getConnectionForTransport(connection) ?? connection,
),
),
);
}),
),
);
@@ -306,14 +317,16 @@ async function authenticated(
);
}
/** A transport's connection with the state of the connection holding its room. */
function backendConnection$(
connection: Connection,
holder: Connection,
): Observable<BackendConnection> {
const resolved$: Observable<ResolvedTransport | undefined> =
connection instanceof ResolvedConnection
? connection.resolved$
: of(undefined);
return combineLatest([connection.state$, resolved$]).pipe(
return combineLatest([holder.state$, resolved$]).pipe(
map(([state, resolved]): BackendConnection => ({
transport: connection.transport,
state: toMediaConnectionState(state),
@@ -0,0 +1,173 @@
/*
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 { logger } from "matrix-js-sdk/lib/logger";
import { describe, expect, it, vi } from "vitest";
import { type ConnectionRole } from "./auth";
import { type Connection } from "./Connection";
import { LivekitRooms, livekitRoomKey } from "./LivekitRooms";
const room = livekitRoomKey("wss://sfu.example.org", "alias");
const otherRoom = livekitRoomKey("wss://sfu.example.org", "other");
describe("LivekitRooms", () => {
it("hands a free room to its first claimant at once", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
await expect(rooms.claim(a, room)).resolves.toBe(false);
expect(rooms.holders$.value).toEqual(new Map([[a, a]]));
});
it("makes a subscriber wait for a held room, reading it through the holder meanwhile", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
const b = connection();
await rooms.claim(a, room);
const held = settled(rooms.claim(b, room));
expect(rooms.holders$.value).toEqual(
new Map([
[a, a],
[b, a],
]),
);
expect(await held()).toBe(false);
rooms.release(a);
expect(await held()).toBe(true);
// A released connection still reads the room through whoever holds it
expect(rooms.holders$.value).toEqual(
new Map([
[a, b],
[b, b],
]),
);
});
it("lets a publisher take a room over and stops the subscriber holding it", async () => {
const rooms = new LivekitRooms(logger);
const subscriber = connection();
const publisher = connection("publisher");
await rooms.claim(subscriber, room);
await expect(rooms.claim(publisher, room)).resolves.toBe(false);
expect(subscriber.stop).toHaveBeenCalled();
// The stopped subscriber still reads the room, now through the publisher
rooms.release(subscriber);
expect(rooms.holders$.value).toEqual(
new Map([
[subscriber, publisher],
[publisher, publisher],
]),
);
});
it("settles a waiter's claim when it is released without the room", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
const b = connection();
await rooms.claim(a, room);
const held = settled(rooms.claim(b, room));
rooms.release(b);
expect(await held()).toBe(true);
expect(rooms.holders$.value).toEqual(
new Map([
[a, a],
[b, a],
]),
);
});
it("keeps rooms apart", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
const b = connection();
await rooms.claim(a, room);
await expect(rooms.claim(b, otherRoom)).resolves.toBe(false);
expect(rooms.holders$.value).toEqual(
new Map([
[a, a],
[b, b],
]),
);
});
it("forgets a connection for good", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
const b = connection();
await rooms.claim(a, room);
void rooms.claim(b, room);
rooms.forget(a);
expect(rooms.holders$.value).toEqual(new Map([[b, b]]));
rooms.forget(b);
expect(rooms.holders$.value).toEqual(new Map());
});
it("ignores a release from a connection that never claimed", () => {
const rooms = new LivekitRooms(logger);
expect(() => rooms.release(connection())).not.toThrow();
});
it("reports whether a claimant had to wait", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
const b = connection();
await rooms.claim(a, room);
const claim = rooms.claim(b, room);
rooms.release(a);
await expect(claim).resolves.toBe(true);
});
it("names the holder of a room", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
expect(rooms.holderOf(room)).toBeUndefined();
await rooms.claim(a, room);
expect(rooms.holderOf(room)).toBe(a);
});
});
describe("livekitRoomKey", () => {
it("spells the SFU url one way however the token did", () => {
expect(livekitRoomKey("wss://sfu.example.org", "alias")).toBe(
livekitRoomKey("wss://sfu.example.org/", "alias"),
);
expect(livekitRoomKey("wss://sfu.example.org/", "alias")).toBe(
livekitRoomKey("WSS://SFU.EXAMPLE.ORG", "alias"),
);
});
it("keeps SFUs and aliases apart", () => {
const key = livekitRoomKey("wss://sfu.example.org", "alias");
expect(livekitRoomKey("wss://sfu.example.org/sfu", "alias")).not.toBe(key);
expect(livekitRoomKey("wss://sfu.example.org", "other")).not.toBe(key);
});
it("falls back to the raw string for something that is not a url", () => {
expect(livekitRoomKey("not a url", "alias")).toBe(
JSON.stringify(["not a url", "alias"]),
);
});
});
function connection(role: ConnectionRole = "subscriber"): Connection {
return { role, stop: vi.fn() } as unknown as Connection;
}
/** Whether a promise has settled by the time the microtasks have run. */
function settled(promise: Promise<unknown>): () => Promise<boolean> {
let done = false;
void promise.then(() => (done = true));
return async () => {
await Promise.resolve();
await Promise.resolve();
return done;
};
}
@@ -0,0 +1,177 @@
/*
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 } from "rxjs";
import { type Behavior } from "../../reactive/Behavior";
import { type Connection } from "./Connection";
// Branded, so that only values returned by `livekitRoomKey` can be used as keys.
export type LivekitRoomKey = string & { __livekitRoomKey: true };
/** A LiveKit room identified by the SFU it is on and its alias there. */
export function livekitRoomKey(url: string, alias: string): LivekitRoomKey {
return JSON.stringify([normalisedUrl(url), alias]) as LivekitRoomKey;
}
/** Normalises URLs to make them comparable. */
function normalisedUrl(url: string): string {
try {
return new URL(url).href;
} catch {
return url;
}
}
/** A room some connection has resolved to, and who is on it. */
interface Room {
/** The one connection joined to the room on the SFU. */
holder: Connection;
/** Connections waiting to hold the room, in the order they will get it. */
waiting: Waiter[];
}
/** A connection's pending claim on a room. */
interface Waiter {
/**
* The connection that resolved to the room and is not connecting until it
* holds it.
*/
connection: Connection;
/**
* Settles the connection's claim after it has either acquired hold of the
* room or was released.
*/
held: () => void;
}
/** Helper to organise rooms and holding or waiting connections. */
export class LivekitRooms {
private readonly logger: Logger;
/** The rooms some connection is on or waiting for. */
private readonly rooms = new Map<LivekitRoomKey, Room>();
/** The room each connection resolved to. */
private readonly roomOf = new Map<Connection, LivekitRoomKey>();
/** The source of `holders$`. */
private readonly holders = new BehaviorSubject<Map<Connection, Connection>>(
new Map(),
);
/** Every connection that resolved a room, to the connection holding that room. */
public readonly holders$: Behavior<Map<Connection, Connection>> =
this.holders;
public constructor(parentLogger: Logger) {
this.logger = parentLogger.getChild("[LivekitRooms]");
}
/** The connection holding a room, if any does. */
public holderOf(key: LivekitRoomKey): Connection | undefined {
return this.rooms.get(key)?.holder;
}
/**
* Resolves once `connection` either holds the room or was released.
* Resolves to whether the connection had to wait for the room.
*/
public async claim(
connection: Connection,
key: LivekitRoomKey,
): Promise<boolean> {
this.roomOf.set(connection, key);
const room = this.rooms.get(key);
if (room === undefined) {
this.logger.debug(`${describe(connection)} holds room ${key}`);
this.rooms.set(key, { holder: connection, waiting: [] });
this.publish();
return false;
}
// If the connection is for publishing and a subscriber holds the room, demote it.
if (connection.role === "publisher") {
const demoted = room.holder;
this.logger.info(
`${describe(connection)} takes room ${key} over from ${describe(demoted)}, which stops`,
);
room.holder = connection;
this.publish();
void demoted.stop();
return false;
}
this.logger.info(
`${describe(connection)} waits for room ${key}, held by ${describe(room.holder)}`,
);
await new Promise<void>((held) => {
room.waiting.push({ connection, held });
this.publish();
});
return true;
}
/** Gives up holding or waiting for the room. */
public release(connection: Connection): void {
const key = this.roomOf.get(connection);
const room = key === undefined ? undefined : this.rooms.get(key);
if (key === undefined || room === undefined) {
return; // Never claimed, or the room has already emptied out
}
if (room.holder === connection) {
// We're the room's holder. Figure out the first waiter and promote it.
const next = room.waiting.shift();
if (next === undefined) {
this.rooms.delete(key);
} else {
this.logger.info(
`${describe(next.connection)} holds room ${key} now, released by ${describe(connection)}`,
);
room.holder = next.connection;
next.held();
}
} else {
// We're a waiter. Figure out our index in the waiting list to remove ourselves.
const index = room.waiting.findIndex((w) => w.connection === connection);
if (index === -1) {
return;
}
const [{ held }] = room.waiting.splice(index, 1);
held();
}
this.publish();
}
/** Releases a connection that is gone for good, so nothing reads through it. */
public forget(connection: Connection): void {
this.release(connection);
this.roomOf.delete(connection);
this.publish();
}
/** Emits `holders$` afresh if needed. */
private publish(): void {
const holders = new Map<Connection, Connection>();
for (const [connection, key] of this.roomOf) {
const holder = this.rooms.get(key)?.holder;
if (holder !== undefined) holders.set(connection, holder);
}
if (!sameHolders(this.holders.value, holders)) this.holders.next(holders);
}
}
/** Checks if two holder maps are identical. */
function sameHolders(
a: Map<Connection, Connection>,
b: Map<Connection, Connection>,
): boolean {
return (
a.size === b.size &&
[...a].every(([connection, holder]) => b.get(connection) === holder)
);
}
/** Summarises a connection for logging. */
function describe(connection: Connection): string {
return `${connection.role} connection to ${JSON.stringify(connection.transport)}`;
}
+2 -1
View File
@@ -7,6 +7,7 @@
padding-top: env(safe-area-inset-top);
opacity: 1;
transition: opacity 0.15s;
text-align: center;
}
/* Pseudo-element for the gradient background */
@@ -52,7 +53,7 @@
". . ."
"primaryButton title secondaryButton"
". . .";
place-items: center;
place-items: center stretch;
column-gap: var(--cpd-space-2x);
}