Reduce ConnectionManager to a ConnectionMap

createConnectionMap$ now returns the behavior of connections and their
participants directly and takes an already resolved local transport
instead of an observable of it. The CallViewModel wraps it with mapScoped
so that a new map is built, in a fresh scope, whenever the local transport
changes, and swallows local transport errors there since the local member
already surfaces them in the UI.
This commit is contained in:
Robin
2026-10-12 10:26:54 +02:00
committed by Johannes Marbach
parent 24b6a632dd
commit d5bbb43e3a
13 changed files with 370 additions and 474 deletions
+7 -9
View File
@@ -694,15 +694,13 @@ export const InCallView: FC<InCallViewProps> = ({
tab={settingsTab} tab={settingsTab}
onTabChange={setSettingsTab} onTabChange={setSettingsTab}
developerSettingsVm={developerSettingsVm} developerSettingsVm={developerSettingsVm}
livekitRooms={allConnections livekitRooms={allConnections.map((connectionItem) => ({
.getConnections() room: connectionItem.livekitRoom,
.map((connectionItem) => ({ livekitAlias: connectionItem.livekitAlias,
room: connectionItem.livekitRoom, // TODO compute is local or tag it in the livekit room items already
livekitAlias: connectionItem.livekitAlias, isLocal: undefined,
// TODO compute is local or tag it in the livekit room items already url: connectionItem.transport.livekit_service_url,
isLocal: undefined, }))}
url: connectionItem.transport.livekit_service_url,
}))}
/> />
</> </>
)} )}
+30 -16
View File
@@ -39,6 +39,9 @@ import {
timer, timer,
takeUntil, takeUntil,
from, from,
concatWith,
catchError,
switchAll,
} from "rxjs"; } from "rxjs";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { import {
@@ -56,6 +59,7 @@ import {
createToggle$, createToggle$,
filterBehavior, filterBehavior,
generateItems, generateItems,
mapScoped,
pauseWhen, pauseWhen,
} from "../../utils/observable"; } from "../../utils/observable";
import { import {
@@ -105,7 +109,7 @@ import {
type OneOnOneMedia, type OneOnOneMedia,
} from "../layoutMedia.ts"; } from "../layoutMedia.ts";
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
import { type Epoch, type ObservableScope } from "../ObservableScope.ts"; import { Epoch, type ObservableScope } from "../ObservableScope.ts";
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
import { import {
createLocalMembership$, createLocalMembership$,
@@ -123,9 +127,9 @@ import {
ECConnectionFactory, ECConnectionFactory,
} from "./remoteMembers/ConnectionFactory.ts"; } from "./remoteMembers/ConnectionFactory.ts";
import { import {
type ConnectionManagerData, ConnectionMap,
createConnectionManager$, createConnectionMap$,
} from "./remoteMembers/ConnectionManager.ts"; } from "./remoteMembers/ConnectionMap.ts";
import { import {
createRemoteMatrixLivekitMembers$, createRemoteMatrixLivekitMembers$,
type LocalMatrixLivekitMember, type LocalMatrixLivekitMember,
@@ -371,7 +375,7 @@ export interface CallViewModel {
* key is generated when someone joins or leaves. * key is generated when someone joins or leaves.
*/ */
keyRotationSuppressed$: Behavior<boolean>; keyRotationSuppressed$: Behavior<boolean>;
allConnections$: Behavior<ConnectionManagerData>; allConnections$: Behavior<Connection[]>;
/** Participants sorted by livekit room so they can be used in the audio rendering */ /** Participants sorted by livekit room so they can be used in the audio rendering */
livekitRoomItems$: Behavior<LivekitRoomItem[]>; livekitRoomItems$: Behavior<LivekitRoomItem[]>;
/** use the layout instead, this is just for the sdk export. */ /** use the layout instead, this is just for the sdk export. */
@@ -577,14 +581,24 @@ export function createCallViewModel$(
options.livekitRoomFactory, options.livekitRoomFactory,
); );
const connectionManager = createConnectionManager$({ const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
scope: scope, localTransport$.pipe(
connectionFactory: connectionFactory, catchError(() => NEVER), // Swallow errors
localTransport$, concatWith(NEVER), // So the Observable doesn't complete prematurely
remoteTransports$: membershipsAndTransports.transports$, mapScoped("connectionMap$", (localTransport, scope) =>
logger: logger, createConnectionMap$({
ownMembershipIdentity, scope,
}); connectionFactory,
localTransport,
remoteTransports$: membershipsAndTransports.transports$,
logger,
ownMembershipIdentity,
}),
),
switchAll(),
),
new Epoch(new ConnectionMap()),
);
const remoteMatrixLivekitMembers$: Behavior< const remoteMatrixLivekitMembers$: Behavior<
Epoch<RemoteMatrixLivekitMember[]> Epoch<RemoteMatrixLivekitMember[]>
@@ -592,7 +606,7 @@ export function createCallViewModel$(
scope: scope, scope: scope,
membershipsWithTransport$: membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$, membershipsAndTransports.membershipsWithTransport$,
connectionManager: connectionManager, connectionMap$,
localUser: { userId, deviceId }, localUser: { userId, deviceId },
}); });
@@ -631,7 +645,7 @@ export function createCallViewModel$(
controlledAudioDevices, controlledAudioDevices,
); );
}, },
connectionManager, connectionMap$,
client, client,
matrixRTCSession, matrixRTCSession,
localTransport$, localTransport$,
@@ -717,7 +731,7 @@ export function createCallViewModel$(
}); });
const allConnections$ = scope.behavior( const allConnections$ = scope.behavior(
connectionManager.connectionManagerData$.pipe(map((d) => d.value)), connectionMap$.pipe(map((d) => d.value.getConnections())),
); );
const livekitRoomItems$ = scope.behavior<LivekitRoomItem[]>( const livekitRoomItems$ = scope.behavior<LivekitRoomItem[]>(
@@ -21,7 +21,7 @@ import {
beforeEach, beforeEach,
afterEach, afterEach,
} from "vitest"; } from "vitest";
import { BehaviorSubject, map, of, Subject } from "rxjs"; import { BehaviorSubject, of, Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import {
ConnectionState as LivekitConnectionState, ConnectionState as LivekitConnectionState,
@@ -57,7 +57,7 @@ import {
import { MatrixRTCTransportMissingError } from "../../../utils/errors"; import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope"; import { Epoch, ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior"; import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager"; import { ConnectionMap } from "../remoteMembers/ConnectionMap";
import { import {
type ConnectionState, type ConnectionState,
type Connection, type Connection,
@@ -261,25 +261,11 @@ describe("LocalMembership", () => {
it("throws error on missing RTC config error", () => { it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, expectObservable }) => { withTestScheduler(({ scope, hot, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransport>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
null,
);
// we do not need any connection data since we want to fail before reaching that.
const mockConnectionManager = {
transports$: scope.behavior(
localTransport$.pipe(map((t) => new Epoch([t]))),
),
connectionManagerData$: constant(
new Epoch(new ConnectionManagerData()),
),
};
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager, // we do not need any connection data since we want to fail before reaching that.
connectionMap$: constant(new Epoch(new ConnectionMap())),
localTransport$: hot( localTransport$: hot(
"1ms #", "1ms #",
{}, {},
@@ -295,24 +281,18 @@ describe("LocalMembership", () => {
}); });
it("logs if callIntent cannot be updated", async () => { it("logs if callIntent cannot be updated", async () => {
const scope = new ObservableScope();
const mockConnectionManager = {
transports$: constant(new Epoch([])),
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
};
async function reject(): Promise<void> { async function reject(): Promise<void> {
return Promise.reject(new Error("Not connected yet")); return Promise.reject(new Error("Not connected yet"));
} }
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope: testScope(),
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
matrixRTCSession: { matrixRTCSession: {
slotId: "m.call#ROOM", slotId: "m.call#ROOM",
updateCallIntent: vi.fn().mockImplementation(reject), updateCallIntent: vi.fn().mockImplementation(reject),
leave: vi.fn(), leave: vi.fn(),
}, },
connectionManager: mockConnectionManager, connectionMap$: constant(new Epoch(new ConnectionMap())),
localTransport$: constant(mockTransport), localTransport$: constant(mockTransport),
}); });
const expextedLog = const expextedLog =
@@ -322,7 +302,6 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
defaultCreateLocalMemberValues.muteStates.video.setEnabled$.value?.(true); defaultCreateLocalMemberValues.muteStates.video.setEnabled$.value?.(true);
expect(internalLogger).toHaveBeenCalledWith(expextedLog); expect(internalLogger).toHaveBeenCalledWith(expextedLog);
scope.end();
}); });
const mockTransport = { const mockTransport = {
@@ -374,14 +353,12 @@ describe("LocalMembership", () => {
if (delegationUrl !== null) if (delegationUrl !== null)
fetchMock.post(delegationUrl, () => ({ status: 401, body: {} })); fetchMock.post(delegationUrl, () => ({ status: 401, body: {} }));
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(connectionTransportAConnected, []); connectionMap.add(connectionTransportAConnected, []);
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
joinMatrixRTC, joinMatrixRTC,
localTransport$: constant(mockTransport), localTransport$: constant(mockTransport),
delayId$, delayId$,
@@ -453,15 +430,13 @@ describe("LocalMembership", () => {
typeof vi.fn typeof vi.fn
>; >;
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(connectionTransportAConnected, []); connectionMap.add(connectionTransportAConnected, []);
// connectionManagerData.add(connectionTransportB, []); // connectionMap.add(connectionTransportB, []);
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport), localTransport$: constant(mockTransport),
}); });
await flushPromises(); await flushPromises();
@@ -483,12 +458,10 @@ describe("LocalMembership", () => {
// //
it("tracks livekit state correctly", async () => { it("tracks livekit state correctly", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
const localTransport$ = new Subject<LivekitTransport>(); const localTransport$ = new Subject<LivekitTransport>();
const connectionManagerData$ = new BehaviorSubject( const connectionMap$ = new BehaviorSubject(new Epoch(connectionMap));
new Epoch(connectionManagerData),
);
const publishers: Publisher[] = []; const publishers: Publisher[] = [];
const publishing$ = new BehaviorSubject<boolean>(false); const publishing$ = new BehaviorSubject<boolean>(false);
@@ -522,9 +495,7 @@ describe("LocalMembership", () => {
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: { connectionMap$: connectionMap$,
connectionManagerData$,
},
localTransport$, localTransport$,
}); });
@@ -542,14 +513,14 @@ describe("LocalMembership", () => {
}, },
}); });
const connectionManagerData2 = new ConnectionManagerData(); const connectionMap2 = new ConnectionMap();
connectionManagerData2.add( connectionMap2.add(
// clone because we will mutate this later. // clone because we will mutate this later.
{ ...connectionTransportAConnecting } as unknown as Connection, { ...connectionTransportAConnecting } as unknown as Connection,
[], [],
); );
connectionManagerData$.next(new Epoch(connectionManagerData2)); connectionMap$.next(new Epoch(connectionMap2));
await flushPromises(); await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({ expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected, matrix: RTCMemberStatus.Connected,
@@ -563,7 +534,7 @@ describe("LocalMembership", () => {
}); });
( (
connectionManagerData2.getConnectionForTransport(mockTransport)! connectionMap2.getConnectionForTransport(mockTransport)!
.state$ as BehaviorSubject<ConnectionState> .state$ as BehaviorSubject<ConnectionState>
).next({ ).next({
state: "authenticated", state: "authenticated",
@@ -659,8 +630,8 @@ describe("LocalMembership", () => {
[boolean, HomeserverDisconnectReason | null] [boolean, HomeserverDisconnectReason | null]
>([false, "membership"]); >([false, "membership"]);
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(connectionTransportAConnected, []); connectionMap.add(connectionTransportAConnected, []);
createLocalMembership$({ createLocalMembership$({
scope, scope,
@@ -669,9 +640,7 @@ describe("LocalMembership", () => {
combined$: hsReason$, combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
}, },
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport), localTransport$: constant(mockTransport),
}); });
@@ -696,8 +665,8 @@ describe("LocalMembership", () => {
[boolean, HomeserverDisconnectReason | null] [boolean, HomeserverDisconnectReason | null]
>([true, null]); >([true, null]);
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(connectionTransportAConnected, []); connectionMap.add(connectionTransportAConnected, []);
createLocalMembership$({ createLocalMembership$({
scope, scope,
@@ -706,9 +675,7 @@ describe("LocalMembership", () => {
combined$: hsReason$, combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
}, },
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport), localTransport$: constant(mockTransport),
}); });
@@ -742,8 +709,8 @@ describe("LocalMembership", () => {
state$: connectionState$, state$: connectionState$,
} as unknown as Connection; } as unknown as Connection;
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(mutableConnection, []); connectionMap.add(mutableConnection, []);
createLocalMembership$({ createLocalMembership$({
scope, scope,
@@ -755,9 +722,7 @@ describe("LocalMembership", () => {
]), ]),
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
}, },
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport), localTransport$: constant(mockTransport),
}); });
@@ -792,8 +757,8 @@ describe("LocalMembership", () => {
[boolean, HomeserverDisconnectReason | null] [boolean, HomeserverDisconnectReason | null]
>([true, null]); >([true, null]);
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
connectionManagerData.add(connectionTransportAConnected, []); connectionMap.add(connectionTransportAConnected, []);
createLocalMembership$({ createLocalMembership$({
scope, scope,
@@ -802,9 +767,7 @@ describe("LocalMembership", () => {
combined$: hsReason$, combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected), rtsSession$: constant(RTCMemberStatus.Connected),
}, },
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport), localTransport$: constant(mockTransport),
}); });
@@ -863,14 +826,12 @@ describe("LocalMembership", () => {
localMembership: ReturnType<typeof createLocalMembership$>; localMembership: ReturnType<typeof createLocalMembership$>;
} => { } => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData(); const connectionMap = new ConnectionMap();
if (connection) connectionManagerData.add(connection, []); if (connection) connectionMap.add(connection, []);
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope, scope,
...defaultCreateLocalMemberValues, ...defaultCreateLocalMemberValues,
connectionManager: { connectionMap$: constant(new Epoch(connectionMap)),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport), localTransport$: constant(mockTransport),
}); });
return { scope, localMembership }; return { scope, localMembership };
@@ -47,8 +47,8 @@ import { deepCompare } from "matrix-js-sdk/lib/utils";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts"; import { type Behavior } from "../../Behavior.ts";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts"; import { type ConnectionMap } from "../remoteMembers/ConnectionMap.ts";
import { type ObservableScope } from "../../ObservableScope.ts"; import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { type Publisher } from "./Publisher.ts"; import { type Publisher } from "./Publisher.ts";
import { type MuteStates } from "../../MuteStates.ts"; import { type MuteStates } from "../../MuteStates.ts";
import { import {
@@ -138,7 +138,7 @@ interface Props {
// that the inputs for those createSomething$() functions should NOT contain any js-sdk objectes // that the inputs for those createSomething$() functions should NOT contain any js-sdk objectes
scope: ObservableScope; scope: ObservableScope;
muteStates: MuteStates; muteStates: MuteStates;
connectionManager: IConnectionManager; connectionMap$: Behavior<Epoch<ConnectionMap>>;
createPublisherFactory: (connection: Connection) => Publisher; createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: ( joinMatrixRTC: (
transport: LivekitTransport, transport: LivekitTransport,
@@ -169,7 +169,7 @@ interface Props {
* - * -
* @param props The properties required to create the local membership. * @param props The properties required to create the local membership.
* @param props.scope The observable scope to use. * @param props.scope The observable scope to use.
* @param props.connectionManager The connection manager to get connections from. * @param props.connectionMap$ The connection map to get connections from.
* @param props.createPublisherFactory Factory to create a publisher once we have a connection. * @param props.createPublisherFactory Factory to create a publisher once we have a connection.
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport. * @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
* @param props.homeserverConnected The homeserver connected state. * @param props.homeserverConnected The homeserver connected state.
@@ -191,7 +191,7 @@ interface Props {
*/ */
export const createLocalMembership$ = ({ export const createLocalMembership$ = ({
scope, scope,
connectionManager, connectionMap$,
localTransport$: localTransportWithErrors$, localTransport$: localTransportWithErrors$,
homeserverConnected, homeserverConnected,
createPublisherFactory, createPublisherFactory,
@@ -324,13 +324,11 @@ export const createLocalMembership$ = ({
// Drop Epoch data here since we will not combine this anymore // Drop Epoch data here since we will not combine this anymore
const localConnection$ = scope.behavior( const localConnection$ = scope.behavior(
combineLatest([ combineLatest(
connectionManager.connectionManagerData$, [connectionMap$, localTransport$],
localTransport$, ({ value: connectionMap }, transport) =>
]).pipe( connectionMap.getConnectionForTransport(transport),
map(([{ value: connectionData }, transport]) => ).pipe(
connectionData.getConnectionForTransport(transport),
),
tap((connection) => { tap((connection) => {
logger.info( logger.info(
`Local connection updated: ${JSON.stringify(connection?.transport)}`, `Local connection updated: ${JSON.stringify(connection?.transport)}`,
@@ -418,8 +418,8 @@ describe("Start connection states", () => {
pendingConnect.reject(new Error("Client initiated disconnect")); pendingConnect.reject(new Error("Client initiated disconnect"));
await stopping; await stopping;
// start() resolves rather than rejecting (it is not awaited by the // start() resolves rather than rejecting (it is not awaited by the caller,
// ConnectionManager, so a rejection would be unhandled). // so a rejection would be unhandled).
await expect(started).resolves.toBeUndefined(); await expect(started).resolves.toBeUndefined();
expect(capturedStates.at(-1)).toEqual({ state: "stopped" }); expect(capturedStates.at(-1)).toEqual({ state: "stopped" });
expect(capturedStates.some((st) => st instanceof Error)).toBe(false); expect(capturedStates.some((st) => st instanceof Error)).toBe(false);
@@ -356,7 +356,7 @@ export class Connection {
// stop() was called while we were connecting, which makes the pending // stop() was called while we were connecting, which makes the pending
// connect reject. That is the abort we asked for, not a failure, so // connect reject. That is the abort we asked for, not a failure, so
// don't record an error state on a stopped connection or rethrow it // don't record an error state on a stopped connection or rethrow it
// (start() is not awaited by the ConnectionManager, so a throw here // (start() is not necessarily awaited by the caller, so a throw here
// becomes an unhandled promise rejection). // becomes an unhandled promise rejection).
this.logger.debug(`Connect aborted because the connection was stopped`); this.logger.debug(`Connect aborted because the connection was stopped`);
return; return;
@@ -1,269 +0,0 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import {
combineLatest,
map,
type Observable,
of,
switchMap,
startWith,
catchError,
NEVER,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type RemoteParticipant } from "livekit-client";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts";
import { type Connection } from "./Connection.ts";
import { Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { generateItemsWithEpoch } from "../../../utils/observable.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
export class ConnectionManagerData {
private readonly store: Map<
string,
{ connection: Connection; participants: RemoteParticipant[] }
> = new Map();
public constructor(private readonly logger?: Logger) {}
public add(connection: Connection, participants: RemoteParticipant[]): void {
const key = this.getKey(connection.transport);
const existing = this.store.get(key);
if (!existing) {
this.store.set(key, { connection, participants });
} else {
// Transports are deduplicated by URL upstream, so this should never
// happen; if it does, members may be matched against the wrong room.
this.logger?.warn(
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
);
existing.participants.push(...participants);
}
}
private getKey(transport: LivekitTransport): string {
// This is enough as a key because the ConnectionManager is already scoped by room.
// We also do not need to consider the slotId at this point since each `MatrixRTCSession` is already scoped by `slotDescription: {id, application}`.
return transport.livekit_service_url;
}
public getConnections(): Connection[] {
return Array.from(this.store.values()).map(({ connection }) => connection);
}
public getConnectionForTransport(
transport: LivekitTransport,
): Connection | null {
return this.store.get(this.getKey(transport))?.connection ?? null;
}
public getParticipantsForTransport(
transport: LivekitTransport,
): RemoteParticipant[] {
const key = this.getKey(transport);
const existing = this.store.get(key);
if (existing) {
return existing.participants;
}
return [];
}
}
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
localTransport$: Observable<LivekitTransport>;
remoteTransports$: Behavior<Epoch<LivekitTransport[]>>;
logger: Logger;
ownMembershipIdentity: CallMembershipIdentityParts;
}
// TODO - write test for scopes (do we really need to bind scope)
export interface IConnectionManager {
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
}
/**
* Crete a `ConnectionManager`
* @param props - Configuration object
* @param props.scope - The observable scope used by this object
* @param props.connectionFactory - Used to create new connections
* @param props.localTransport$ - The transport to publish local media on. (deduplicated with remoteTransports$)
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport$)
* @param props.ownMembershipIdentity - The own membership identity to use.
* @param props.logger - The logger to use.
*
* Each of these behaviors can be interpreted as subscribed list of transports.
*
* Using `registerTransports` independent external modules can control what connections
* are created by the ConnectionManager.
*
* The connection manager will remove all duplicate transports in each subscibed list.
*
* See `unregisterAllTransports` and `unregisterTransport` for details on how to unsubscribe.
*/
export function createConnectionManager$({
scope,
connectionFactory,
localTransport$,
remoteTransports$,
logger: parentLogger,
ownMembershipIdentity,
}: Props): IConnectionManager {
const logger = parentLogger.getChild("[ConnectionManager]");
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
const localTransportAsArray$ = localTransport$.pipe(
// LocalMember already surfaces local transport errors properly in the UI,
// here we can just swallow them
catchError(() => NEVER),
map((transport) => [transport]),
startWith([]),
);
/**
* All transports currently managed by the ConnectionManager.
*
* This list does not include duplicate transports.
*
* It is build based on the list of subscribed transports (`transportsSubscriptions$`).
* externally this is modified via `registerTransports()`.
*/
const localAndRemoteTransports$: Behavior<
Epoch<{ transport: LivekitTransport; role: "publisher" | "subscriber" }[]>
> = scope.behavior(
combineLatest([localTransportAsArray$, remoteTransports$]).pipe(
// Combine local and remote transports into one transport array, tagged
// with the role we take on each of them
map(([localTransportAsArray, remoteTransports]) => {
const dedupedRemote = removeDuplicateTransports(remoteTransports.value);
const remoteWithoutLocal = dedupedRemote.filter(
(transport) =>
!localTransportAsArray.find((l) =>
areLivekitTransportsEqual(l, transport),
),
);
logger.debug(
"remoteWithoutLocal",
remoteWithoutLocal,
"localTransportAsArray",
localTransportAsArray,
);
return new Epoch(
[
...localTransportAsArray.map((transport) => ({
transport,
role: "publisher" as const,
})),
...remoteWithoutLocal.map((transport) => ({
transport,
role: "subscriber" as const,
})),
],
remoteTransports.epoch,
);
}),
),
);
/**
* Connections for each transport in use by one or more session members.
*/
const connections$ = scope.behavior(
localAndRemoteTransports$.pipe(
generateItemsWithEpoch(
"ConnectionManager connections$",
function* (transports) {
for (const { transport, role } of transports) {
yield {
keys: [role, transport.livekit_service_url],
data: undefined,
};
}
},
(scope, _data$, role, serviceUrl) => {
const connection = connectionFactory.createConnection(
scope,
role,
{
type: "livekit",
livekit_service_url: serviceUrl,
},
ownMembershipIdentity,
logger,
);
// Start the connection immediately
// Use connection state to track connection progress
void connection.start();
// TODO subscribe to connection state to retry or log issues?
return connection;
},
),
),
);
const connectionManagerData$ = scope.behavior(
connections$.pipe(
switchMap((connections) => {
const epoch = connections.epoch;
// Map the connections to list of {connection, participants}[]
const listOfConnectionsWithRemoteParticipants = connections.value.map(
(connection) => {
return connection.remoteParticipants$.pipe(
map((participants) => ({
connection,
participants,
})),
);
},
);
// probably not required
if (listOfConnectionsWithRemoteParticipants.length === 0) {
return of(new Epoch(new ConnectionManagerData(), epoch));
}
// combineLatest the several streams into a single stream with the ConnectionManagerData
return combineLatest(listOfConnectionsWithRemoteParticipants).pipe(
map(
(lists) =>
new Epoch(
lists.reduce((data, { connection, participants }) => {
data.add(connection, participants);
return data;
}, new ConnectionManagerData(logger)),
epoch,
),
),
);
}),
),
new Epoch(new ConnectionManagerData(), -1),
);
return { connectionManagerData$ };
}
function removeDuplicateTransports<T extends LivekitTransport>(
transports: T[],
): T[] {
return transports.reduce((acc, transport) => {
if (!acc.some((t) => areLivekitTransportsEqual(t, transport)))
acc.push(transport);
return acc;
}, [] as T[]);
}
@@ -6,16 +6,13 @@ Please see LICENSE in the repository root for full details.
*/ */
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BehaviorSubject, NEVER } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type RemoteParticipant } from "livekit-client"; import { type RemoteParticipant } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts"; import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
import { import { createConnectionMap$, ConnectionMap } from "./ConnectionMap.ts";
createConnectionManager$,
ConnectionManagerData,
} from "./ConnectionManager.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
import { ownMemberMock, withTestScheduler } from "../../../utils/test.ts"; import { ownMemberMock, withTestScheduler } from "../../../utils/test.ts";
@@ -34,6 +31,15 @@ const TRANSPORT_2: LivekitTransport = {
livekit_service_url: "https://lk.sample.com", livekit_service_url: "https://lk.sample.com",
}; };
function findConnection(
transport: LivekitTransport,
connections: Connection[],
): Connection | undefined {
return connections.find((c) =>
areLivekitTransportsEqual(c.transport, transport),
);
}
let fakeConnectionFactory: ConnectionFactory; let fakeConnectionFactory: ConnectionFactory;
let testScope: ObservableScope; let testScope: ObservableScope;
@@ -71,10 +77,10 @@ afterEach(() => {
describe("connections$ stream", () => { describe("connections$ stream", () => {
test("Should create and start new connections for each transports", () => { test("Should create and start new connections for each transports", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", { remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0), a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}), }),
@@ -83,7 +89,7 @@ describe("connections$ stream", () => {
}); });
expectObservable( expectObservable(
connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("a", { ).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => { a: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value; const connections = e.value;
@@ -93,15 +99,11 @@ describe("connections$ stream", () => {
vi.mocked(fakeConnectionFactory).createConnection, vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledTimes(2); ).toHaveBeenCalledTimes(2);
const conn1 = connections.find((c) => const conn1 = findConnection(TRANSPORT_1, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_1),
);
expect(conn1).toBeDefined(); expect(conn1).toBeDefined();
expect(conn1!.start).toHaveBeenCalled(); expect(conn1!.start).toHaveBeenCalled();
const conn2 = connections.find((c) => const conn2 = findConnection(TRANSPORT_2, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_2),
);
expect(conn2).toBeDefined(); expect(conn2).toBeDefined();
expect(conn2!.start).toHaveBeenCalled(); expect(conn2!.start).toHaveBeenCalled();
return true; return true;
@@ -112,10 +114,10 @@ describe("connections$ stream", () => {
test("Should start connection only once", () => { test("Should start connection only once", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("abcdef", { remoteTransports$: behavior("abcdef", {
a: new Epoch([TRANSPORT_1], 0), a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1], 1), b: new Epoch([TRANSPORT_1], 1),
@@ -129,7 +131,7 @@ describe("connections$ stream", () => {
}); });
expectObservable( expectObservable(
connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("xxxxxa", { ).toBe("xxxxxa", {
x: expect.anything(), x: expect.anything(),
a: expect.toSatisfy((e: Epoch<Connection[]>) => { a: expect.toSatisfy((e: Epoch<Connection[]>) => {
@@ -140,14 +142,10 @@ describe("connections$ stream", () => {
vi.mocked(fakeConnectionFactory).createConnection, vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledTimes(2); ).toHaveBeenCalledTimes(2);
const conn2 = connections.find((c) => const conn2 = findConnection(TRANSPORT_2, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_2),
);
expect(conn2).toBeDefined(); expect(conn2).toBeDefined();
const conn1 = connections.find((c) => const conn1 = findConnection(TRANSPORT_1, connections);
areLivekitTransportsEqual(c.transport, TRANSPORT_1),
);
expect(conn1).toBeDefined(); expect(conn1).toBeDefined();
expect(conn1!.start).toHaveBeenCalledOnce(); expect(conn1!.start).toHaveBeenCalledOnce();
@@ -159,10 +157,10 @@ describe("connections$ stream", () => {
test("Should cleanup connections when not needed anymore", () => { test("Should cleanup connections when not needed anymore", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("abc", { remoteTransports$: behavior("abc", {
a: new Epoch([TRANSPORT_1], 0), a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1), b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
@@ -173,7 +171,7 @@ describe("connections$ stream", () => {
}); });
expectObservable( expectObservable(
connectionManagerData$.pipe(mapEpoch((d) => d.getConnections())), connectionMap$.pipe(mapEpoch((d) => d.getConnections())),
).toBe("xab", { ).toBe("xab", {
x: expect.anything(), x: expect.anything(),
a: expect.toSatisfy((e: Epoch<Connection[]>) => { a: expect.toSatisfy((e: Epoch<Connection[]>) => {
@@ -186,8 +184,9 @@ describe("connections$ stream", () => {
expect(connections.length).toBe(1); expect(connections.length).toBe(1);
// The second connection should have been stopped has it is no longer needed. // The second connection should have been stopped has it is no longer needed.
const connection2 = allCreatedConnections.find((c) => const connection2 = findConnection(
areLivekitTransportsEqual(c.transport, TRANSPORT_2), TRANSPORT_2,
allCreatedConnections,
); );
expect(connection2).toBeDefined(); expect(connection2).toBeDefined();
expect(connection2!.stop).toHaveBeenCalled(); expect(connection2!.stop).toHaveBeenCalled();
@@ -203,10 +202,10 @@ describe("connections$ stream", () => {
}); });
}); });
describe("ConnectionManagerData", () => { describe("ConnectionMap", () => {
test("warns when a second connection to the same URL is merged", () => { test("warns when a second connection to the same URL is merged", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const data = new ConnectionManagerData(logger); const data = new ConnectionMap(logger);
const connection = { transport: TRANSPORT_1 } as unknown as Connection; const connection = { transport: TRANSPORT_1 } as unknown as Connection;
const p = (identity: string): RemoteParticipant => const p = (identity: string): RemoteParticipant =>
({ identity }) as unknown as RemoteParticipant; ({ identity }) as unknown as RemoteParticipant;
@@ -221,7 +220,7 @@ describe("ConnectionManagerData", () => {
}); });
}); });
describe("connectionManagerData$ stream", () => { describe("connectionMap$", () => {
// Used in test to control fake connections' remoteParticipants$ streams // Used in test to control fake connections' remoteParticipants$ streams
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>; let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
@@ -298,10 +297,10 @@ describe("connectionManagerData$ stream", () => {
); );
// ============================== // ==============================
const { connectionManagerData$ } = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: fakeConnectionFactory, connectionFactory: fakeConnectionFactory,
localTransport$: NEVER, localTransport: TRANSPORT_1,
remoteTransports$: behavior("a", { remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0), a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}), }),
@@ -309,16 +308,16 @@ describe("connectionManagerData$ stream", () => {
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
}); });
expectObservable(connectionManagerData$).toBe("abcd", { expectObservable(connectionMap$).toBe("abcd", {
a: expect.toSatisfy((e) => { a: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
return true; return true;
}), }),
b: expect.toSatisfy((e) => { b: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
@@ -328,7 +327,7 @@ describe("connectionManagerData$ stream", () => {
return true; return true;
}), }),
c: expect.toSatisfy((e) => { c: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
@@ -341,7 +340,7 @@ describe("connectionManagerData$ stream", () => {
return true; return true;
}), }),
d: expect.toSatisfy((e) => { d: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value; const data: ConnectionMap = e.value;
expect(data.getConnections().length).toBe(2); expect(data.getConnections().length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2); expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2);
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1); expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
@@ -0,0 +1,193 @@
/*
Copyright 2025-2026 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, map, of, switchMap, tap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type RemoteParticipant } from "livekit-client";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts";
import { type Connection } from "./Connection.ts";
import {
Epoch,
mapEpoch,
type ObservableScope,
} from "../../ObservableScope.ts";
import { generateItemsWithEpoch } from "../../../utils/observable.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
function removeDuplicateTransports<T extends LivekitTransport>(
transports: T[],
): T[] {
return transports.reduce((acc, transport) => {
if (!acc.some((t) => areLivekitTransportsEqual(t, transport)))
acc.push(transport);
return acc;
}, [] as T[]);
}
export class ConnectionMap {
private readonly map = new Map<
string,
{ connection: Connection; participants: RemoteParticipant[] }
>();
public constructor(private readonly logger?: Logger) {}
public add(connection: Connection, participants: RemoteParticipant[]): void {
const key = this.getKey(connection.transport);
const existing = this.map.get(key);
if (!existing) {
this.map.set(key, { connection, participants });
} else {
// Transports are deduplicated by URL upstream, so this should never
// happen; if it does, members may be matched against the wrong room.
this.logger?.warn(
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
);
existing.participants.push(...participants);
}
}
private getKey(transport: LivekitTransport): string {
// This is enough as a key because the ConnectionMap is already scoped by room.
// We also do not need to consider the slotId at this point since each `MatrixRTCSession` is already scoped by `slotDescription: {id, application}`.
return transport.livekit_service_url;
}
public getConnections(): Connection[] {
return Array.from(this.map.values()).map(({ connection }) => connection);
}
public getConnectionForTransport(
transport: LivekitTransport,
): Connection | null {
return this.map.get(this.getKey(transport))?.connection ?? null;
}
public getParticipantsForTransport(
transport: LivekitTransport,
): RemoteParticipant[] {
const existing = this.map.get(this.getKey(transport));
if (existing) {
return existing.participants;
}
return [];
}
}
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
localTransport: LivekitTransport;
remoteTransports$: Behavior<Epoch<LivekitTransport[]>>;
logger: Logger;
ownMembershipIdentity: CallMembershipIdentityParts;
}
/**
* Create a dynamic map of connections and their associated backend participants.
* @param props - Configuration object
* @param props.scope - The observable scope used by this object
* @param props.connectionFactory - Used to create new connections
* @param props.localTransport - The transport to publish local media on. (deduplicated with remoteTransports$)
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport)
* @param props.ownMembershipIdentity - The own membership identity to use.
* @param props.logger - The logger to use.
*/
export function createConnectionMap$({
scope,
connectionFactory,
localTransport,
remoteTransports$,
logger: parentLogger,
ownMembershipIdentity,
}: Props): Behavior<Epoch<ConnectionMap>> {
const logger = parentLogger.getChild("[ConnectionMap]");
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
/**
* All transports currently represented in the connection map. This list does
* not include duplicate transports.
*/
const localAndRemoteTransports$: Behavior<Epoch<LivekitTransport[]>> =
scope.behavior(
remoteTransports$.pipe(
mapEpoch((remoteTransports) =>
removeDuplicateTransports([localTransport, ...remoteTransports]),
),
tap((transports) =>
logger.debug("localAndRemoteTransports$ = ", transports),
),
),
);
/**
* Connections for each transport in use by one or more session members.
*/
const connections$ = scope.behavior(
localAndRemoteTransports$.pipe(
generateItemsWithEpoch(
"ConnectionMap connections$",
function* (transports) {
for (const transport of transports) {
const role = areLivekitTransportsEqual(transport, localTransport)
? ("publisher" as const)
: ("subscriber" as const);
yield {
keys: [role, transport.livekit_service_url],
data: undefined,
};
}
},
(scope, _data$, role, serviceUrl) => {
const connection = connectionFactory.createConnection(
scope,
role,
{ type: "livekit", livekit_service_url: serviceUrl },
ownMembershipIdentity,
logger,
);
// Start the connection immediately
// Use connection state to track connection progress
void connection.start();
// TODO subscribe to connection state to retry or log issues?
return connection;
},
),
),
);
return scope.behavior(
connections$.pipe(
switchMap(({ value: connections, epoch }) => {
if (connections.length === 0)
return of(new Epoch(new ConnectionMap(), epoch));
return combineLatest(
// Map the connections to list of Observable<{connection, participants}>
connections.map((connection) =>
connection.remoteParticipants$.pipe(
map((participants) => ({ connection, participants })),
),
),
// Collect into a single ConnectionMap
(...list) => {
const map = new ConnectionMap(logger);
for (const item of list)
map.add(item.connection, item.participants);
return new Epoch(map, epoch);
},
);
}),
),
);
}
@@ -13,7 +13,6 @@ import {
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs"; import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs";
import { type IConnectionManager } from "./ConnectionManager.ts";
import { import {
type RemoteMatrixLivekitMember, type RemoteMatrixLivekitMember,
createRemoteMatrixLivekitMembers$, createRemoteMatrixLivekitMembers$,
@@ -24,7 +23,7 @@ import {
ObservableScope, ObservableScope,
trackEpoch, trackEpoch,
} from "../../ObservableScope.ts"; } from "../../ObservableScope.ts";
import { ConnectionManagerData } from "./ConnectionManager.ts"; import { ConnectionMap } from "./ConnectionMap.ts";
import { import {
flushPromises, flushPromises,
mockRtcMembership, mockRtcMembership,
@@ -78,23 +77,18 @@ function epochMeWith$<T, U>(
test("should signal participant not yet connected to livekit", async () => { test("should signal participant not yet connected to livekit", async () => {
const info = vi.spyOn(logger, "info"); const info = vi.spyOn(logger, "info");
const mockedMemberships$ = new BehaviorSubject([bobMembership]); const mockedMemberships$ = new BehaviorSubject([bobMembership]);
const mockConnectionManagerData$ = new BehaviorSubject( const mockConnectionMap$ = new BehaviorSubject(new ConnectionMap());
new ConnectionManagerData(),
);
const { memberships$, membershipsWithTransport$ } = const { memberships$, membershipsWithTransport$ } =
createEpochedMemberships$(mockedMemberships$); createEpochedMemberships$(mockedMemberships$);
const connectionManagerData$ = epochMeWith$( const connectionMap$ = testScope.behavior(
memberships$, epochMeWith$(memberships$, mockConnectionMap$),
mockConnectionManagerData$,
); );
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionMap$,
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember, localUser: localRtcMember,
}); });
@@ -152,22 +146,19 @@ test("should signal participant on a connection that is publishing", async () =>
const connection = { const connection = {
transport: bobMembership.getTransport(), transport: bobMembership.getTransport(),
} as unknown as Connection; } as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData(); const dataWithPublisher = new ConnectionMap();
dataWithPublisher.add(connection, [ dataWithPublisher.add(connection, [
mockRemoteParticipant({ identity: bobParticipantId }), mockRemoteParticipant({ identity: bobParticipantId }),
]); ]);
const connectionManagerData$ = epochMeWith$( const connectionMap$ = testScope.behavior(
memberships$, epochMeWith$(memberships$, constant(dataWithPublisher)),
constant(dataWithPublisher),
); );
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionMap$,
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember, localUser: localRtcMember,
}); });
@@ -195,20 +186,17 @@ test("should signal participant on a connection that is not publishing", async (
const connection = { const connection = {
transport: bobMembership.getTransport(), transport: bobMembership.getTransport(),
} as unknown as Connection; } as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData(); const dataWithPublisher = new ConnectionMap();
dataWithPublisher.add(connection, []); dataWithPublisher.add(connection, []);
const connectionManagerData$ = epochMeWith$( const connectionMap$ = testScope.behavior(
memberships$, epochMeWith$(memberships$, constant(dataWithPublisher)),
constant(dataWithPublisher),
); );
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionMap$,
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember, localUser: localRtcMember,
}); });
await flushPromises(); await flushPromises();
@@ -228,7 +216,7 @@ describe("Publication edge case", () => {
const { memberships$, membershipsWithTransport$ } = const { memberships$, membershipsWithTransport$ } =
createEpochedMemberships$(constant([bobMembership, carlMembership])); createEpochedMemberships$(constant([bobMembership, carlMembership]));
const connectionWithPublisher = new ConnectionManagerData(); const connectionWithPublisher = new ConnectionMap();
const bobParticipantId = fallbackMemberId( const bobParticipantId = fallbackMemberId(
bobMembership.userId, bobMembership.userId,
bobMembership.deviceId, bobMembership.deviceId,
@@ -247,17 +235,14 @@ describe("Publication edge case", () => {
mockRemoteParticipant({ identity: bobParticipantId }), mockRemoteParticipant({ identity: bobParticipantId }),
]); ]);
const connectionManagerData$ = epochMeWith$( const connectionMap$ = testScope.behavior(
memberships$, epochMeWith$(memberships$, constant(connectionWithPublisher)),
constant(connectionWithPublisher),
); );
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionMap$,
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember, localUser: localRtcMember,
}); });
await flushPromises(); await flushPromises();
@@ -287,7 +272,7 @@ test("bob is publishing in the wrong connection", async () => {
const { memberships$, membershipsWithTransport$ } = const { memberships$, membershipsWithTransport$ } =
createEpochedMemberships$(mockedMemberships$); createEpochedMemberships$(mockedMemberships$);
const connectionWithPublisher = new ConnectionManagerData(); const connectionWithPublisher = new ConnectionMap();
const bobParticipantId = fallbackMemberId( const bobParticipantId = fallbackMemberId(
bobMembership.userId, bobMembership.userId,
@@ -306,17 +291,14 @@ test("bob is publishing in the wrong connection", async () => {
const connectionsWithPublisher$ = new BehaviorSubject( const connectionsWithPublisher$ = new BehaviorSubject(
connectionWithPublisher, connectionWithPublisher,
); );
const connectionManagerData$ = epochMeWith$( const connectionMap$ = testScope.behavior(
memberships$, epochMeWith$(memberships$, connectionsWithPublisher$),
connectionsWithPublisher$,
); );
const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({
scope: testScope, scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: { connectionMap$,
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
localUser: localRtcMember, localUser: localRtcMember,
}); });
@@ -14,7 +14,7 @@ import { combineLatest, filter, map } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior"; import { type Behavior } from "../../Behavior";
import { type IConnectionManager } from "./ConnectionManager"; import { type ConnectionMap } from "./ConnectionMap";
import { Epoch, type ObservableScope } from "../../ObservableScope"; import { Epoch, type ObservableScope } from "../../ObservableScope";
import { type Connection } from "./Connection"; import { type Connection } from "./Connection";
import { generateItemsWithEpoch } from "../../../utils/observable"; import { generateItemsWithEpoch } from "../../../utils/observable";
@@ -62,7 +62,7 @@ interface Props {
membershipsWithTransport$: Behavior< membershipsWithTransport$: Behavior<
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]> Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
>; >;
connectionManager: IConnectionManager; connectionMap$: Behavior<Epoch<ConnectionMap>>;
localUser: { deviceId: string; userId: string }; localUser: { deviceId: string; userId: string };
} }
@@ -72,24 +72,21 @@ interface Props {
* It has a small public interface: * It has a small public interface:
* - in (via constructor): * - in (via constructor):
* - an observable of CallMembership[] to track the call members (The matrix side) * - an observable of CallMembership[] to track the call members (The matrix side)
* - a `ConnectionManager` for the lk rooms (The livekit side) * - a `ConnectionMap` for the lk rooms (The livekit side)
* - out (via public Observable): * - out (via public Observable):
* - `remoteMatrixLivekitMember` an observable of MatrixLivekitMember[] to track the remote members and associated livekit data. * - `remoteMatrixLivekitMember` an observable of MatrixLivekitMember[] to track the remote members and associated livekit data.
*/ */
export function createRemoteMatrixLivekitMembers$({ export function createRemoteMatrixLivekitMembers$({
scope, scope,
membershipsWithTransport$, membershipsWithTransport$,
connectionManager, connectionMap$,
localUser, localUser,
}: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> { }: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
/** /**
* Behavior of all the remote call members and their associated livekit data (if available). * Behavior of all the remote call members and their associated livekit data (if available).
*/ */
return scope.behavior( return scope.behavior(
combineLatest([ combineLatest([membershipsWithTransport$, connectionMap$]).pipe(
membershipsWithTransport$,
connectionManager.connectionManagerData$,
]).pipe(
filter((values) => filter((values) =>
values.every((value) => value.epoch === values[0].epoch), values.every((value) => value.epoch === values[0].epoch),
), ),
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { test, vi, expect, beforeEach, afterEach } from "vitest"; import { test, vi, expect, beforeEach, afterEach } from "vitest";
import { BehaviorSubject, NEVER } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { type Room as LivekitRoom } from "livekit-client"; import { type Room as LivekitRoom } from "livekit-client";
import EventEmitter from "events"; import EventEmitter from "events";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
@@ -21,6 +21,7 @@ import {
import { ECConnectionFactory } from "./ConnectionFactory.ts"; import { ECConnectionFactory } from "./ConnectionFactory.ts";
import { type ClientOpenIDParts } from "../../../livekit/auth"; import { type ClientOpenIDParts } from "../../../livekit/auth";
import { import {
exampleTransport,
mockMediaDevices, mockMediaDevices,
mockRtcMembership, mockRtcMembership,
ownMemberMock, ownMemberMock,
@@ -32,11 +33,11 @@ import {
createRemoteMatrixLivekitMembers$, createRemoteMatrixLivekitMembers$,
type RemoteMatrixLivekitMember, type RemoteMatrixLivekitMember,
} from "./MatrixLivekitMembers.ts"; } from "./MatrixLivekitMembers.ts";
import { createConnectionManager$ } from "./ConnectionManager.ts"; import { createConnectionMap$ } from "./ConnectionMap.ts";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts"; import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts"; import { localRtcMember, testJWTToken } from "../../../utils/test-fixtures.ts";
// Test the integration of ConnectionManager and MatrixLivekitMerger // Test the integration of ConnectionMap and MatrixLivekitMembers
let testScope: ObservableScope; let testScope: ObservableScope;
let ecConnectionFactory: ECConnectionFactory; let ecConnectionFactory: ECConnectionFactory;
@@ -122,10 +123,10 @@ test("bob, carl, then bob joining no tracks yet", () => {
memberships$, memberships$,
); );
const connectionManager = createConnectionManager$({ const connectionMap$ = createConnectionMap$({
scope: testScope, scope: testScope,
connectionFactory: ecConnectionFactory, connectionFactory: ecConnectionFactory,
localTransport$: NEVER, localTransport: exampleTransport,
remoteTransports$: membershipsAndTransports.transports$, remoteTransports$: membershipsAndTransports.transports$,
logger: logger, logger: logger,
ownMembershipIdentity: ownMemberMock, ownMembershipIdentity: ownMemberMock,
@@ -135,7 +136,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
scope: testScope, scope: testScope,
membershipsWithTransport$: membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$, membershipsAndTransports.membershipsWithTransport$,
connectionManager, connectionMap$,
localUser: localRtcMember, localUser: localRtcMember,
}); });
+22
View File
@@ -218,6 +218,28 @@ export function filterBehavior<T, S extends T>(
); );
} }
/**
* Applies a given projection to each value emitted by the source Observable.
* The `project` function additionally receives an {@link ObservableScope}.
*/
export function mapScoped<A, B>(
name: string,
project: (a: A, scope: ObservableScope) => B,
): OperatorFunction<A, B> {
return (a$) =>
a$.pipe(
generateItemsInternal(
name,
function* (a) {
yield { keys: [a], data: undefined };
},
(scope, _data$, a) => project(a, scope),
(items) => items,
),
map(([item]) => item),
);
}
function generateItemsInternal< function generateItemsInternal<
Input, Input,
Keys extends [unknown, ...unknown[]], Keys extends [unknown, ...unknown[]],