[SDK] split the clint object into -> slot and participation

This commit is contained in:
Timo K.
2026-10-08 13:53:57 +02:00
parent 9cc9eb0889
commit dc983e5493
17 changed files with 653 additions and 415 deletions
+9 -17
View File
@@ -46,25 +46,22 @@ describe("createLocalMembership$", () => {
});
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);
membership.leave();
expect(membership.state$.value).toEqual({
media: PublishState.WaitingForUser,
matrix: Status.Connected,
});
});
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"]);
@@ -72,7 +69,7 @@ describe("createLocalMembership$", () => {
homeserver.combined$.next([true, null]);
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
membership.requestDisconnect();
membership.leave();
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
// A sync outage after leaving must not bring the media back
homeserver.combined$.next([false, "sync"]);
@@ -82,10 +79,8 @@ describe("createLocalMembership$", () => {
});
it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
const { membership, prepared$, delayId$, joinMatrixRTC, delegate } =
setup();
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
prepared$.next({ transport, canDelegateDelayedLeave: true });
membership.requestJoinAndPublish();
await flushPromises();
expect(joinMatrixRTC).toHaveBeenCalledWith(
transport,
@@ -101,10 +96,8 @@ describe("createLocalMembership$", () => {
});
it("never hands the leave over where the backend cannot take it", async () => {
const { membership, prepared$, delayId$, joinMatrixRTC, delegate } =
setup();
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
prepared$.next({ transport, canDelegateDelayedLeave: false });
membership.requestJoinAndPublish();
delayId$.next("delay-1");
await flushPromises();
expect(joinMatrixRTC).toHaveBeenCalledWith(
@@ -124,7 +117,6 @@ describe("createLocalMembership$", () => {
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({
+14 -19
View File
@@ -89,9 +89,8 @@ interface Props {
}
export interface LocalMembership {
requestJoinAndPublish: () => void;
requestDisconnect: () => void;
joinRequested$: Behavior<boolean>;
/** Sends the leave and stops publishing. Final: there is no joining again. */
leave: () => void;
state$: Behavior<LocalMemberState>;
/** Fully connected: to the homeserver, the session and the transport. */
connected$: Behavior<boolean>;
@@ -101,8 +100,8 @@ export interface LocalMembership {
}
/**
* The local member's state machine: waits for the transport, publishes once
* asked to join, and enters and leaves the MatrixRTC session in step.
* The local member's state machine: waits for the transport, then publishes
* and enters the MatrixRTC session, and leaves both in step on `leave()`.
*/
export function createLocalMembership$({
scope,
@@ -131,22 +130,22 @@ export function createLocalMembership$({
null,
);
const joinRequested$ = new BehaviorSubject(false);
const joined$ = new BehaviorSubject(true);
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,
[joined$, homeserverConnected.combined$],
(joined, [connected]) => joined && 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;
scope.behavior(combineLatest([transport$, joined$])),
async ([prepared, joined]) => {
if (prepared === null || !joined) return;
try {
joinMatrixRTC(
prepared.transport,
@@ -191,8 +190,8 @@ export function createLocalMembership$({
});
const mediaState$ = scope.behavior<LocalMemberMediaState>(
combineLatest([local.connectionState$, joinRequested$]).pipe(
map(([connectionState, shouldPublish]) => {
combineLatest([local.connectionState$, joined$]).pipe(
map(([connectionState, joined]) => {
if (connectionState !== MediaConnectionState.Connected)
return {
connection:
@@ -200,9 +199,7 @@ export function createLocalMembership$({
? toMatrixRTCError(connectionState)
: connectionState,
};
return shouldPublish
? PublishState.Publishing
: PublishState.WaitingForUser;
return joined ? PublishState.Publishing : PublishState.WaitingForUser;
}),
distinctUntilChanged(deepCompare),
),
@@ -260,9 +257,7 @@ export function createLocalMembership$({
);
return {
requestJoinAndPublish: () => joinRequested$.next(true),
requestDisconnect: () => joinRequested$.next(false),
joinRequested$,
leave: () => joined$.next(false),
state$,
connected$,
reconnecting$,
+2 -2
View File
@@ -9,7 +9,7 @@ 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 { type RTCParticipationOptions } from "../api";
import { MatrixRTCTransportMissingError } from "../errors";
import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
@@ -27,7 +27,7 @@ export async function discoverLocalTransport(
{
transportUrl,
fallbackTransportUrl,
}: Pick<MatrixRTCClientOptions, "transportUrl" | "fallbackTransportUrl">,
}: Pick<RTCParticipationOptions, "transportUrl" | "fallbackTransportUrl">,
logger: Logger,
): Promise<Transport> {
const transport = transportUrl
+36 -44
View File
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { distinctUntilChanged, map } from "rxjs";
import { type Behavior } from "../reactive/Behavior";
@@ -16,16 +17,15 @@ import {
type RemoteRTCMember,
type RTCMember,
} from "../api";
import {
type LocalMediaBackend,
type MemberMediaFields,
} from "../media-backend/api";
import { type LocalMemberMedia, type MemberMedia } from "../media-api";
import { type TransportRegistry } from "./Transports";
/** What every member is built from, besides its own membership and media. */
/** What every member is built from, besides its own membership. */
export interface MemberContext {
metadata: ReturnType<typeof createMatrixMemberMetadata$>;
transports: TransportRegistry;
/** Whose memberships are `local`. */
own: { userId: string; deviceId: string };
}
/** Everything that tells one membership from another, for keying items. */
@@ -40,50 +40,21 @@ export function membershipKeys(
];
}
export function createRemoteRTCMember(
/** The slot's view of a membership: identity, room state and transport, no media. */
export function createRTCMember(
scope: ObservableScope,
membership$: Behavior<CallMembership>,
media: MemberMediaFields,
context: MemberContext,
): RemoteRTCMember {
{ metadata, transports, own }: MemberContext,
): RTCMember {
const { userId, deviceId, memberId, rtcBackendIdentity } = membership$.value;
return {
...createRTCMember(scope, membership$, context),
local: false,
tracks$: media.tracks$,
encryptionError$: media.encryptionError$,
};
}
export function createLocalRTCMember(
scope: ObservableScope,
membership$: Behavior<CallMembership>,
local: Pick<
LocalMediaBackend,
"tracks$" | "encryptionError$" | "publish" | "unpublish"
>,
context: MemberContext,
): LocalRTCMember {
return {
...createRTCMember(scope, membership$, context),
local: true,
tracks$: local.tracks$,
encryptionError$: local.encryptionError$,
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" | "tracks$" | "encryptionError$"> {
const { userId, deviceId, rtcBackendIdentity } = membership$.value;
return {
id: rtcBackendIdentity,
local: userId === own.userId && deviceId === own.deviceId,
// Read off the membership for now; the backend will derive it once the
// identity scheme is its own and not the js-sdk's
rtcBackendIdentity,
userId,
deviceId,
membership$,
memberId,
displayName$: scope.behavior(
metadata
.createDisplayNameBehavior$(scope, userId)
@@ -97,5 +68,26 @@ function createRTCMember(
map((transport) => transport && transports.get(transport)),
),
),
applicationData$: scope.behavior(
membership$.pipe(
map((membership) => membership.applicationData),
distinctUntilChanged(deepCompare),
),
),
};
}
/** The participation's view of a remote member: the slot's member plus its media. */
export function createRemoteRTCMember(
member: RTCMember,
media: MemberMedia,
): RemoteRTCMember {
return { ...member, local: false, ...media };
}
export function createLocalRTCMember(
member: RTCMember,
media: LocalMemberMedia,
): LocalRTCMember {
return { ...member, local: true, ...media };
}
+19 -11
View File
@@ -15,7 +15,7 @@ import {
PublishState,
TransportState,
} from "./LocalMember";
import { fatalError, sessionStatus } from "./status";
import { fatalError, participationStatus } from "./status";
const publishing: LocalMemberState = {
media: PublishState.Publishing,
@@ -27,31 +27,39 @@ const connecting: LocalMemberState = {
};
const error = new MatrixRTCError("gone");
describe("sessionStatus", () => {
describe("participationStatus", () => {
it.each<[string, LocalMemberState, boolean, boolean, boolean, string]>([
[
"no transport yet",
TransportState.Waiting,
true,
false,
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"],
["joining", connecting, false, false, false, "connecting"],
["connected", publishing, true, false, false, "connected"],
[
"dropped after connecting",
connecting,
false,
true,
false,
"reconnecting",
],
["a transport error", error, false, false, false, "disconnected"],
[
"a matrix error",
{ ...publishing, matrix: error },
true,
true,
false,
false,
"disconnected",
],
])("%s", (_name, state, joinRequested, connected, reconnecting, expected) => {
expect(sessionStatus(state, joinRequested, connected, reconnecting)).toBe(
["left", publishing, true, false, true, "left"],
["left after an error", error, false, false, true, "left"],
])("%s", (_name, state, connected, reconnecting, left, expected) => {
expect(participationStatus(state, connected, reconnecting, left)).toBe(
expected,
);
});
+7 -6
View File
@@ -5,24 +5,25 @@ 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 MatrixRTCError, type ParticipationStatus } from "../api";
import { type LocalMemberState, TransportState } from "./LocalMember";
/** The local member's state, collapsed to what a host shows. */
export function sessionStatus(
export function participationStatus(
state: LocalMemberState,
joinRequested: boolean,
connected: boolean,
reconnecting: boolean,
): ConnectionStatus {
left: boolean,
): ParticipationStatus {
if (left) return "left";
if (fatalError(state) !== null) return "disconnected";
if (state === TransportState.Waiting) return "waitingForTransport";
if (connected) return "connected";
if (reconnecting) return "reconnecting";
return joinRequested ? "connecting" : "disconnected";
return "connecting";
}
/** The error that stops the session, if the state holds one. */
/** The error that stops the participation, if the state holds one. */
export function fatalError(state: LocalMemberState): MatrixRTCError | null {
if (state === TransportState.Waiting) return null;
if (state instanceof Error) return state;