renames and restructure

- session -> matrixrtc (folder)
 - move MatrixRtcClient to top level of sdk
 - RtcTransport -> Transport
 - backend -> media-backend
This commit is contained in:
Timo K.
2026-10-08 13:53:57 +02:00
parent d0a2ff12f1
commit 5b13e1da81
51 changed files with 96 additions and 85 deletions
@@ -0,0 +1,336 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2024 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 { describe, it, expect, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "events";
import { ClientEvent, SyncState } from "matrix-js-sdk";
import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
import { ObservableScope } from "../reactive/ObservableScope";
import { createHomeserverConnected$ } from "./HomeserverConnected";
import { testScope, withTestScheduler } from "../utils/test";
/**
* Minimal stub of a Matrix client sufficient for our tests:
```
createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
)
```
*/
class MockMatrixClient extends EventEmitter {
private syncState: SyncState;
public constructor(initial: SyncState) {
super();
this.syncState = initial;
}
public setSyncState(state: SyncState): void {
this.syncState = state;
// Matrix's Sync event in createHomeserverConnected$ expects [SyncState]
this.emit(ClientEvent.Sync, [state]);
}
public getSyncState(): SyncState {
return this.syncState;
}
}
/**
* Minimal stub of MatrixRTCSession (membership manager):
```
createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
)
```
*/
class MockMatrixRTCSession extends EventEmitter {
public membershipStatus: Status;
public probablyLeft: boolean;
public constructor(props: {
membershipStatus: Status;
probablyLeft: boolean;
}) {
super();
this.membershipStatus = props.membershipStatus;
this.probablyLeft = props.probablyLeft;
}
public setMembershipStatus(status: Status): void {
this.membershipStatus = status;
this.emit(MembershipManagerEvent.StatusChanged);
}
public setProbablyLeft(flag: boolean): void {
this.probablyLeft = flag;
this.emit(MembershipManagerEvent.ProbablyLeft);
}
}
describe("createHomeserverConnected$", () => {
let scope: ObservableScope;
let client: MockMatrixClient;
let session: MockMatrixRTCSession;
beforeEach(() => {
scope = new ObservableScope();
client = new MockMatrixClient(SyncState.Error); // start disconnected
session = new MockMatrixRTCSession({
membershipStatus: Status.Disconnected,
probablyLeft: false,
});
});
afterEach(() => {
scope.end();
});
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
// easy enough to read them so I think they can stay.
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
it("reports syncing reason when sync state is not Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
});
it("reports membership reason when sync is Syncing but membership is not Connected", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
});
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Make sync loop OK
client.setSyncState(SyncState.Syncing);
// Indicate probable leave before connection
session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
});
it("becomes null (connected) only when all three conditions are satisfied", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// 1. Sync loop connected
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toEqual([false, "membership"]); // not yet membership connected
// 2. Membership connected
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]); // probablyLeft is false
});
it("returns syncing reason when sync loop leaves Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Reach connected state
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]);
// Sync loop error => should report syncing reason
client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
});
it("returns membershipConnected reason when membership status becomes disconnected", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]);
session.setMembershipStatus(Status.Disconnected);
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
});
it("returns certainlyConnected reason when ProbablyLeft is emitted", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]);
session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
});
it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]);
session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
// Simulate clearing the flag (in realistic scenario membership manager would update)
session.setProbablyLeft(false);
expect(hsConnected.combined$.value).toEqual([true, null]);
});
it("composite sequence reflects each individual failure reason", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Initially: sync error + membership disconnected → syncing wins (highest priority)
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
// Fix sync only → membershipConnected is now the blocker
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
// Fix membership → all conditions satisfied
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toEqual([true, null]);
// Introduce probablyLeft → certainlyConnected
session.setProbablyLeft(true);
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
// Restore notProbablyLeft → connected again
session.setProbablyLeft(false);
expect(hsConnected.combined$.value).toEqual([true, null]);
// Drop sync → syncing reason
client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
});
});
describe("createHomeserverConnected$ - combined$ reason values", () => {
let scope: ObservableScope;
let client: MockMatrixClient;
let session: MockMatrixRTCSession;
beforeEach(() => {
scope = new ObservableScope();
// Start with sync failing and membership disconnected
client = new MockMatrixClient(SyncState.Error);
session = new MockMatrixRTCSession({
membershipStatus: Status.Disconnected,
probablyLeft: false,
});
});
afterEach(() => {
scope.end();
});
it("is [true, null] when all three conditions are satisfied", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(combined$.value).toEqual([true, null]);
});
it("reports syncing when sync loop is not Syncing", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
// client starts with SyncState.Error, membership also disconnected
expect(combined$.value).toEqual([false, "sync"]);
});
it("reports membershipConnected when sync is fine but membership is not Connected", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
// session still Status.Disconnected
expect(combined$.value).toEqual([false, "membership"]);
});
it("reports certainlyConnected when probablyLeft is true", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
session.setProbablyLeft(true);
expect(combined$.value).toEqual([false, "probablyLeft"]);
});
it("prioritises syncing over membershipConnected when both fail", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
// Both sync (Error) and membership (Disconnected) are failing
expect(combined$.value).toEqual([false, "sync"]);
});
it("updates reason as conditions change", () => {
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
// Initially: syncing fails
expect(combined$.value).toEqual([false, "sync"]);
// Fix sync → membershipConnected is now the blocker
client.setSyncState(SyncState.Syncing);
expect(combined$.value).toEqual([false, "membership"]);
// Fix membership → probablyLeft makes certainlyConnected fail
session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected);
expect(combined$.value).toEqual([false, "probablyLeft"]);
// Clear probablyLeft → all conditions satisfied
session.setProbablyLeft(false);
expect(combined$.value).toEqual([true, null]);
});
});
describe("createHomeserverConnected$ - Grace Period", () => {
const GRACE_PERIOD = 5;
function marbleTest(
syncStateMarbles: string,
expectedConnectedMarbles: string,
): void {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
const syncState$ = behavior(syncStateMarbles, {
s: SyncState.Syncing,
e: SyncState.Error,
});
const client = new MockMatrixClient(syncState$.value);
schedule(syncStateMarbles, {
s: () => client.setSyncState(SyncState.Syncing),
e: () => client.setSyncState(SyncState.Error),
});
const session = new MockMatrixRTCSession({
membershipStatus: Status.Connected,
probablyLeft: false,
});
const hsConnected = createHomeserverConnected$(
testScope(),
client,
session,
GRACE_PERIOD,
);
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, {
y: [true, null],
n: [false, "sync"],
});
});
}
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
// - Initial state: Everything is connected
// - Sync error occurs -> should remain connected due to grace period
// - After grace period, not connected
marbleTest("se", "y-----n");
// If the sync error takes longer to occur, it should take equally long for
// the connection state to change
marbleTest("s--e", "y-------n");
});
it("recovers immediately if sync returns during grace period", () => {
// - Initial state: Connected
// - Sync error occurs
// - Sync recovers BEFORE the grace period expires
// - Connection state remains constant
marbleTest("se--s", "y");
});
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
// - Initial error: connection eventually flips to false
// - Back to Syncing -> Must be connected immediately (synchronously)
marbleTest("e-----s", "y----ny");
});
});
+143
View File
@@ -0,0 +1,143 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2024 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 {
MembershipManagerEvent,
Status,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import {
fromEvent,
startWith,
map,
tap,
type Observable,
distinctUntilChanged,
switchMap,
of,
delay,
combineLatest,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type Behavior } from "../reactive/Behavior";
import { defaultSessionTimings } from "../config";
type NodeEventHandler = (...args: unknown[]) => void;
interface NodeStyleEventEmitter {
addListener(eventName: string | symbol, handler: NodeEventHandler): this;
removeListener(eventName: string | symbol, handler: NodeEventHandler): this;
}
export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
export interface HomeserverConnected {
/**
* Emits `[true, null]` when the homeserver connection is healthy, or
* `[false, reason]` when one of the three sub-conditions fails.
*/
combined$: Behavior<[boolean, HomeserverDisconnectReason | null]>;
rtsSession$: Behavior<Status>;
}
/**
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
* for the purposes of a MatrixRTC session.
*
* `combined$` emits `null` when all conditions are satisfied, or the first failing
* reason (priority: syncing > membershipConnected > certainlyConnected):
* 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
* 2. membershipStatus !== Status.Connected → "membership"
* 3. probablyLeft === true → "probablyLeft"
*
* @param scope - The observable scope for lifecycle management.
* @param client - The Matrix client to monitor sync state.
* @param matrixRTCSession - The RTC session to monitor membership.
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
* If not provided, uses the config value (default 10000ms).
*/
export function createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
gracePeriodMs?: number,
): HomeserverConnected {
const logger = rootLogger.getChild("[HomeserverConnected]");
// Get grace period from parameter or config (default 10000ms)
const graceMs =
gracePeriodMs ?? defaultSessionTimings.syncDisconnectGracePeriodMs;
const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe(
startWith([client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
distinctUntilChanged(),
switchMap((isSyncing) => {
if (isSyncing || graceMs <= 0) {
return of(isSyncing);
}
return of(false).pipe(delay(graceMs), startWith(true));
}),
distinctUntilChanged(),
);
const rtsSession$ = scope.behavior<Status>(
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
),
matrixRTCSession.membershipStatus ?? Status.Unknown,
);
const membershipConnected$ = rtsSession$.pipe(
map((status) => status === Status.Connected),
);
// This is basically notProbablyLeft$
//
// probablyLeft is computed by a local timer that mimics the server delayed event.
// If we locally predict our server event timed out. We consider ourselves as probablyLeft
// even though we might not yet have received the delayed event leave.
//
// If that is not the case we certainly still have a valid membership on the matrix network
// independet if the sync currently works.
const certainlyConnected$ = fromEvent(
matrixRTCSession,
MembershipManagerEvent.ProbablyLeft,
).pipe(
startWith(null),
map(() => matrixRTCSession.probablyLeft !== true),
);
const combined$ = scope.behavior(
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
map(
([syncing, membership, certainly]): [
boolean,
HomeserverDisconnectReason | null,
] => {
if (!syncing) return [false, "sync"];
if (!membership) return [false, "membership"];
if (!certainly) return [false, "probablyLeft"];
return [true, null];
},
),
tap(([connected, reason]) => {
logger.info(
`Homeserver connected update: ${connected ? "connected" : reason}`,
);
}),
),
);
return { combined$, rtsSession$ };
}
@@ -0,0 +1,41 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { MatrixRTCSessionEvent } from "matrix-js-sdk/lib/matrixrtc";
import { EventEmitter } from "events";
import { createKeyRotationSuppressed$ } from "./JsRtcSessionBehaviors";
import { testScope } from "../utils/test";
describe("JsRtcSessionBehaviors", () => {
describe("createKeyRotationSuppressed$", () => {
it("emits initial value from isKeyRotationSuppressed and updates when KeyRotationSuppressedChanged event is emitted", () => {
const scope = testScope();
const emitter = new EventEmitter();
const mockSession = Object.assign(emitter, {
on: vi.fn(),
off: vi.fn(),
isKeyRotationSuppressed: false,
});
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
scope,
mockSession as any,
);
expect(keyRotationSuppressed$.value).toBe(false);
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, true);
expect(keyRotationSuppressed$.value).toBe(true);
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, false);
expect(keyRotationSuppressed$.value).toBe(false);
});
});
});
@@ -0,0 +1,53 @@
/*
Copyright 2025 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 CallMembership,
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { fromEvent } from "rxjs";
import {
Epoch,
trackEpoch,
type ObservableScope,
} from "../reactive/ObservableScope";
import { type Behavior } from "../reactive/Behavior";
export const createMemberships$ = (
scope: ObservableScope,
matrixRTCSession: MatrixRTCSession,
): Behavior<Epoch<CallMembership[]>> => {
return scope.behavior(
fromEvent(
matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
(_, memberships: CallMembership[]) => memberships,
).pipe(trackEpoch()),
new Epoch(matrixRTCSession.memberships),
);
};
/**
* Whether the session has grown large enough that MatrixRTC has stopped rotating the media
* encryption key. While this is true the key in use is still shared with new joiners, but no new
* key is generated when someone joins or leaves.
*/
export const createKeyRotationSuppressed$ = (
scope: ObservableScope,
matrixRTCSession: MatrixRTCSession,
): Behavior<boolean> => {
return scope.behavior(
fromEvent(
matrixRTCSession,
MatrixRTCSessionEvent.KeyRotationSuppressedChanged,
(suppressed: boolean) => suppressed,
),
matrixRTCSession.isKeyRotationSuppressed,
);
};
+215
View File
@@ -0,0 +1,215 @@
/*
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 { Status, type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, type Observable, Subject, throwError } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { defaultSessionTimings } from "../config";
import { MatrixRTCError } from "../errors";
import {
type LocalMediaBackend,
MediaConnectionState,
} from "../media-backend/api";
import { type HomeserverConnected } from "./HomeserverConnected";
import {
createLocalMembership$,
type LocalMembership,
type PreparedTransport,
PublishState,
TransportState,
} from "./LocalMember";
import { flushPromises, testScope } from "../utils/test";
const transport: Transport = { type: "livekit", livekit_service_url: "u" };
describe("createLocalMembership$", () => {
it("waits for the transport, then reports the media connection and the join", async () => {
const { membership, local, prepared$ } = setup();
expect(membership.state$.value).toBe(TransportState.Waiting);
prepared$.next({ transport, canDelegateDelayedLeave: false });
expect(membership.state$.value).toEqual({
media: { connection: MediaConnectionState.Initialized },
matrix: Status.Connected,
});
local.connectionState$.next(MediaConnectionState.Connected);
expect(membership.state$.value).toEqual({
media: PublishState.WaitingForUser,
matrix: Status.Connected,
});
membership.requestJoinAndPublish();
await flushPromises();
expect(membership.state$.value).toEqual({
media: PublishState.Publishing,
matrix: Status.Connected,
});
expect(membership.connected$.value).toBe(true);
});
it("publishes only while joined and the homeserver is reachable", async () => {
const { membership, local, homeserver } = setup();
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
membership.requestJoinAndPublish();
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
homeserver.combined$.next([false, "sync"]);
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
homeserver.combined$.next([true, null]);
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
membership.requestDisconnect();
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
// A sync outage after leaving must not bring the media back
homeserver.combined$.next([false, "sync"]);
homeserver.combined$.next([true, null]);
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
await flushPromises();
});
it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
const { membership, prepared$, delayId$, joinMatrixRTC, delegate } =
setup();
prepared$.next({ transport, canDelegateDelayedLeave: true });
membership.requestJoinAndPublish();
await flushPromises();
expect(joinMatrixRTC).toHaveBeenCalledWith(
transport,
defaultSessionTimings.delegatedDelayedLeave,
);
delayId$.next("delay-1");
await flushPromises();
expect(delegate).toHaveBeenCalledWith("delay-1");
delayId$.next("delay-2");
await flushPromises();
expect(delegate).toHaveBeenLastCalledWith("delay-2");
});
it("never hands the leave over where the backend cannot take it", async () => {
const { membership, prepared$, delayId$, joinMatrixRTC, delegate } =
setup();
prepared$.next({ transport, canDelegateDelayedLeave: false });
membership.requestJoinAndPublish();
delayId$.next("delay-1");
await flushPromises();
expect(joinMatrixRTC).toHaveBeenCalledWith(
transport,
defaultSessionTimings.delayedLeave,
);
expect(delegate).not.toHaveBeenCalled();
});
it("reports a transport that could not be prepared as fatal", () => {
const error = new MatrixRTCError("no token");
const { membership } = setup(throwError(() => error));
expect(membership.state$.value).toBe(error);
});
it("reports a connection loss and a publish failure in the media state", () => {
const { membership, local, prepared$ } = setup();
prepared$.next({ transport, canDelegateDelayedLeave: false });
local.connectionState$.next(MediaConnectionState.Connected);
membership.requestJoinAndPublish();
local.connectionState$.next(MediaConnectionState.Reconnecting);
expect(membership.state$.value).toEqual({
media: { connection: MediaConnectionState.Reconnecting },
matrix: Status.Connected,
});
expect(membership.disconnectReason$.value).toBe("media");
expect(membership.reconnecting$.value).toBe(true);
const failure = new MatrixRTCError("no camera");
local.publishError$.next(failure);
const state = membership.state$.value;
expect(typeof state === "object" && "media" in state && state.media).toBe(
failure,
);
});
});
function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): {
membership: LocalMembership;
local: FakeLocalMediaBackend;
homeserver: FakeHomeserverConnected;
prepared$: Subject<PreparedTransport>;
delayId$: BehaviorSubject<string | null>;
joinMatrixRTC: ReturnType<typeof vi.fn>;
delegate: ReturnType<typeof vi.fn>;
} {
const prepared$ = new Subject<PreparedTransport>();
const local = fakeLocalMediaBackend();
const homeserver = fakeHomeserverConnected();
const delayId$ = new BehaviorSubject<string | null>(null);
const joinMatrixRTC = vi.fn();
const delegate = vi.fn(async () => Promise.resolve());
const membership = createLocalMembership$({
scope: testScope(),
local,
delegateDelayedLeave: delegate,
preparedTransport$: preparedTransportWithErrors$ ?? prepared$,
homeserverConnected: homeserver,
joinMatrixRTC,
membershipManagerError$: new Subject(),
matrixRTCSession: {
leaveRoomSession: vi.fn(async () => Promise.resolve(true)),
},
delayId$,
timings: defaultSessionTimings,
logger,
});
return {
membership,
local,
homeserver,
prepared$,
delayId$,
joinMatrixRTC,
delegate,
};
}
type FakeLocalMediaBackend = Omit<
LocalMediaBackend,
"connectionState$" | "publishError$" | "setPublishing"
> & {
connectionState$: BehaviorSubject<MediaConnectionState | Error>;
publishError$: BehaviorSubject<Error | null>;
setPublishing: ReturnType<typeof vi.fn<LocalMediaBackend["setPublishing"]>>;
};
function fakeLocalMediaBackend(): FakeLocalMediaBackend {
return {
connectionState$: new BehaviorSubject<MediaConnectionState | Error>(
MediaConnectionState.Initialized,
),
media$: new BehaviorSubject(null),
setPublishing: vi.fn<LocalMediaBackend["setPublishing"]>(),
publishError$: new BehaviorSubject<Error | null>(null),
publish: vi.fn<LocalMediaBackend["publish"]>(),
unpublish: vi.fn<LocalMediaBackend["unpublish"]>(),
};
}
type FakeHomeserverConnected = Omit<HomeserverConnected, "combined$"> & {
combined$: BehaviorSubject<HomeserverConnected["combined$"]["value"]>;
};
function fakeHomeserverConnected(): FakeHomeserverConnected {
return {
combined$: new BehaviorSubject<HomeserverConnected["combined$"]["value"]>([
true,
null,
]),
rtsSession$: new BehaviorSubject(Status.Connected),
};
}
+271
View File
@@ -0,0 +1,271 @@
/*
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 {
type MatrixRTCSession as JsSdkRTCSession,
type Status as RTCSessionStatus,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import {
BehaviorSubject,
catchError,
combineLatest,
concat,
distinctUntilChanged,
map,
NEVER,
type Observable,
of,
pairwise,
race,
Subject,
switchMap,
} from "rxjs";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type DelayedLeaveTimings, type SessionTimings } from "../config";
import { type DisconnectReason } from "../api";
import {
type MatrixRTCError,
MembershipManagerError,
toMatrixRTCError,
} from "../errors";
import {
type LocalMediaBackend,
type MediaBackend,
MediaConnectionState,
type TransportCapabilities,
} from "../media-backend/api";
import { type HomeserverConnected } from "./HomeserverConnected";
export enum TransportState {
Waiting = "transport_waiting",
}
export enum PublishState {
WaitingForUser = "publish_waiting_for_user",
Publishing = "publish_publishing",
}
export type LocalMemberMediaState =
| { connection: MediaConnectionState | MatrixRTCError }
| PublishState
| MatrixRTCError;
export type LocalMemberState =
| MatrixRTCError
| TransportState.Waiting
| { media: LocalMemberMediaState; matrix: MatrixRTCError | RTCSessionStatus };
/** The local transport, found and prepared, with what the join needs to know about it. */
export interface PreparedTransport extends TransportCapabilities {
transport: Transport;
}
interface Props {
scope: ObservableScope;
local: LocalMediaBackend;
delegateDelayedLeave: MediaBackend["delegateDelayedLeave"];
preparedTransport$: Observable<PreparedTransport>;
homeserverConnected: HomeserverConnected;
joinMatrixRTC: (
transport: Transport,
delayedLeave: DelayedLeaveTimings,
) => void;
/** The membership manager giving up on keeping the membership alive. */
membershipManagerError$: Observable<unknown>;
matrixRTCSession: Pick<JsSdkRTCSession, "leaveRoomSession">;
/** The id of the delayed leave event, for the backend to take over. */
delayId$: Behavior<string | null>;
timings: SessionTimings;
logger: Logger;
}
export interface LocalMembership {
requestJoinAndPublish: () => void;
requestDisconnect: () => void;
joinRequested$: Behavior<boolean>;
state$: Behavior<LocalMemberState>;
/** Fully connected: to the homeserver, the session and the transport. */
connected$: Behavior<boolean>;
/** Connected once, and currently not. */
reconnecting$: Behavior<boolean>;
disconnectReason$: Behavior<DisconnectReason | null>;
}
/**
* The local member's state machine: waits for the transport, publishes once
* asked to join, and enters and leaves the MatrixRTC session in step.
*/
export function createLocalMembership$({
scope,
local,
delegateDelayedLeave,
preparedTransport$: preparedTransportWithErrors$,
homeserverConnected,
joinMatrixRTC,
membershipManagerError$,
matrixRTCSession,
delayId$,
timings,
logger: parentLogger,
}: Props): LocalMembership {
const logger = parentLogger.getChild("[LocalMember]");
const fatalTransportError$ = new Subject<MatrixRTCError>();
const preparedTransport$ = preparedTransportWithErrors$.pipe(
catchError((e: unknown) => {
fatalTransportError$.next(toMatrixRTCError(e));
return NEVER;
}),
);
const transport$ = scope.behavior<PreparedTransport | null>(
preparedTransport$,
null,
);
const joinRequested$ = new BehaviorSubject(false);
const matrixError$ = new BehaviorSubject<MatrixRTCError | null>(null);
// Nothing leaves this device while it may already have been dropped from
// the session: the member would show as away while still being heard
combineLatest(
[joinRequested$, homeserverConnected.combined$],
(join, [connected]) => join && connected,
)
.pipe(distinctUntilChanged(), scope.bind())
.subscribe((publish) => local.setPublishing(publish));
scope.reconcile(
scope.behavior(combineLatest([transport$, joinRequested$])),
async ([prepared, shouldJoin]) => {
if (prepared === null || !shouldJoin) return;
try {
joinMatrixRTC(
prepared.transport,
prepared.canDelegateDelayedLeave
? timings.delegatedDelayedLeave
: timings.delayedLeave,
);
} catch (e) {
logger.error("Failed to enter the session", e);
if (matrixError$.value === null) matrixError$.next(toMatrixRTCError(e));
}
return Promise.resolve(async (): Promise<void> => {
try {
await matrixRTCSession.leaveRoomSession(1000);
} catch (e) {
logger.error("Failed to leave the session", e);
}
});
},
);
scope.reconcile(
scope.behavior(combineLatest([transport$, delayId$])),
async ([prepared, delayId]) => {
if (!prepared?.canDelegateDelayedLeave || delayId === null) return;
try {
await delegateDelayedLeave(delayId);
} catch (e) {
logger.error("Failed to delegate the leave", e);
}
},
);
membershipManagerError$.pipe(scope.bind()).subscribe((e) => {
logger.error("The membership manager stopped", e);
if (matrixError$.value === null)
matrixError$.next(
new MembershipManagerError(
e instanceof Error ? e : new Error(String(e)),
),
);
});
const mediaState$ = scope.behavior<LocalMemberMediaState>(
combineLatest([local.connectionState$, joinRequested$]).pipe(
map(([connectionState, shouldPublish]) => {
if (connectionState !== MediaConnectionState.Connected)
return {
connection:
connectionState instanceof Error
? toMatrixRTCError(connectionState)
: connectionState,
};
return shouldPublish
? PublishState.Publishing
: PublishState.WaitingForUser;
}),
distinctUntilChanged(deepCompare),
),
);
const state$ = scope.behavior<LocalMemberState>(
concat(
of(TransportState.Waiting),
race(
fatalTransportError$,
preparedTransport$.pipe(
switchMap(() =>
combineLatest(
[
mediaState$,
homeserverConnected.rtsSession$,
matrixError$,
local.publishError$,
],
(media, sessionStatus, matrixError, publishError) => ({
matrix: matrixError ?? sessionStatus,
media: publishError ? toMatrixRTCError(publishError) : media,
}),
),
),
),
),
),
);
const disconnectReason$ = scope.behavior(
combineLatest(
[homeserverConnected.combined$, local.connectionState$],
(
[homeserverConnected, reason],
connectionState,
): DisconnectReason | null => {
if (!homeserverConnected) return reason ?? "sync";
if (connectionState !== MediaConnectionState.Connected) return "media";
return null;
},
),
);
const connected$ = scope.behavior(
disconnectReason$.pipe(map((reason) => reason === null)),
);
const reconnecting$ = scope.behavior(
connected$.pipe(
pairwise(),
map(([was, is]) => was && !is),
),
false,
);
return {
requestJoinAndPublish: () => joinRequested$.next(true),
requestDisconnect: () => joinRequested$.next(false),
joinRequested$,
state$,
connected$,
reconnecting$,
disconnectReason$,
};
}
+44
View File
@@ -0,0 +1,44 @@
/*
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 MatrixClient } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixRTCClientOptions } from "../api";
import { MatrixRTCTransportMissingError } from "../errors";
import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
/**
* The transport the local member advertises: the one the host names, else the
* homeserver's first of the type the backend serves, else the host's fallback.
* The two URL options are LiveKit service URLs, and turning one into a
* membership transport is the one piece of LiveKit knowledge outside the
* backend. It stays here, for hosts that configure a URL rather than a
* transport, and goes once the options take a `Transport`.
*/
export async function discoverLocalTransport(
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
transportType: string,
{
transportUrl,
fallbackTransportUrl,
}: Pick<MatrixRTCClientOptions, "transportUrl" | "fallbackTransportUrl">,
logger: Logger,
): Promise<Transport> {
const transport = transportUrl
? { type: "livekit", livekit_service_url: transportUrl }
: await new TransportAutoDiscovery({
client,
transportType,
fallbackTransportUrl,
logger,
}).discoverPreferredTransport();
if (transport === null)
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
return transport;
}
@@ -0,0 +1,632 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, vi } from "vitest";
import {
type MatrixEvent,
type RoomMember,
type RoomState,
RoomStateEvent,
} from "matrix-js-sdk";
import EventEmitter from "events";
import { it } from "vitest";
import { ObservableScope } from "../reactive/ObservableScope";
import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room";
import {
mockRtcMembership,
mockMatrixRoomMember,
withTestScheduler,
} from "../utils/test";
import {
createMatrixMemberMetadata$,
createRoomMembers$,
} from "./MatrixMemberMetadata.ts";
let testScope: ObservableScope;
let mockMatrixRoom: MatrixRoom;
describe("MatrixMemberMetadata", () => {
/*
* To be populated in the test setup.
* Maps userId to a partial/mock RoomMember object.
*/
let fakeMembersMap: Map<string, Partial<RoomMember>>;
beforeEach(() => {
testScope = new ObservableScope();
fakeMembersMap = new Map<string, Partial<RoomMember>>();
const roomEmitter = new EventEmitter();
mockMatrixRoom = {
on: roomEmitter.on.bind(roomEmitter),
off: roomEmitter.off.bind(roomEmitter),
emit: roomEmitter.emit.bind(roomEmitter),
// addListener: roomEmitter.addListener.bind(roomEmitter),
// removeListener: roomEmitter.removeListener.bind(roomEmitter),
getMember: vi.fn().mockImplementation((userId: string) => {
const member = fakeMembersMap.get(userId);
if (member) {
return member as RoomMember;
}
return null;
}),
getMembers: vi.fn().mockImplementation(() => {
const members = Array.from(fakeMembersMap.values());
return members;
}),
getMembersWithMembership: vi.fn().mockImplementation(() => {
const members = Array.from(fakeMembersMap.values());
return members;
}),
} as unknown as MatrixRoom;
});
function fakeMemberWith(data: Partial<RoomMember>): void {
const userId = data.userId || "@alice:example.com";
const member: Partial<RoomMember> = {
userId: userId,
rawDisplayName: data.rawDisplayName ?? userId,
getMxcAvatarUrl:
data.getMxcAvatarUrl ||
vi.fn().mockImplementation(() => {
return `mxc://example.com/${userId}`;
}),
...data,
} as unknown as RoomMember;
fakeMembersMap.set(userId, member);
}
afterEach(() => {
fakeMembersMap.clear();
});
describe("displayname", () => {
function updateDisplayName(
userId: `@${string}:${string}`,
newDisplayName: string,
): void {
const member = fakeMembersMap.get(userId);
if (member) {
member.rawDisplayName = newDisplayName;
// Emit the event to notify listeners
mockMatrixRoom.emit(
RoomStateEvent.Members,
{} as unknown as MatrixEvent,
{} as unknown as RoomState,
member as RoomMember,
);
} else {
throw new Error(`No member found with userId: ${userId}`);
}
}
it("should show our own user if present in rtc session and room", () => {
withTestScheduler(({ scope, behavior, expectObservable }) => {
fakeMemberWith({
userId: "@local:example.com",
rawDisplayName: "it's a me",
});
const memberships$ = behavior("a", {
a: [mockRtcMembership("@local:example.com", "DEVICE1")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const dn$ = metadataStore.createDisplayNameBehavior$(
scope,
"@local:example.com",
);
expectObservable(dn$).toBe("a", {
a: "it's a me",
});
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([["@local:example.com", "it's a me"]]),
});
});
});
function setUpBasicRoom(): void {
fakeMemberWith({
userId: "@local:example.com",
rawDisplayName: "it's a me",
});
fakeMemberWith({ userId: "@alice:example.com", rawDisplayName: "Alice" });
fakeMemberWith({ userId: "@bob:example.com", rawDisplayName: "Bob" });
fakeMemberWith({ userId: "@carl:example.com", rawDisplayName: "Carl" });
fakeMemberWith({ userId: "@evil:example.com", rawDisplayName: "Carl" });
fakeMemberWith({ userId: "@bob:foo.bar", rawDisplayName: "Bob" });
fakeMemberWith({ userId: "@no-name:foo.bar" });
}
it("should get displayName for users", () => {
setUpBasicRoom();
withTestScheduler(({ scope, behavior, expectObservable }) => {
const memberships$ = behavior("a", {
a: [
mockRtcMembership("@alice:example.com", "DEVICE1"),
mockRtcMembership("@bob:example.com", "DEVICE1"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const aliceDispName$ = metadataStore.createDisplayNameBehavior$(
scope,
"@alice:example.com",
);
expectObservable(aliceDispName$).toBe("a", {
a: "Alice",
});
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([
["@alice:example.com", "Alice"],
["@bob:example.com", "Bob"],
]),
});
});
});
it("should use userId if no display name", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("a", {
a: [mockRtcMembership("@no-name:foo.bar", "D000")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([
["@no-name:foo.bar", "@no-name:foo.bar"],
]),
});
});
});
it("should disambiguate users with same display name", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("a", {
a: [
mockRtcMembership("@bob:example.com", "DEVICE1"),
mockRtcMembership("@bob:example.com", "DEVICE2"),
mockRtcMembership("@bob:foo.bar", "BOB000"),
mockRtcMembership("@carl:example.com", "C000"),
mockRtcMembership("@evil:example.com", "E000"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([
// ["@local:example.com", "it's a me"],
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
["@carl:example.com", "Carl (@carl:example.com)"],
["@evil:example.com", "Carl (@evil:example.com)"],
]),
});
});
});
it("should start to disambiguate reactivly when needed", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("ab", {
a: [mockRtcMembership("@bob:example.com", "DEVICE1")],
b: [
mockRtcMembership("@bob:example.com", "DEVICE1"),
mockRtcMembership("@bob:foo.bar", "BOB000"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
a: new Map<string, string>([["@bob:example.com", "Bob"]]),
b: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
]),
});
});
});
it("should keep disambiguated name when other leave", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("ab", {
a: [
mockRtcMembership("@bob:example.com", "DEVICE1"),
mockRtcMembership("@bob:foo.bar", "BOB000"),
],
b: [mockRtcMembership("@bob:example.com", "DEVICE1")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
a: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
]),
b: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
]),
});
});
});
it("should disambiguate on name change", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("a", {
a: [
mockRtcMembership("@bob:example.com", "B000"),
mockRtcMembership("@carl:example.com", "C000"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
schedule("-a", {
a: () => {
updateDisplayName("@carl:example.com", "Bob");
},
});
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
a: new Map<string, string>([
["@bob:example.com", "Bob"],
["@carl:example.com", "Carl"],
]),
b: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
["@carl:example.com", "Bob (@carl:example.com)"],
]),
});
});
});
it("should track individual member id with createDisplayNameBehavior", () => {
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
setUpBasicRoom();
const BOB = "@bob:example.com";
const CARL = "@carl:example.com";
// for this test we build a mock environment that does all possible changes:
// - memberships join/leave
// - room join/leave
// - disambiguate
const memberships$ = behavior("ab-d", {
a: [mockRtcMembership(CARL, "C000")],
b: [
mockRtcMembership(CARL, "C000"),
// bob joins
mockRtcMembership(BOB, "B000"),
],
// c carl gets renamed to BOB
d: [
// carl leaves
mockRtcMembership(BOB, "B000"),
],
});
schedule("--a-", {
a: () => {
// carl renames
updateDisplayName(CARL, "Bob");
},
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const bob$ = metadataStore.createDisplayNameBehavior$(scope, BOB);
const carl$ = metadataStore.createDisplayNameBehavior$(scope, CARL);
expectObservable(bob$).toBe("abc-", {
a: undefined,
b: "Bob",
c: "Bob (@bob:example.com)",
// bob stays disambiguate even though carl left
// d: "Bob (@bob:example.com)",
});
expectObservable(carl$).toBe("a-cd", {
a: "Carl",
// b: "Carl",
// carl gets renamed and disambiguate
c: "Bob (@carl:example.com)",
d: undefined,
});
});
});
it("should disambiguate users with invisible characters", () => {
withTestScheduler(({ scope, behavior, expectObservable }) => {
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
const bobZeroWidthSpaceRtcMember = mockRtcMembership(
"@bob2:example.org",
"BBBB",
);
const bob = mockMatrixRoomMember(bobRtcMember, {
rawDisplayName: "Bob",
});
const bobZeroWidthSpace = mockMatrixRoomMember(
bobZeroWidthSpaceRtcMember,
{
rawDisplayName: "Bo\u200bb",
},
);
fakeMemberWith(bob);
fakeMemberWith(bobZeroWidthSpace);
fakeMemberWith({ userId: "@carol:example.org" });
const memberships$ = behavior("ab", {
a: [mockRtcMembership("@carol:example.org", "1111"), bobRtcMember],
b: [
mockRtcMembership("@carol:example.org", "1111"),
bobRtcMember,
bobZeroWidthSpaceRtcMember,
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const bob$ = metadataStore.createDisplayNameBehavior$(
scope,
"@bob:example.org",
);
const bob2$ = metadataStore.createDisplayNameBehavior$(
scope,
"@bob2:example.org",
);
const carol$ = metadataStore.createDisplayNameBehavior$(
scope,
"@carol:example.org",
);
expectObservable(bob$).toBe("ab", {
a: "Bob",
b: "Bob (@bob:example.org)",
});
expectObservable(bob2$).toBe("ab", {
a: undefined,
b: "Bo\u200bb (@bob2:example.org)",
});
expectObservable(carol$).toBe("a-", {
a: "@carol:example.org",
});
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
// Carol has no displayname - So userId is used.
a: new Map([
["@carol:example.org", "@carol:example.org"],
["@bob:example.org", "Bob"],
]),
// Other Bob joins, and should handle zero width hacks.
b: new Map([
["@carol:example.org", "@carol:example.org"],
[bobRtcMember.userId, `Bob (@bob:example.org)`],
[
bobZeroWidthSpace.userId,
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
],
]),
});
});
});
it("should strip RTL characters from displayname", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
const daveRTLRtcMember = mockRtcMembership(
"@dave2:example.org",
"DDDD",
);
const dave = mockMatrixRoomMember(daveRtcMember, {
rawDisplayName: "Dave",
});
const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
rawDisplayName: "\u202eevaD",
});
fakeMemberWith({ userId: "@carol:example.org" });
fakeMemberWith(daveRTL);
fakeMemberWith(dave);
const memberships$ = behavior("ab", {
a: [mockRtcMembership("@carol:example.org", "DDDD")],
b: [
mockRtcMembership("@carol:example.org", "DDDD"),
daveRtcMember,
daveRTLRtcMember,
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
// Carol has no displayname - So userId is used.
a: new Map([["@carol:example.org", "@carol:example.org"]]),
// Both Dave's join. Since after stripping
b: new Map([
["@carol:example.org", "@carol:example.org"],
// Not disambiguated
["@dave:example.org", "Dave"],
// This one is, since it's using RTL.
["@dave2:example.org", "evaD (@dave2:example.org)"],
]),
});
});
});
});
describe("avatarUrl", () => {
function updateAvatarUrl(
userId: `@${string}:${string}`,
avatarUrl: string,
): void {
const member = fakeMembersMap.get(userId);
if (member) {
member.getMxcAvatarUrl = vi.fn().mockReturnValue(avatarUrl);
// Emit the event to notify listeners
mockMatrixRoom.emit(
RoomStateEvent.Members,
{} as unknown as MatrixEvent,
{} as unknown as RoomState,
member as RoomMember,
);
} else {
throw new Error(`No member found with userId: ${userId}`);
}
}
it("should use avatar url from room members", () => {
withTestScheduler(({ scope, behavior, expectObservable }) => {
fakeMemberWith({
userId: "@local:example.com",
});
fakeMemberWith({
userId: "@alice:example.com",
getMxcAvatarUrl: vi.fn().mockReturnValue("mxc://custom.url/avatar"),
});
const memberships$ = behavior("a", {
a: [
mockRtcMembership("@local:example.com", "DEVICE1"),
mockRtcMembership("@alice:example.com", "DEVICE1"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const local$ = metadataStore.createAvatarUrlBehavior$(
scope,
"@local:example.com",
);
const alice$ = metadataStore.createAvatarUrlBehavior$(
scope,
"@alice:example.com",
);
expectObservable(local$).toBe("a", {
a: "mxc://example.com/@local:example.com",
});
expectObservable(alice$).toBe("a", {
a: "mxc://custom.url/avatar",
});
expectObservable(metadataStore.avatarMap$).toBe("a", {
a: new Map<string, string>([
["@local:example.com", "mxc://example.com/@local:example.com"],
["@alice:example.com", "mxc://custom.url/avatar"],
]),
});
});
});
it("should update on avatar change and user join/leave", () => {
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
fakeMemberWith({ userId: "@carl:example.com" });
fakeMemberWith({ userId: "@bob:example.com" });
const memberships$ = behavior("ab-d", {
a: [mockRtcMembership("@bob:example.com", "B000")],
b: [
mockRtcMembership("@bob:example.com", "B000"),
mockRtcMembership("@carl:example.com", "C000"),
],
d: [mockRtcMembership("@carl:example.com", "C000")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
schedule("--c-", {
c: () => {
updateAvatarUrl(
"@carl:example.com",
"mxc://updated.me/updatedAvatar",
);
},
});
const bob$ = metadataStore.createAvatarUrlBehavior$(
scope,
"@bob:example.com",
);
const carl$ = metadataStore.createAvatarUrlBehavior$(
scope,
"@carl:example.com",
);
expectObservable(bob$).toBe("a---", {
a: "mxc://example.com/@bob:example.com",
});
expectObservable(carl$).toBe("a-c-", {
a: "mxc://example.com/@carl:example.com",
c: "mxc://updated.me/updatedAvatar",
});
expectObservable(metadataStore.avatarMap$).toBe("a-c-", {
a: new Map<string, string>([
["@bob:example.com", "mxc://example.com/@bob:example.com"],
["@carl:example.com", "mxc://example.com/@carl:example.com"],
]),
// expect an update once we update the avatar URL
c: new Map<string, string>([
["@bob:example.com", "mxc://example.com/@bob:example.com"],
["@carl:example.com", "mxc://updated.me/updatedAvatar"],
]),
});
});
});
});
});
+157
View File
@@ -0,0 +1,157 @@
/*
Copyright 2025 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 RoomMember, RoomStateEvent } from "matrix-js-sdk";
import { combineLatest, fromEvent, map } from "rxjs";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
KnownMembership,
type Room as MatrixRoom,
} from "matrix-js-sdk/lib/matrix";
// eslint-disable-next-line rxjs/no-internal
import { type ObservableScope } from "../reactive/ObservableScope";
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type Behavior } from "../reactive/Behavior";
export type RoomMemberMap = Map<
string,
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
>;
export function roomToMembersMap(matrixRoom: MatrixRoom): RoomMemberMap {
const members = matrixRoom
.getMembersWithMembership(KnownMembership.Join)
.concat(matrixRoom.getMembersWithMembership(KnownMembership.Invite));
return members.reduce((acc, member) => {
acc.set(member.userId, {
userId: member.userId,
getMxcAvatarUrl: member.getMxcAvatarUrl.bind(member),
rawDisplayName: member.rawDisplayName,
});
return acc;
}, new Map());
}
export function createRoomMembers$(
scope: ObservableScope,
matrixRoom: MatrixRoom,
): Behavior<RoomMemberMap> {
return scope.behavior(
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(
map(() => roomToMembersMap(matrixRoom)),
),
roomToMembersMap(matrixRoom),
);
}
/**
* Displayname for each member of the call. This will disambiguate
* any displayname that clashes with another member. Only members
* joined to the call are considered here.
*
* @returns Map<userId, displayname> uses the Matrix user ID as the key.
*/
// don't do this work more times than we need to. This is achieved by converting to a behavior:
export const memberDisplaynames$ = (
scope: ObservableScope,
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
roomMembers$: Behavior<RoomMemberMap>,
): Behavior<Map<string, string>> => {
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
// This map tracks userIds that at some point needed disambiguation.
// This is a memory leak bound to the number of participants.
// A call application will always increase the memory if there have been more members in a call.
// Its capped by room member participants.
const shouldDisambiguateTrackerMap = new Set<string>();
return scope.behavior(
combineLatest([
// Handle call membership changes
memberships$,
// Additionally handle display name changes (implicitly reacting to them)
roomMembers$,
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
]).pipe(
map(([memberships, roomMembers]) => {
const displaynameMap = new Map<string, string>();
// We only consider RTC members for disambiguation as they are the only visible members.
for (const rtcMember of memberships) {
const member = roomMembers.get(rtcMember.userId);
if (!member) {
logger.error(`Could not find member for user ${rtcMember.userId}`);
continue;
}
const disambiguateComputed = shouldDisambiguate(
member,
memberships,
roomMembers,
);
const disambiguate =
shouldDisambiguateTrackerMap.has(rtcMember.userId) ||
disambiguateComputed;
if (disambiguate) shouldDisambiguateTrackerMap.add(rtcMember.userId);
displaynameMap.set(
rtcMember.userId,
calculateDisplayName(member, disambiguate),
);
}
return displaynameMap;
}),
),
);
};
export const createMatrixMemberMetadata$ = (
scope: ObservableScope,
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
roomMembers$: Behavior<RoomMemberMap>,
): {
createDisplayNameBehavior$: (
scope: ObservableScope,
userId: string,
) => Behavior<string | undefined>;
createAvatarUrlBehavior$: (
scope: ObservableScope,
userId: string,
) => Behavior<string | undefined>;
displaynameMap$: Behavior<Map<string, string>>;
avatarMap$: Behavior<Map<string, string | undefined>>;
} => {
const displaynameMap$ = memberDisplaynames$(
scope,
memberships$,
roomMembers$,
);
const avatarMap$ = scope.behavior(
roomMembers$.pipe(
map((roomMembers) =>
Array.from(roomMembers.keys()).reduce((acc, key) => {
acc.set(key, roomMembers.get(key)?.getMxcAvatarUrl());
return acc;
}, new Map<string, string | undefined>()),
),
),
);
return {
createDisplayNameBehavior$: (scope: ObservableScope, userId: string) =>
scope.behavior(
displaynameMap$.pipe(
map((displaynameMap) => displaynameMap.get(userId)),
),
),
createAvatarUrlBehavior$: (scope: ObservableScope, userId: string) =>
scope.behavior(
roomMembers$.pipe(
map((roomMembers) => roomMembers.get(userId)?.getMxcAvatarUrl()),
),
),
// mostly for testing purposes
displaynameMap$,
avatarMap$,
};
};
+94
View File
@@ -0,0 +1,94 @@
/*
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 CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { distinctUntilChanged, map } from "rxjs";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type createMatrixMemberMetadata$ } from "./MatrixMemberMetadata";
import {
type LocalRTCMember,
type MemberMedia,
type RemoteRTCMember,
type RTCMember,
} from "../api";
import { type LocalMediaBackend } from "../media-backend/api";
import { type TransportRegistry } from "./Transports";
/** What every member is built from, besides its own membership and media. */
export interface MemberContext {
metadata: ReturnType<typeof createMatrixMemberMetadata$>;
transports: TransportRegistry;
}
/** Everything that tells one membership from another, for keying items. */
export function membershipKeys(
membership: CallMembership,
): [string, string, string, string] {
return [
membership.userId,
membership.deviceId,
membership.memberId,
membership.rtcBackendIdentity,
];
}
export function createRemoteRTCMember(
scope: ObservableScope,
membership$: Behavior<CallMembership>,
media$: Behavior<MemberMedia | null>,
context: MemberContext,
): RemoteRTCMember {
return {
...createRTCMember(scope, membership$, context),
local: false,
media$,
};
}
export function createLocalRTCMember(
scope: ObservableScope,
membership$: Behavior<CallMembership>,
local: Pick<LocalMediaBackend, "media$" | "publish" | "unpublish">,
context: MemberContext,
): LocalRTCMember {
return {
...createRTCMember(scope, membership$, context),
local: true,
media$: local.media$,
publish: async (request) => local.publish(request),
unpublish: async (id) => local.unpublish(id),
};
}
function createRTCMember(
scope: ObservableScope,
membership$: Behavior<CallMembership>,
{ metadata, transports }: MemberContext,
): Omit<RTCMember, "local" | "media$"> {
const { userId, deviceId, rtcBackendIdentity } = membership$.value;
return {
id: rtcBackendIdentity,
userId,
deviceId,
membership$,
displayName$: scope.behavior(
metadata
.createDisplayNameBehavior$(scope, userId)
.pipe(map((name) => name ?? userId)),
),
avatarUrl$: metadata.createAvatarUrlBehavior$(scope, userId),
transport$: scope.behavior(
membership$.pipe(
map((membership) => membership.getTransport()),
distinctUntilChanged(),
map((transport) => transport && transports.get(transport)),
),
),
};
}
@@ -0,0 +1,146 @@
/*
Copyright 2025 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 {
beforeEach,
describe,
expect,
it,
type MockedObject,
vi,
} from "vitest";
import { MatrixError } from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
type LivekitTransport,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import {
TransportAutoDiscovery,
type TransportAutoDiscoveryProps,
} from "./TransportAutoDiscovery.ts";
type DiscoveryClient = TransportAutoDiscoveryProps["client"];
const backendTransport: LivekitTransport = {
type: "livekit",
livekit_service_url: "https://backend.example.org",
};
const configTransport: LivekitTransport = {
type: "livekit",
livekit_service_url: "https://config.example.org",
};
function makeClient(): MockedObject<DiscoveryClient> {
return {
getDomain: vi.fn().mockReturnValue("example.org"),
baseUrl: "https://matrix.example.org",
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
} as unknown as MockedObject<DiscoveryClient>;
}
describe("TransportAutoDiscovery", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const VALID_TEST_CASES: Array<{ transports: Transport[] }> = [
{ transports: [backendTransport] },
// will pick the first livekit transport in the list, even if there are other non-livekit transports
{ transports: [{ type: "not_livekit" }, backendTransport] },
];
it.each(VALID_TEST_CASES)(
"prefers backend transport other app config $transports",
async ({ transports }) => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
fallbackTransportUrl: configTransport.livekit_service_url,
logger: rootLogger,
});
const discoveredTransport = await discovery.discoverPreferredTransport();
expect(discoveredTransport).toStrictEqual(backendTransport);
expect(discoveredTransport).not.toStrictEqual(configTransport);
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
},
);
it("Retries limit_exceeded backend transport", async () => {
const client = makeClient();
client._unstable_getRTCTransports
.mockRejectedValueOnce(
new MatrixError(
{
errcode: "M_LIMIT_EXCEEDED",
error: "Too many requests",
retry_after_ms: 100,
},
429,
),
)
.mockResolvedValue([backendTransport]);
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
fallbackTransportUrl: "https://config.example.org",
logger: rootLogger,
});
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
backendTransport,
);
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
});
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
{ transports: [] },
{ transports: [{ type: "not_livekit" }] },
];
it.each(INVALID_TEST_CASES)(
"falls back to config when backend has no (valid) livekit transports $transports",
async ({ transports }) => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
fallbackTransportUrl: configTransport.livekit_service_url,
logger: rootLogger,
});
const discoveredTransport = await discovery.discoverPreferredTransport();
expect(discoveredTransport).not.toStrictEqual(backendTransport);
expect(discoveredTransport).toStrictEqual(configTransport);
},
);
it("returns null when backend and config are all unavailable", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue([]);
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
fallbackTransportUrl: undefined,
logger: rootLogger,
});
await expect(discovery.discoverPreferredTransport()).resolves.toBeNull();
});
});
+103
View File
@@ -0,0 +1,103 @@
/*
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 Transport } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { doNetworkOperationWithRetry } from "../utils/network";
type TransportDiscoveryClient = Pick<
MatrixClient,
"getDomain" | "_unstable_getRTCTransports"
>;
export interface TransportAutoDiscoveryProps {
client: TransportDiscoveryClient;
/** The transport type the media backend serves. */
transportType: string;
/** A LiveKit service URL to use when the homeserver advertises none. */
fallbackTransportUrl?: string;
logger: Logger;
}
export class TransportAutoDiscovery {
private readonly client: TransportDiscoveryClient;
private readonly transportType: string;
private readonly fallbackTransportUrl: string | undefined;
private readonly logger: Logger;
public constructor({
client,
transportType,
fallbackTransportUrl,
logger,
}: TransportAutoDiscoveryProps) {
this.client = client;
this.transportType = transportType;
this.fallbackTransportUrl = fallbackTransportUrl;
this.logger = logger.getChild("[TransportAutoDiscovery]");
}
public async discoverPreferredTransport(): Promise<Transport | null> {
// 1) backend transports
const backendTransport = await this.tryBackendTransports();
if (backendTransport) {
this.logger.info("Found backend transport:", backendTransport);
return backendTransport;
}
// 2) app config URL
const configTransport = this.tryConfigTransport();
if (configTransport) {
this.logger.info("Found app config transport:", configTransport);
return configTransport;
}
return null;
}
/**
* Fetches the first transport of the backend's type from the homeserver.
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
* @private
*/
private async tryBackendTransports(): Promise<Transport | null> {
const client = this.client;
// MSC4143: Attempt to fetch transports from backend.
this.logger.info("First try to use getRTCTransports end point ...");
try {
const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(),
);
const first = transportList.find(
(transport) => transport.type === this.transportType,
);
if (first) {
return first;
} else {
this.logger.info(
`No ${this.transportType} transport found in getRTCTransports end point`,
transportList,
);
}
} catch (ex) {
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
}
return null;
}
private tryConfigTransport(): Transport | null {
const url = this.fallbackTransportUrl;
if (url) {
return {
type: "livekit",
livekit_service_url: url,
};
}
return null;
}
}
+71
View File
@@ -0,0 +1,71 @@
/*
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 Transport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it } from "vitest";
import { type ResolvedTransport } from "../api";
import {
type BackendConnection,
MediaConnectionState,
} from "../media-backend/api";
import { testScope } from "../utils/test";
import { createTransportRegistry, transportId } from "./Transports";
const sfu: Transport = { type: "livekit", livekit_service_url: "https://sfu" };
const resolved: ResolvedTransport = {
type: "livekit",
url: "wss://sfu",
token: "jwt",
roomAlias: "room",
identity: "me",
};
describe("transportId", () => {
it("does not depend on the order a sender wrote the keys in", () => {
expect(transportId({ type: "livekit", livekit_service_url: "u" })).toBe(
transportId({ livekit_service_url: "u", type: "livekit" }),
);
});
it("tells transports of the same type apart", () => {
expect(transportId(sfu)).not.toBe(
transportId({ ...sfu, livekit_service_url: "https://other" }),
);
});
});
describe("createTransportRegistry", () => {
it("hands out one metadata object per transport", () => {
const registry = createTransportRegistry(
testScope(),
new BehaviorSubject<BackendConnection[]>([]),
);
expect(registry.get(sfu)).toBe(registry.get({ ...sfu }));
expect(registry.get(sfu).id).toBe(transportId(sfu));
});
it("derives the connected transports and what was resolved from the backend's connections", () => {
const connections$ = new BehaviorSubject<BackendConnection[]>([]);
const registry = createTransportRegistry(testScope(), connections$);
const metadata = registry.get(sfu);
expect(registry.connected$.value).toEqual([]);
expect(metadata.resolved$.value).toBeUndefined();
connections$.next([
{ transport: sfu, state: MediaConnectionState.Connecting, resolved },
]);
expect(registry.connected$.value).toEqual([]);
expect(metadata.resolved$.value).toBe(resolved);
connections$.next([
{ transport: sfu, state: MediaConnectionState.Connected, resolved },
]);
expect(registry.connected$.value).toEqual([metadata]);
});
});
+81
View File
@@ -0,0 +1,81 @@
/*
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 Transport } from "matrix-js-sdk/lib/matrixrtc";
import { distinctUntilChanged, map } from "rxjs";
import { type Behavior } from "../reactive/Behavior";
import { type ObservableScope } from "../reactive/ObservableScope";
import { type TransportMetadata } from "../api";
import { type MediaBackend, MediaConnectionState } from "../media-backend/api";
export interface TransportRegistry {
/** The one `TransportMetadata` for a transport, however many memberships name it. */
get(raw: Transport): TransportMetadata;
/** Transports with a live connection. */
connected$: Behavior<TransportMetadata[]>;
}
export function createTransportRegistry(
scope: ObservableScope,
connections$: MediaBackend["connections$"],
): TransportRegistry {
const transports = new Map<string, TransportMetadata>();
const get = (raw: Transport): TransportMetadata => {
const id = transportId(raw);
let transport = transports.get(id);
if (transport === undefined) {
transport = createTransportMetadata(scope, connections$, raw, id);
transports.set(id, transport);
}
return transport;
};
const connected$ = scope.behavior(
connections$.pipe(
map((connections) =>
connections
.filter(({ state }) => state === MediaConnectionState.Connected)
.map(({ transport }) => get(transport)),
),
),
);
return { get, connected$ };
}
function createTransportMetadata(
scope: ObservableScope,
connections$: MediaBackend["connections$"],
raw: Transport,
id: string,
): TransportMetadata {
return {
type: raw.type,
id,
raw,
resolved$: scope.behavior(
connections$.pipe(
map(
(connections) =>
connections.find((c) => transportId(c.transport) === id)?.resolved,
),
distinctUntilChanged(),
),
),
};
}
/**
* Transports are plain JSON out of membership events, so the same transport
* serialises the same way wherever it appears. Keys are sorted so that the
* order a sender wrote them in does not matter.
*/
export function transportId(raw: Transport): string {
return JSON.stringify(raw, Object.keys(raw).sort());
}
+77
View File
@@ -0,0 +1,77 @@
/*
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 MatrixRTCSession as JsSdkRTCSession,
type RTCCallIntent,
type RTCNotificationType,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import {
type DelayedLeaveTimings,
MatrixRTCMode,
type SessionTimings,
} from "../config";
interface Options {
encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode;
sendNotificationType?: RTCNotificationType;
applicationData?: Record<string, unknown>;
timings: SessionTimings;
/** The delayed leave timings to use: delegated to the SFU or not. */
delayedLeave: DelayedLeaveTimings;
}
/**
* Sends the membership and starts the membership manager, which keeps it
* alive and retries on failure.
* @throws If the join could not be started.
*/
export function joinJsSdkSession(
session: JsSdkRTCSession,
ownMembershipIdentity: CallMembershipIdentityParts,
transport: Transport,
{
encryptMedia,
matrixRTCMode,
sendNotificationType,
applicationData,
timings,
delayedLeave,
}: Options,
): void {
// The membership format carries the intent as a field of its own
const { "m.call.intent": intent, ...rest } = applicationData ?? {};
const callIntent =
typeof intent === "string" ? (intent as RTCCallIntent) : undefined;
// Give up on the network as soon as either the sync has been down for the
// grace period or the delayed leave has probably been sent, whichever is
// sooner
const maxWaitMs = Math.min(
timings.syncDisconnectGracePeriodMs,
delayedLeave.delay_ms,
);
session.joinRTCSession(ownMembershipIdentity, [transport], {
notificationType: sendNotificationType,
callIntent,
applicationData: rest,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
delayedLeaveEventRestartLocalTimeoutMs: delayedLeave.restart_timeout_ms,
networkErrorRetryMs: timings.networkErrorRetryMs,
makeKeyDelay: timings.waitForKeyRotationMs,
membershipEventExpiryMs: timings.membershipEventExpiryMs,
keyRotationParticipantLimit: timings.keyRotationParticipantLimit,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount:
Math.ceil(maxWaitMs / timings.networkErrorRetryMs) + 1,
});
}
+77
View File
@@ -0,0 +1,77 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { Status } from "matrix-js-sdk/lib/matrixrtc";
import { MediaConnectionState } from "../media-backend/api";
import { MatrixRTCError } from "../api";
import {
type LocalMemberState,
PublishState,
TransportState,
} from "./LocalMember";
import { fatalError, sessionStatus } from "./status";
const publishing: LocalMemberState = {
media: PublishState.Publishing,
matrix: Status.Connected,
};
const connecting: LocalMemberState = {
media: { connection: MediaConnectionState.Connecting },
matrix: Status.Connecting,
};
const error = new MatrixRTCError("gone");
describe("sessionStatus", () => {
it.each<[string, LocalMemberState, boolean, boolean, boolean, string]>([
[
"no transport yet",
TransportState.Waiting,
true,
false,
false,
"waitingForTransport",
],
["not asked to join", connecting, false, false, false, "disconnected"],
["joining", connecting, true, false, false, "connecting"],
["connected", publishing, true, true, false, "connected"],
["dropped after connecting", connecting, true, false, true, "reconnecting"],
["a transport error", error, true, false, false, "disconnected"],
[
"a matrix error",
{ ...publishing, matrix: error },
true,
true,
false,
"disconnected",
],
])("%s", (_name, state, joinRequested, connected, reconnecting, expected) => {
expect(sessionStatus(state, joinRequested, connected, reconnecting)).toBe(
expected,
);
});
});
describe("fatalError", () => {
it("is null while nothing is wrong", () => {
expect(fatalError(TransportState.Waiting)).toBeNull();
expect(fatalError(publishing)).toBeNull();
});
it("finds the error wherever the state holds it", () => {
expect(fatalError(error)).toBe(error);
expect(fatalError({ ...publishing, matrix: error })).toBe(error);
expect(
fatalError({ media: { connection: error }, matrix: Status.Connected }),
).toBe(error);
});
it("does not treat a publish error as fatal", () => {
expect(fatalError({ media: error, matrix: Status.Connected })).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
/*
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 MatrixRTCError, type ConnectionStatus } from "../api";
import { type LocalMemberState, TransportState } from "./LocalMember";
/** The local member's state, collapsed to what a host shows. */
export function sessionStatus(
state: LocalMemberState,
joinRequested: boolean,
connected: boolean,
reconnecting: boolean,
): ConnectionStatus {
if (fatalError(state) !== null) return "disconnected";
if (state === TransportState.Waiting) return "waitingForTransport";
if (connected) return "connected";
if (reconnecting) return "reconnecting";
return joinRequested ? "connecting" : "disconnected";
}
/** The error that stops the session, if the state holds one. */
export function fatalError(state: LocalMemberState): MatrixRTCError | null {
if (state === TransportState.Waiting) return null;
if (state instanceof Error) return state;
if (state.matrix instanceof Error) return state.matrix;
if (
typeof state.media === "object" &&
"connection" in state.media &&
state.media.connection instanceof Error
)
return state.media.connection;
return null;
}