mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
[SDK] update state structure (less duplication, one state enum)
- remove media from LocalMembership file
This commit is contained in:
+35
-37
@@ -131,18 +131,18 @@ participation split in
|
|||||||
[`client-into-slot-session-split-plan.md`](./client-into-slot-session-split-plan.md);
|
[`client-into-slot-session-split-plan.md`](./client-into-slot-session-split-plan.md);
|
||||||
the sections below still describe `MatrixRTCClient` until that lands.
|
the sections below still describe `MatrixRTCClient` until that lands.
|
||||||
|
|
||||||
| Word | Meaning here | Elsewhere |
|
| Word | Meaning here | Elsewhere |
|
||||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||||
| slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event |
|
| slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event |
|
||||||
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `status$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
|
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `status$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
|
||||||
| member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" |
|
| member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" |
|
||||||
| local member, remote member | `LocalRTCMember` and `RemoteRTCMember`: an `RTCMember` plus its `MemberMedia`, which only a participation can carry. The local one's tracks carry the controls. What the participation hands out in `localMember$` and `remoteMembers$` | LiveKit `LocalParticipant` and `RemoteParticipant`, which never leave the SDK |
|
| local member, remote member | `LocalRTCMember` and `RemoteRTCMember`: an `RTCMember` plus its `MemberMedia`, which only a participation can carry. The local one's tracks carry the controls. What the participation hands out in `localMember$` and `remoteMembers$` | LiveKit `LocalParticipant` and `RemoteParticipant`, which never leave the SDK |
|
||||||
| media | a `MemberMedia`: what a member sends once it has arrived on its transport, `tracks$`, null while waiting, and `encryptionError$` beside it. The media backend supplies exactly these two fields per member | Element Call's `MediaViewModel` is a view of it |
|
| media | a `MemberMedia`: what a member sends once it has arrived on its transport, `tracks$`, null while waiting, and `encryptionError$` beside it. The media backend supplies exactly these two fields per member | Element Call's `MediaViewModel` is a view of it |
|
||||||
| track | a `MediaTrack`: one publication of a member, audio or video, with `source`, `kind`, `id` and the behaviors on it. `LocalAudioMediaTrack` and `LocalVideoMediaTrack` add the controls over our own | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
|
| track | a `MediaTrack`: one publication of a member, audio or video, with `source`, `kind`, `id` and the behaviors on it. `LocalAudioMediaTrack` and `LocalVideoMediaTrack` add the controls over our own | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
|
||||||
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
|
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
|
||||||
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
|
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
|
||||||
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
|
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
|
||||||
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
|
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
|
||||||
|
|
||||||
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
|
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
|
||||||
`RTCMember`, `matrixRTCMode`.
|
`RTCMember`, `matrixRTCMode`.
|
||||||
@@ -263,20 +263,26 @@ export function createMatrixRTCClient(
|
|||||||
room: Room,
|
room: Room,
|
||||||
options: MatrixRTCClientOptions,
|
options: MatrixRTCClientOptions,
|
||||||
): MatrixRTCClient;
|
): MatrixRTCClient;
|
||||||
````
|
```
|
||||||
|
|
||||||
### The client
|
### The client
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export type ConnectionStatus =
|
/** The homeserver link that is down, in the order they depend on each other. */
|
||||||
| "waitingForTransport"
|
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||||
| "connecting"
|
|
||||||
| "connected"
|
|
||||||
| "reconnecting"
|
|
||||||
| "disconnected";
|
|
||||||
|
|
||||||
/** Why the client is not connected: the first failing of its three links. */
|
/** Why the participation is not connected: the first failing link, in dependency order. */
|
||||||
export type DisconnectReason = "sync" | "membership" | "probablyLeft" | "media";
|
export type DisconnectReason = MatrixDisconnectReason | "media";
|
||||||
|
|
||||||
|
export type ParticipationState =
|
||||||
|
| { kind: "waitingForTransport" }
|
||||||
|
| { kind: "connecting" }
|
||||||
|
| { kind: "connected" }
|
||||||
|
| { kind: "reconnecting"; reason: DisconnectReason }
|
||||||
|
/** A failed publication is not fatal: the member can still receive, so it stays on the local member's media. */
|
||||||
|
| { kind: "failed"; error: MatrixRTCError }
|
||||||
|
/** `leave()` was called. Terminal. */
|
||||||
|
| { kind: "left" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An error raised by the client. `code` and `category` say what went wrong in
|
* An error raised by the client. `code` and `category` say what went wrong in
|
||||||
@@ -290,21 +296,12 @@ export class MatrixRTCError extends Error {
|
|||||||
export interface MatrixRTCClient {
|
export interface MatrixRTCClient {
|
||||||
join(): void;
|
join(): void;
|
||||||
leave(): void;
|
leave(): void;
|
||||||
/** The local member's state machine, collapsed to what a host shows. */
|
|
||||||
status$: Behavior<ConnectionStatus>;
|
|
||||||
/** Connected to the homeserver, the session and the local transport, all three. */
|
|
||||||
connected$: Behavior<boolean>;
|
|
||||||
/** Connected once, and currently not. */
|
|
||||||
reconnecting$: Behavior<boolean>;
|
|
||||||
/** Null while connected, the first failing link otherwise. */
|
|
||||||
disconnectReason$: Behavior<DisconnectReason | null>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A transport, Matrix or connection error that stops the session. Null
|
* The Matrix membership combined with the local media connection. One
|
||||||
* while fine. A failed publication is not fatal: the member can still
|
* value: a reason exists only while `reconnecting`, an error only once
|
||||||
* receive, so it stays on the local member's media.
|
* `failed`.
|
||||||
*/
|
*/
|
||||||
fatalError$: Behavior<MatrixRTCError | null>;
|
state$: Behavior<ParticipationState>;
|
||||||
|
|
||||||
/** Null until our own membership has been seen in the room. */
|
/** Null until our own membership has been seen in the room. */
|
||||||
localMember$: Behavior<LocalRTCMember | null>;
|
localMember$: Behavior<LocalRTCMember | null>;
|
||||||
@@ -749,9 +746,10 @@ public API only.
|
|||||||
join; creating the tracks earlier, for a lobby preview, is still missing.
|
join; creating the tracks earlier, for a lobby preview, is still missing.
|
||||||
- **`applicationData` after the join.** The option is read once; there is no
|
- **`applicationData` after the join.** The option is read once; there is no
|
||||||
way to update it on a live membership yet.
|
way to update it on a live membership yet.
|
||||||
- **Raw local state.** The local member's state machine (`LocalMemberState`) stays
|
- **Raw membership state.** The Matrix side's own state machine
|
||||||
internal; `status$`, `disconnectReason$` and `fatalError$` are its public view.
|
(`MembershipState` in `LocalMembership.ts`) stays internal; `state$` on the
|
||||||
A developer panel may want it as `LocalRTCMember.state$`.
|
participation is its public view combined with the media connection. A
|
||||||
|
developer panel may want it as `LocalRTCMember.membershipState$`.
|
||||||
- **Developer panel.** `resolved$` on each connected transport covers what the
|
- **Developer panel.** `resolved$` on each connected transport covers what the
|
||||||
panel shows today. Anything beyond that (LiveKit room state, connection
|
panel shows today. Anything beyond that (LiveKit room state, connection
|
||||||
quality) needs an opaque `debug$` per transport.
|
quality) needs an opaque `debug$` per transport.
|
||||||
|
|||||||
+7
-6
@@ -77,11 +77,9 @@ async function start(
|
|||||||
const rtcParticipation = slot.join({
|
const rtcParticipation = slot.join({
|
||||||
publish: [{ source: "microphone" }, { source: "camera" }],
|
publish: [{ source: "microphone" }, { source: "camera" }],
|
||||||
});
|
});
|
||||||
rtcParticipation.status$.pipe(scope.bind()).subscribe((s) => {
|
rtcParticipation.state$.pipe(scope.bind()).subscribe((state) => {
|
||||||
status.textContent = s;
|
status.textContent =
|
||||||
});
|
state.kind === "failed" ? `Error: ${state.error.message}` : state.kind;
|
||||||
rtcParticipation.fatalError$.pipe(scope.bind()).subscribe((error) => {
|
|
||||||
if (error !== null) status.textContent = `Error: ${error.message}`;
|
|
||||||
});
|
});
|
||||||
showMembers(scope, rtcParticipation);
|
showMembers(scope, rtcParticipation);
|
||||||
showMessages(scope, rtcParticipation);
|
showMessages(scope, rtcParticipation);
|
||||||
@@ -179,7 +177,10 @@ function showMembers(
|
|||||||
rtcParticipation: RTCParticipation,
|
rtcParticipation: RTCParticipation,
|
||||||
): void {
|
): void {
|
||||||
const tiles = new Map<string, HTMLElement>();
|
const tiles = new Map<string, HTMLElement>();
|
||||||
combineLatest([rtcParticipation.localMember$, rtcParticipation.remoteMembers$])
|
combineLatest([
|
||||||
|
rtcParticipation.localMember$,
|
||||||
|
rtcParticipation.remoteMembers$,
|
||||||
|
])
|
||||||
.pipe(scope.bind())
|
.pipe(scope.bind())
|
||||||
.subscribe(([local, remote]) => {
|
.subscribe(([local, remote]) => {
|
||||||
const current = local === null ? remote : [local, ...remote];
|
const current = local === null ? remote : [local, ...remote];
|
||||||
|
|||||||
+29
-21
@@ -14,15 +14,17 @@ import {
|
|||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
|
||||||
combineLatest,
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
from,
|
from,
|
||||||
fromEvent,
|
fromEvent,
|
||||||
map,
|
map,
|
||||||
Observable,
|
Observable,
|
||||||
|
scan,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { defaultSessionTimings, MatrixRTCMode } from "./config";
|
import { defaultSessionTimings, MatrixRTCMode } from "./config";
|
||||||
@@ -34,6 +36,7 @@ import { mapScoped } from "./utils/mapScoped";
|
|||||||
import {
|
import {
|
||||||
type DataMessage,
|
type DataMessage,
|
||||||
type LocalRTCMember,
|
type LocalRTCMember,
|
||||||
|
type ParticipationState,
|
||||||
type RemoteRTCMember,
|
type RemoteRTCMember,
|
||||||
type RTCMember,
|
type RTCMember,
|
||||||
type RTCParticipation,
|
type RTCParticipation,
|
||||||
@@ -59,8 +62,8 @@ import {
|
|||||||
createRemoteRTCMember,
|
createRemoteRTCMember,
|
||||||
} from "./matrixrtc/Members";
|
} from "./matrixrtc/Members";
|
||||||
import { createKeyRotationSuppressed$ } from "./matrixrtc/JsRtcSessionBehaviors";
|
import { createKeyRotationSuppressed$ } from "./matrixrtc/JsRtcSessionBehaviors";
|
||||||
import { fatalError, participationStatus } from "./matrixrtc/status";
|
|
||||||
import { type TransportRegistry } from "./matrixrtc/Transports";
|
import { type TransportRegistry } from "./matrixrtc/Transports";
|
||||||
|
import { participationState } from "./participationState";
|
||||||
|
|
||||||
/** What the slot lends a participation. */
|
/** What the slot lends a participation. */
|
||||||
export interface ParticipationDeps {
|
export interface ParticipationDeps {
|
||||||
@@ -124,7 +127,7 @@ export function createRTCParticipation(
|
|||||||
mediaKeys$: mediaKeys$(jsSdkSession),
|
mediaKeys$: mediaKeys$(jsSdkSession),
|
||||||
timings,
|
timings,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const preparedTransport$ = from(
|
const preparedTransport$ = from(
|
||||||
discoverLocalTransport(client, backend.transportType, options, logger).then(
|
discoverLocalTransport(client, backend.transportType, options, logger).then(
|
||||||
@@ -137,7 +140,6 @@ export function createRTCParticipation(
|
|||||||
|
|
||||||
const localMembership = createLocalMembership$({
|
const localMembership = createLocalMembership$({
|
||||||
scope,
|
scope,
|
||||||
local: backend.local,
|
|
||||||
delegateDelayedLeave: async (delayId) =>
|
delegateDelayedLeave: async (delayId) =>
|
||||||
backend.delegateDelayedLeave(delayId),
|
backend.delegateDelayedLeave(delayId),
|
||||||
preparedTransport$,
|
preparedTransport$,
|
||||||
@@ -228,22 +230,32 @@ export function createRTCParticipation(
|
|||||||
filter((message) => message !== null),
|
filter((message) => message !== null),
|
||||||
);
|
);
|
||||||
|
|
||||||
const left$ = new BehaviorSubject(false);
|
// Nothing leaves this device while it may already have been dropped from
|
||||||
const status$ = scope.behavior(
|
// the session: the member would show as away while still being heard
|
||||||
combineLatest(
|
localMembership.state$
|
||||||
[
|
.pipe(
|
||||||
localMembership.state$,
|
map((membership) => membership.kind === "joined"),
|
||||||
localMembership.connected$,
|
distinctUntilChanged(),
|
||||||
localMembership.reconnecting$,
|
scope.bind(),
|
||||||
left$,
|
)
|
||||||
],
|
.subscribe((publish) => backend.local.setPublishing(publish));
|
||||||
participationStatus,
|
|
||||||
|
const state$ = scope.behavior<ParticipationState>(
|
||||||
|
combineLatest([
|
||||||
|
localMembership.state$,
|
||||||
|
backend.local.connectionState$,
|
||||||
|
]).pipe(
|
||||||
|
scan(
|
||||||
|
(previous, [membership, media]) =>
|
||||||
|
participationState(previous, membership, media),
|
||||||
|
{ kind: "waitingForTransport" } as ParticipationState,
|
||||||
|
),
|
||||||
|
distinctUntilChanged(deepCompare),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const leave = (): void => {
|
const leave = (): void => {
|
||||||
if (left$.value) return;
|
if (state$.value.kind === "left") return;
|
||||||
left$.next(true);
|
|
||||||
localMembership.leave();
|
localMembership.leave();
|
||||||
scope.end();
|
scope.end();
|
||||||
};
|
};
|
||||||
@@ -253,11 +265,7 @@ export function createRTCParticipation(
|
|||||||
participation: {
|
participation: {
|
||||||
slot,
|
slot,
|
||||||
leave,
|
leave,
|
||||||
status$,
|
state$,
|
||||||
connected$: localMembership.connected$,
|
|
||||||
reconnecting$: localMembership.reconnecting$,
|
|
||||||
disconnectReason$: localMembership.disconnectReason$,
|
|
||||||
fatalError$: scope.behavior(localMembership.state$.pipe(map(fatalError))),
|
|
||||||
localMember$,
|
localMember$,
|
||||||
remoteMembers$,
|
remoteMembers$,
|
||||||
publish: async (request) => backend.local.publish(request),
|
publish: async (request) => backend.local.publish(request),
|
||||||
|
|||||||
+31
-18
@@ -121,31 +121,44 @@ export interface RTCParticipationOptions {
|
|||||||
backend?: MediaBackendFactory;
|
backend?: MediaBackendFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParticipationStatus =
|
/**
|
||||||
| "waitingForTransport"
|
* The homeserver link that is down, in the order they depend on each other:
|
||||||
| "connecting"
|
* without sync the membership is unknowable, and without a confirmed
|
||||||
| "connected"
|
* membership the delayed leave cannot be refreshed.
|
||||||
| "reconnecting"
|
*/
|
||||||
| "disconnected"
|
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||||
/** `leave()` was called; the participation is over and stays so. */
|
|
||||||
| "left";
|
|
||||||
|
|
||||||
/** Why the participation is not connected: the first failing of its three links. */
|
/**
|
||||||
export type DisconnectReason = "sync" | "membership" | "probablyLeft" | "media";
|
* Why the participation is not connected: the first failing link, in the
|
||||||
|
* order they depend on each other. The media connection comes last because
|
||||||
|
* it is worth little while the Matrix side is down.
|
||||||
|
*/
|
||||||
|
export type DisconnectReason = MatrixDisconnectReason | "media";
|
||||||
|
|
||||||
|
export type ParticipationState =
|
||||||
|
/** Asking the homeserver for a transport, or preparing it. */
|
||||||
|
| { kind: "waitingForTransport" }
|
||||||
|
/** Transport known; joining the session and connecting the media for the first time. */
|
||||||
|
| { kind: "connecting" }
|
||||||
|
/** Membership joined and the local media connection up. */
|
||||||
|
| { kind: "connected" }
|
||||||
|
/** Was connected and one link is down. Back to `connected` or on to `failed`. */
|
||||||
|
| { kind: "reconnecting"; reason: DisconnectReason }
|
||||||
|
/**
|
||||||
|
* The membership or the media connection ended with an error. Terminal
|
||||||
|
* unless `left`. A failed publication is not fatal: the member can still
|
||||||
|
* receive, so it stays on the local member's media.
|
||||||
|
*/
|
||||||
|
| { kind: "failed"; error: MatrixRTCError }
|
||||||
|
/** `leave()` was called. Terminal. */
|
||||||
|
| { kind: "left" };
|
||||||
|
|
||||||
/** The span between our `join()` of a slot and our `leave()`. */
|
/** The span between our `join()` of a slot and our `leave()`. */
|
||||||
export interface RTCParticipation {
|
export interface RTCParticipation {
|
||||||
readonly slot: RTCSlot;
|
readonly slot: RTCSlot;
|
||||||
/** Sends the leave, tears the connections down and ends the participation. */
|
/** Sends the leave, tears the connections down and ends the participation. */
|
||||||
leave(): void;
|
leave(): void;
|
||||||
/** Collapsed view of the local member's state machine. */
|
state$: Behavior<ParticipationState>;
|
||||||
status$: Behavior<ParticipationStatus>;
|
|
||||||
connected$: Behavior<boolean>;
|
|
||||||
reconnecting$: Behavior<boolean>;
|
|
||||||
/** Null while connected, the first failing link otherwise. */
|
|
||||||
disconnectReason$: Behavior<DisconnectReason | null>;
|
|
||||||
/** A transport, Matrix or connection error that stops the participation. */
|
|
||||||
fatalError$: Behavior<MatrixRTCError | null>;
|
|
||||||
|
|
||||||
/** Null until our own membership has been seen in the room. */
|
/** Null until our own membership has been seen in the room. */
|
||||||
localMember$: Behavior<LocalRTCMember | null>;
|
localMember$: Behavior<LocalRTCMember | null>;
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ describe("createHomeserverConnected$", () => {
|
|||||||
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
|
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
|
||||||
it("reports syncing reason when sync state is not Syncing", () => {
|
it("reports syncing reason when sync state is not Syncing", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports membership reason when sync is Syncing but membership is not Connected", () => {
|
it("reports membership reason when sync is Syncing but membership is not Connected", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
|
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
|
||||||
@@ -116,17 +116,17 @@ describe("createHomeserverConnected$", () => {
|
|||||||
// Indicate probable leave before connection
|
// Indicate probable leave before connection
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("becomes null (connected) only when all three conditions are satisfied", () => {
|
it("becomes null (connected) only when all three conditions are satisfied", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
// 1. Sync loop connected
|
// 1. Sync loop connected
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]); // not yet membership connected
|
expect(hsConnected.disconnectReason$.value).toEqual("membership"); // not yet membership connected
|
||||||
// 2. Membership connected
|
// 2. Membership connected
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toEqual([true, null]); // probablyLeft is false
|
expect(hsConnected.disconnectReason$.value).toEqual(null); // probablyLeft is false
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns syncing reason when sync loop leaves Syncing", () => {
|
it("returns syncing reason when sync loop leaves Syncing", () => {
|
||||||
@@ -134,76 +134,76 @@ describe("createHomeserverConnected$", () => {
|
|||||||
// Reach connected state
|
// Reach connected state
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
// Sync loop error => should report syncing reason
|
// Sync loop error => should report syncing reason
|
||||||
client.setSyncState(SyncState.Error);
|
client.setSyncState(SyncState.Error);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns membershipConnected reason when membership status becomes disconnected", () => {
|
it("returns membershipConnected reason when membership status becomes disconnected", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
session.setMembershipStatus(Status.Disconnected);
|
session.setMembershipStatus(Status.Disconnected);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns certainlyConnected reason when ProbablyLeft is emitted", () => {
|
it("returns certainlyConnected reason when ProbablyLeft is emitted", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => {
|
it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||||
|
|
||||||
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
||||||
session.setProbablyLeft(false);
|
session.setProbablyLeft(false);
|
||||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("composite sequence reflects each individual failure reason", () => {
|
it("composite sequence reflects each individual failure reason", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
|
||||||
// Initially: sync error + membership disconnected → syncing wins (highest priority)
|
// Initially: sync error + membership disconnected → syncing wins (highest priority)
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||||
|
|
||||||
// Fix sync only → membershipConnected is now the blocker
|
// Fix sync only → membershipConnected is now the blocker
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "membership"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||||
|
|
||||||
// Fix membership → all conditions satisfied
|
// Fix membership → all conditions satisfied
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
// Introduce probablyLeft → certainlyConnected
|
// Introduce probablyLeft → certainlyConnected
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||||
|
|
||||||
// Restore notProbablyLeft → connected again
|
// Restore notProbablyLeft → connected again
|
||||||
session.setProbablyLeft(false);
|
session.setProbablyLeft(false);
|
||||||
expect(hsConnected.combined$.value).toEqual([true, null]);
|
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||||
|
|
||||||
// Drop sync → syncing reason
|
// Drop sync → syncing reason
|
||||||
client.setSyncState(SyncState.Error);
|
client.setSyncState(SyncState.Error);
|
||||||
expect(hsConnected.combined$.value).toEqual([false, "sync"]);
|
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createHomeserverConnected$ - combined$ reason values", () => {
|
describe("createHomeserverConnected$ - disconnectReason$ reason values", () => {
|
||||||
let scope: ObservableScope;
|
let scope: ObservableScope;
|
||||||
let client: MockMatrixClient;
|
let client: MockMatrixClient;
|
||||||
let session: MockMatrixRTCSession;
|
let session: MockMatrixRTCSession;
|
||||||
@@ -222,57 +222,87 @@ describe("createHomeserverConnected$ - combined$ reason values", () => {
|
|||||||
scope.end();
|
scope.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is [true, null] when all three conditions are satisfied", () => {
|
it("is null when all three conditions are satisfied", () => {
|
||||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(combined$.value).toEqual([true, null]);
|
expect(disconnectReason$.value).toEqual(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports syncing when sync loop is not Syncing", () => {
|
it("reports syncing when sync loop is not Syncing", () => {
|
||||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
// client starts with SyncState.Error, membership also disconnected
|
// client starts with SyncState.Error, membership also disconnected
|
||||||
expect(combined$.value).toEqual([false, "sync"]);
|
expect(disconnectReason$.value).toEqual("sync");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports membershipConnected when sync is fine but membership is not Connected", () => {
|
it("reports membershipConnected when sync is fine but membership is not Connected", () => {
|
||||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
// session still Status.Disconnected
|
// session still Status.Disconnected
|
||||||
expect(combined$.value).toEqual([false, "membership"]);
|
expect(disconnectReason$.value).toEqual("membership");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports certainlyConnected when probablyLeft is true", () => {
|
it("reports certainlyConnected when probablyLeft is true", () => {
|
||||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
expect(combined$.value).toEqual([false, "probablyLeft"]);
|
expect(disconnectReason$.value).toEqual("probablyLeft");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prioritises syncing over membershipConnected when both fail", () => {
|
it("prioritises syncing over membershipConnected when both fail", () => {
|
||||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
// Both sync (Error) and membership (Disconnected) are failing
|
// Both sync (Error) and membership (Disconnected) are failing
|
||||||
expect(combined$.value).toEqual([false, "sync"]);
|
expect(disconnectReason$.value).toEqual("sync");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates reason as conditions change", () => {
|
it("updates reason as conditions change", () => {
|
||||||
const { combined$ } = createHomeserverConnected$(scope, client, session, 0);
|
const { disconnectReason$ } = createHomeserverConnected$(
|
||||||
|
scope,
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
0,
|
||||||
|
);
|
||||||
// Initially: syncing fails
|
// Initially: syncing fails
|
||||||
expect(combined$.value).toEqual([false, "sync"]);
|
expect(disconnectReason$.value).toEqual("sync");
|
||||||
|
|
||||||
// Fix sync → membershipConnected is now the blocker
|
// Fix sync → membershipConnected is now the blocker
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(combined$.value).toEqual([false, "membership"]);
|
expect(disconnectReason$.value).toEqual("membership");
|
||||||
|
|
||||||
// Fix membership → probablyLeft makes certainlyConnected fail
|
// Fix membership → probablyLeft makes certainlyConnected fail
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(combined$.value).toEqual([false, "probablyLeft"]);
|
expect(disconnectReason$.value).toEqual("probablyLeft");
|
||||||
|
|
||||||
// Clear probablyLeft → all conditions satisfied
|
// Clear probablyLeft → all conditions satisfied
|
||||||
session.setProbablyLeft(false);
|
session.setProbablyLeft(false);
|
||||||
expect(combined$.value).toEqual([true, null]);
|
expect(disconnectReason$.value).toEqual(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -303,10 +333,13 @@ describe("createHomeserverConnected$ - Grace Period", () => {
|
|||||||
session,
|
session,
|
||||||
GRACE_PERIOD,
|
GRACE_PERIOD,
|
||||||
);
|
);
|
||||||
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, {
|
expectObservable(hsConnected.disconnectReason$).toBe(
|
||||||
y: [true, null],
|
expectedConnectedMarbles,
|
||||||
n: [false, "sync"],
|
{
|
||||||
});
|
y: null,
|
||||||
|
n: "sync",
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
|||||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||||
import { type Behavior } from "../reactive/Behavior";
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
import { defaultSessionTimings } from "../config";
|
import { defaultSessionTimings } from "../config";
|
||||||
|
import { type MatrixDisconnectReason } from "../api";
|
||||||
|
|
||||||
type NodeEventHandler = (...args: unknown[]) => void;
|
type NodeEventHandler = (...args: unknown[]) => void;
|
||||||
|
|
||||||
@@ -37,22 +38,16 @@ interface NodeStyleEventEmitter {
|
|||||||
removeListener(eventName: string | symbol, handler: NodeEventHandler): this;
|
removeListener(eventName: string | symbol, handler: NodeEventHandler): this;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
|
|
||||||
|
|
||||||
export interface HomeserverConnected {
|
export interface HomeserverConnected {
|
||||||
/**
|
/** Null while the homeserver connection is healthy, the first failing link otherwise. */
|
||||||
* Emits `[true, null]` when the homeserver connection is healthy, or
|
disconnectReason$: Behavior<MatrixDisconnectReason | null>;
|
||||||
* `[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
|
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
|
||||||
* for the purposes of a MatrixRTC session.
|
* for the purposes of a MatrixRTC session.
|
||||||
*
|
*
|
||||||
* `combined$` emits `null` when all conditions are satisfied, or the first failing
|
* `disconnectReason$` emits `null` when all conditions are satisfied, or the first failing
|
||||||
* reason (priority: syncing > membershipConnected > certainlyConnected):
|
* reason (priority: syncing > membershipConnected > certainlyConnected):
|
||||||
* 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
|
* 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
|
||||||
* 2. membershipStatus !== Status.Connected → "membership"
|
* 2. membershipStatus !== Status.Connected → "membership"
|
||||||
@@ -118,26 +113,19 @@ export function createHomeserverConnected$(
|
|||||||
map(() => matrixRTCSession.probablyLeft !== true),
|
map(() => matrixRTCSession.probablyLeft !== true),
|
||||||
);
|
);
|
||||||
|
|
||||||
const combined$ = scope.behavior(
|
const disconnectReason$ = scope.behavior(
|
||||||
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
|
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
|
||||||
map(
|
map(([syncing, membership, certainly]): MatrixDisconnectReason | null => {
|
||||||
([syncing, membership, certainly]): [
|
if (!syncing) return "sync";
|
||||||
boolean,
|
if (!membership) return "membership";
|
||||||
HomeserverDisconnectReason | null,
|
if (!certainly) return "probablyLeft";
|
||||||
] => {
|
return null;
|
||||||
if (!syncing) return [false, "sync"];
|
}),
|
||||||
if (!membership) return [false, "membership"];
|
tap((reason) => {
|
||||||
if (!certainly) return [false, "probablyLeft"];
|
logger.info(`Homeserver connected update: ${reason ?? "connected"}`);
|
||||||
return [true, null];
|
|
||||||
},
|
|
||||||
),
|
|
||||||
tap(([connected, reason]) => {
|
|
||||||
logger.info(
|
|
||||||
`Homeserver connected update: ${connected ? "connected" : reason}`,
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { combined$, rtsSession$ };
|
return { disconnectReason$ };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,77 +5,57 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
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,
|
|
||||||
EMPTY,
|
|
||||||
type Observable,
|
|
||||||
Subject,
|
|
||||||
throwError,
|
|
||||||
} from "rxjs";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { BehaviorSubject, type Observable, Subject, throwError } from "rxjs";
|
||||||
|
|
||||||
import { defaultSessionTimings } from "../config";
|
import { defaultSessionTimings } from "../config";
|
||||||
import { MatrixRTCError } from "../errors";
|
import { MatrixRTCError } from "../errors";
|
||||||
import {
|
|
||||||
type LocalMediaBackend,
|
|
||||||
MediaConnectionState,
|
|
||||||
} from "../media-backend/api";
|
|
||||||
import { type HomeserverConnected } from "./HomeserverConnected";
|
import { type HomeserverConnected } from "./HomeserverConnected";
|
||||||
import {
|
import {
|
||||||
createLocalMembership$,
|
createLocalMembership$,
|
||||||
type LocalMembership,
|
type LocalMembership,
|
||||||
type PreparedTransport,
|
type PreparedTransport,
|
||||||
PublishState,
|
|
||||||
TransportState,
|
|
||||||
} from "./LocalMembership";
|
} from "./LocalMembership";
|
||||||
import { flushPromises, testScope } from "../utils/test";
|
import { flushPromises, testScope } from "../utils/test";
|
||||||
|
|
||||||
const transport: Transport = { type: "livekit", livekit_service_url: "u" };
|
const transport: Transport = { type: "livekit", livekit_service_url: "u" };
|
||||||
|
|
||||||
describe("createLocalMembership$", () => {
|
describe("createLocalMembership$", () => {
|
||||||
it("waits for the transport, then reports the media connection and the join", async () => {
|
it("waits for the transport, joins, and leaves on leave()", async () => {
|
||||||
const { membership, local, prepared$ } = setup();
|
const { membership, homeserver, prepared$ } = setup();
|
||||||
expect(membership.state$.value).toBe(TransportState.Waiting);
|
expect(membership.state$.value).toEqual({ kind: "waitingForTransport" });
|
||||||
|
|
||||||
|
homeserver.disconnectReason$.next("membership");
|
||||||
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||||
expect(membership.state$.value).toEqual({
|
expect(membership.state$.value).toEqual({ kind: "joining" });
|
||||||
media: { connection: MediaConnectionState.Initialized },
|
|
||||||
matrix: Status.Connected,
|
|
||||||
});
|
|
||||||
|
|
||||||
local.connectionState$.next(MediaConnectionState.Connected);
|
homeserver.disconnectReason$.next(null);
|
||||||
await flushPromises();
|
expect(membership.state$.value).toEqual({ kind: "joined" });
|
||||||
expect(membership.state$.value).toEqual({
|
|
||||||
media: PublishState.Publishing,
|
|
||||||
matrix: Status.Connected,
|
|
||||||
});
|
|
||||||
expect(membership.connected$.value).toBe(true);
|
|
||||||
|
|
||||||
membership.leave();
|
membership.leave();
|
||||||
expect(membership.state$.value).toEqual({
|
await flushPromises();
|
||||||
media: PublishState.WaitingForUser,
|
expect(membership.state$.value).toEqual({ kind: "left" });
|
||||||
matrix: Status.Connected,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("publishes only while joined and the homeserver is reachable", async () => {
|
it("reconnects only after it was joined once", () => {
|
||||||
const { membership, local, homeserver } = setup();
|
const { membership, homeserver, prepared$ } = setup();
|
||||||
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
|
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||||
|
expect(membership.state$.value).toEqual({ kind: "joined" });
|
||||||
|
|
||||||
homeserver.combined$.next([false, "sync"]);
|
homeserver.disconnectReason$.next("sync");
|
||||||
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
|
expect(membership.state$.value).toEqual({
|
||||||
homeserver.combined$.next([true, null]);
|
kind: "reconnecting",
|
||||||
expect(local.setPublishing).toHaveBeenLastCalledWith(true);
|
reason: "sync",
|
||||||
|
});
|
||||||
membership.leave();
|
homeserver.disconnectReason$.next("probablyLeft");
|
||||||
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
|
expect(membership.state$.value).toEqual({
|
||||||
// A sync outage after leaving must not bring the media back
|
kind: "reconnecting",
|
||||||
homeserver.combined$.next([false, "sync"]);
|
reason: "probablyLeft",
|
||||||
homeserver.combined$.next([true, null]);
|
});
|
||||||
expect(local.setPublishing).toHaveBeenLastCalledWith(false);
|
homeserver.disconnectReason$.next(null);
|
||||||
await flushPromises();
|
expect(membership.state$.value).toEqual({ kind: "joined" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
|
it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
|
||||||
@@ -107,57 +87,54 @@ describe("createLocalMembership$", () => {
|
|||||||
expect(delegate).not.toHaveBeenCalled();
|
expect(delegate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a transport that could not be prepared as fatal", () => {
|
it("reports a transport that could not be prepared as failed", () => {
|
||||||
const error = new MatrixRTCError("no token");
|
const error = new MatrixRTCError("no token");
|
||||||
const { membership } = setup(throwError(() => error));
|
const { membership } = setup(throwError(() => error));
|
||||||
expect(membership.state$.value).toBe(error);
|
expect(membership.state$.value).toEqual({ kind: "failed", error });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a connection loss and a publish failure in the media state", () => {
|
it("reports the membership manager giving up as failed, and stays failed", () => {
|
||||||
const { membership, local, prepared$ } = setup();
|
const { membership, homeserver, prepared$, membershipManagerError$ } =
|
||||||
|
setup();
|
||||||
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||||
local.connectionState$.next(MediaConnectionState.Connected);
|
membershipManagerError$.next(new Error("gave up"));
|
||||||
|
|
||||||
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;
|
const state = membership.state$.value;
|
||||||
expect(typeof state === "object" && "media" in state && state.media).toBe(
|
expect(state.kind).toBe("failed");
|
||||||
failure,
|
|
||||||
);
|
homeserver.disconnectReason$.next("sync");
|
||||||
|
homeserver.disconnectReason$.next(null);
|
||||||
|
expect(membership.state$.value).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is left after a failure once leave() is called", () => {
|
||||||
|
const { membership } = setup(throwError(() => new MatrixRTCError("x")));
|
||||||
|
membership.leave();
|
||||||
|
expect(membership.state$.value).toEqual({ kind: "left" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): {
|
function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): {
|
||||||
membership: LocalMembership;
|
membership: LocalMembership;
|
||||||
local: FakeLocalMediaBackend;
|
|
||||||
homeserver: FakeHomeserverConnected;
|
homeserver: FakeHomeserverConnected;
|
||||||
prepared$: Subject<PreparedTransport>;
|
prepared$: Subject<PreparedTransport>;
|
||||||
delayId$: BehaviorSubject<string | null>;
|
delayId$: BehaviorSubject<string | null>;
|
||||||
|
membershipManagerError$: Subject<unknown>;
|
||||||
joinMatrixRTC: ReturnType<typeof vi.fn>;
|
joinMatrixRTC: ReturnType<typeof vi.fn>;
|
||||||
delegate: ReturnType<typeof vi.fn>;
|
delegate: ReturnType<typeof vi.fn>;
|
||||||
} {
|
} {
|
||||||
const prepared$ = new Subject<PreparedTransport>();
|
const prepared$ = new Subject<PreparedTransport>();
|
||||||
const local = fakeLocalMediaBackend();
|
|
||||||
const homeserver = fakeHomeserverConnected();
|
const homeserver = fakeHomeserverConnected();
|
||||||
const delayId$ = new BehaviorSubject<string | null>(null);
|
const delayId$ = new BehaviorSubject<string | null>(null);
|
||||||
|
const membershipManagerError$ = new Subject<unknown>();
|
||||||
const joinMatrixRTC = vi.fn();
|
const joinMatrixRTC = vi.fn();
|
||||||
const delegate = vi.fn(async () => Promise.resolve());
|
const delegate = vi.fn(async () => Promise.resolve());
|
||||||
const membership = createLocalMembership$({
|
const membership = createLocalMembership$({
|
||||||
scope: testScope(),
|
scope: testScope(),
|
||||||
local,
|
|
||||||
delegateDelayedLeave: delegate,
|
delegateDelayedLeave: delegate,
|
||||||
preparedTransport$: preparedTransportWithErrors$ ?? prepared$,
|
preparedTransport$: preparedTransportWithErrors$ ?? prepared$,
|
||||||
homeserverConnected: homeserver,
|
homeserverConnected: homeserver,
|
||||||
joinMatrixRTC,
|
joinMatrixRTC,
|
||||||
membershipManagerError$: new Subject(),
|
membershipManagerError$,
|
||||||
matrixRTCSession: {
|
matrixRTCSession: {
|
||||||
leaveRoomSession: vi.fn(async () => Promise.resolve(true)),
|
leaveRoomSession: vi.fn(async () => Promise.resolve(true)),
|
||||||
},
|
},
|
||||||
@@ -167,48 +144,25 @@ function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
membership,
|
membership,
|
||||||
local,
|
|
||||||
homeserver,
|
homeserver,
|
||||||
prepared$,
|
prepared$,
|
||||||
delayId$,
|
delayId$,
|
||||||
|
membershipManagerError$,
|
||||||
joinMatrixRTC,
|
joinMatrixRTC,
|
||||||
delegate,
|
delegate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type FakeLocalMediaBackend = Omit<
|
type FakeHomeserverConnected = {
|
||||||
LocalMediaBackend,
|
disconnectReason$: BehaviorSubject<
|
||||||
"connectionState$" | "publishError$" | "setPublishing"
|
HomeserverConnected["disconnectReason$"]["value"]
|
||||||
> & {
|
>;
|
||||||
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,
|
|
||||||
),
|
|
||||||
tracks$: new BehaviorSubject(null),
|
|
||||||
encryptionError$: EMPTY,
|
|
||||||
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 {
|
function fakeHomeserverConnected(): FakeHomeserverConnected {
|
||||||
return {
|
return {
|
||||||
combined$: new BehaviorSubject<HomeserverConnected["combined$"]["value"]>([
|
disconnectReason$: new BehaviorSubject<
|
||||||
true,
|
HomeserverConnected["disconnectReason$"]["value"]
|
||||||
null,
|
>(null),
|
||||||
]),
|
|
||||||
rtsSession$: new BehaviorSubject(Status.Connected),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
type MatrixRTCSession as JsSdkRTCSession,
|
type MatrixRTCSession as JsSdkRTCSession,
|
||||||
type Status as RTCSessionStatus,
|
|
||||||
type Transport,
|
type Transport,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||||
@@ -16,53 +15,40 @@ import {
|
|||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
catchError,
|
catchError,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
concat,
|
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
map,
|
|
||||||
NEVER,
|
NEVER,
|
||||||
type Observable,
|
type Observable,
|
||||||
of,
|
scan,
|
||||||
pairwise,
|
|
||||||
race,
|
|
||||||
Subject,
|
|
||||||
switchMap,
|
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { type Behavior } from "../reactive/Behavior";
|
import { type Behavior } from "../reactive/Behavior";
|
||||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||||
import { type DelayedLeaveTimings, type SessionTimings } from "../config";
|
import { type DelayedLeaveTimings, type SessionTimings } from "../config";
|
||||||
import { type DisconnectReason } from "../api";
|
import { type MatrixDisconnectReason } from "../api";
|
||||||
import {
|
import {
|
||||||
type MatrixRTCError,
|
type MatrixRTCError,
|
||||||
MembershipManagerError,
|
MembershipManagerError,
|
||||||
toMatrixRTCError,
|
toMatrixRTCError,
|
||||||
} from "../errors";
|
} from "../errors";
|
||||||
import {
|
import {
|
||||||
type LocalMediaBackend,
|
|
||||||
type MediaBackend,
|
type MediaBackend,
|
||||||
MediaConnectionState,
|
|
||||||
type TransportCapabilities,
|
type TransportCapabilities,
|
||||||
} from "../media-backend/api";
|
} from "../media-backend/api";
|
||||||
import { type HomeserverConnected } from "./HomeserverConnected";
|
import { type HomeserverConnected } from "./HomeserverConnected";
|
||||||
|
|
||||||
export enum TransportState {
|
export type MembershipState =
|
||||||
Waiting = "transport_waiting",
|
/** Asking the homeserver for a transport, or preparing it. */
|
||||||
}
|
| { kind: "waitingForTransport" }
|
||||||
|
/** Transport known; sending the membership for the first time. */
|
||||||
export enum PublishState {
|
| { kind: "joining" }
|
||||||
WaitingForUser = "publish_waiting_for_user",
|
/** Syncing, membership confirmed by the server, delayed leave still being refreshed. */
|
||||||
Publishing = "publish_publishing",
|
| { kind: "joined" }
|
||||||
}
|
/** Was joined and one homeserver link is down; the membership manager is retrying. */
|
||||||
|
| { kind: "reconnecting"; reason: MatrixDisconnectReason }
|
||||||
export type LocalMemberMediaState =
|
/** A transport, join or membership manager error ended the membership. Terminal unless `left`. */
|
||||||
| { connection: MediaConnectionState | MatrixRTCError }
|
| { kind: "failed"; error: MatrixRTCError }
|
||||||
| PublishState
|
/** `leave()` was called. Terminal. */
|
||||||
| MatrixRTCError;
|
| { kind: "left" };
|
||||||
|
|
||||||
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. */
|
/** The local transport, found and prepared, with what the join needs to know about it. */
|
||||||
export interface PreparedTransport extends TransportCapabilities {
|
export interface PreparedTransport extends TransportCapabilities {
|
||||||
@@ -71,7 +57,6 @@ export interface PreparedTransport extends TransportCapabilities {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
scope: ObservableScope;
|
scope: ObservableScope;
|
||||||
local: LocalMediaBackend;
|
|
||||||
delegateDelayedLeave: MediaBackend["delegateDelayedLeave"];
|
delegateDelayedLeave: MediaBackend["delegateDelayedLeave"];
|
||||||
preparedTransport$: Observable<PreparedTransport>;
|
preparedTransport$: Observable<PreparedTransport>;
|
||||||
homeserverConnected: HomeserverConnected;
|
homeserverConnected: HomeserverConnected;
|
||||||
@@ -89,23 +74,18 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalMembership {
|
export interface LocalMembership {
|
||||||
/** Sends the leave and stops publishing. Final: there is no joining again. */
|
/** Sends the leave. Final: there is no joining again. */
|
||||||
leave: () => void;
|
leave: () => void;
|
||||||
state$: Behavior<LocalMemberState>;
|
state$: Behavior<MembershipState>;
|
||||||
/** 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, then publishes
|
* The Matrix side of the local member: waits for the transport, enters the
|
||||||
* and enters the MatrixRTC session, and leaves both in step on `leave()`.
|
* MatrixRTC session, keeps the delayed leave delegated, and leaves on
|
||||||
|
* `leave()`. Knows nothing about the media.
|
||||||
*/
|
*/
|
||||||
export function createLocalMembership$({
|
export function createLocalMembership$({
|
||||||
scope,
|
scope,
|
||||||
local,
|
|
||||||
delegateDelayedLeave,
|
delegateDelayedLeave,
|
||||||
preparedTransport$: preparedTransportWithErrors$,
|
preparedTransport$: preparedTransportWithErrors$,
|
||||||
homeserverConnected,
|
homeserverConnected,
|
||||||
@@ -118,30 +98,20 @@ export function createLocalMembership$({
|
|||||||
}: Props): LocalMembership {
|
}: Props): LocalMembership {
|
||||||
const logger = parentLogger.getChild("[LocalMembership]");
|
const logger = parentLogger.getChild("[LocalMembership]");
|
||||||
|
|
||||||
const fatalTransportError$ = new Subject<MatrixRTCError>();
|
const transportError$ = new BehaviorSubject<MatrixRTCError | null>(null);
|
||||||
const preparedTransport$ = preparedTransportWithErrors$.pipe(
|
|
||||||
catchError((e: unknown) => {
|
|
||||||
fatalTransportError$.next(toMatrixRTCError(e));
|
|
||||||
return NEVER;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const transport$ = scope.behavior<PreparedTransport | null>(
|
const transport$ = scope.behavior<PreparedTransport | null>(
|
||||||
preparedTransport$,
|
preparedTransportWithErrors$.pipe(
|
||||||
|
catchError((e: unknown) => {
|
||||||
|
transportError$.next(toMatrixRTCError(e));
|
||||||
|
return NEVER;
|
||||||
|
}),
|
||||||
|
),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const joined$ = new BehaviorSubject(true);
|
const joined$ = new BehaviorSubject(true);
|
||||||
const matrixError$ = new BehaviorSubject<MatrixRTCError | null>(null);
|
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(
|
|
||||||
[joined$, homeserverConnected.combined$],
|
|
||||||
(joined, [connected]) => joined && connected,
|
|
||||||
)
|
|
||||||
.pipe(distinctUntilChanged(), scope.bind())
|
|
||||||
.subscribe((publish) => local.setPublishing(publish));
|
|
||||||
|
|
||||||
scope.reconcile(
|
scope.reconcile(
|
||||||
scope.behavior(combineLatest([transport$, joined$])),
|
scope.behavior(combineLatest([transport$, joined$])),
|
||||||
async ([prepared, joined]) => {
|
async ([prepared, joined]) => {
|
||||||
@@ -189,78 +159,52 @@ export function createLocalMembership$({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaState$ = scope.behavior<LocalMemberMediaState>(
|
const state$ = scope.behavior<MembershipState>(
|
||||||
combineLatest([local.connectionState$, joined$]).pipe(
|
combineLatest([
|
||||||
map(([connectionState, joined]) => {
|
joined$,
|
||||||
if (connectionState !== MediaConnectionState.Connected)
|
transportError$,
|
||||||
return {
|
matrixError$,
|
||||||
connection:
|
transport$,
|
||||||
connectionState instanceof Error
|
homeserverConnected.disconnectReason$,
|
||||||
? toMatrixRTCError(connectionState)
|
]).pipe(
|
||||||
: connectionState,
|
scan(
|
||||||
};
|
(previous, [joined, transportError, matrixError, transport, reason]) =>
|
||||||
return joined ? PublishState.Publishing : PublishState.WaitingForUser;
|
membershipState(previous, {
|
||||||
}),
|
joined,
|
||||||
|
error: transportError ?? matrixError,
|
||||||
|
hasTransport: transport !== null,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
{ kind: "waitingForTransport" } as MembershipState,
|
||||||
|
),
|
||||||
distinctUntilChanged(deepCompare),
|
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 {
|
return {
|
||||||
leave: () => joined$.next(false),
|
leave: () => joined$.next(false),
|
||||||
state$,
|
state$,
|
||||||
connected$,
|
|
||||||
reconnecting$,
|
|
||||||
disconnectReason$,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MembershipInputs {
|
||||||
|
joined: boolean;
|
||||||
|
error: MatrixRTCError | null;
|
||||||
|
hasTransport: boolean;
|
||||||
|
reason: MatrixDisconnectReason | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next state, in priority order; `left` and `failed` are absorbing. */
|
||||||
|
function membershipState(
|
||||||
|
previous: MembershipState,
|
||||||
|
{ joined, error, hasTransport, reason }: MembershipInputs,
|
||||||
|
): MembershipState {
|
||||||
|
if (!joined) return { kind: "left" };
|
||||||
|
if (previous.kind === "failed") return previous;
|
||||||
|
if (error !== null) return { kind: "failed", error };
|
||||||
|
if (!hasTransport) return { kind: "waitingForTransport" };
|
||||||
|
if (reason === null) return { kind: "joined" };
|
||||||
|
const wasJoined =
|
||||||
|
previous.kind === "joined" || previous.kind === "reconnecting";
|
||||||
|
return wasJoined ? { kind: "reconnecting", reason } : { kind: "joining" };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2026 Element Creations Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } 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 "./LocalMembership";
|
|
||||||
import { fatalError, participationStatus } 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("participationStatus", () => {
|
|
||||||
it.each<[string, LocalMemberState, boolean, boolean, boolean, string]>([
|
|
||||||
[
|
|
||||||
"no transport yet",
|
|
||||||
TransportState.Waiting,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
"waitingForTransport",
|
|
||||||
],
|
|
||||||
["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,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
"disconnected",
|
|
||||||
],
|
|
||||||
["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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2026 Element Creations Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type MatrixRTCError, type ParticipationStatus } from "../api";
|
|
||||||
import { type LocalMemberState, TransportState } from "./LocalMembership";
|
|
||||||
|
|
||||||
/** The local member's state, collapsed to what a host shows. */
|
|
||||||
export function participationStatus(
|
|
||||||
state: LocalMemberState,
|
|
||||||
connected: boolean,
|
|
||||||
reconnecting: boolean,
|
|
||||||
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 "connecting";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/*
|
||||||
|
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 { MatrixRTCError, type ParticipationState } from "./api";
|
||||||
|
import { type MembershipState } from "./matrixrtc/LocalMembership";
|
||||||
|
import { MediaConnectionState } from "./media-backend/api";
|
||||||
|
import { participationState } from "./participationState";
|
||||||
|
|
||||||
|
const error = new MatrixRTCError("gone");
|
||||||
|
const waiting: ParticipationState = { kind: "waitingForTransport" };
|
||||||
|
const connecting: ParticipationState = { kind: "connecting" };
|
||||||
|
const connected: ParticipationState = { kind: "connected" };
|
||||||
|
const reconnecting: ParticipationState = {
|
||||||
|
kind: "reconnecting",
|
||||||
|
reason: "media",
|
||||||
|
};
|
||||||
|
const joined: MembershipState = { kind: "joined" };
|
||||||
|
|
||||||
|
describe("participationState", () => {
|
||||||
|
it.each<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
ParticipationState,
|
||||||
|
MembershipState,
|
||||||
|
MediaConnectionState | Error,
|
||||||
|
ParticipationState,
|
||||||
|
]
|
||||||
|
>([
|
||||||
|
[
|
||||||
|
"no transport yet",
|
||||||
|
waiting,
|
||||||
|
{ kind: "waitingForTransport" },
|
||||||
|
MediaConnectionState.Initialized,
|
||||||
|
waiting,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"joining",
|
||||||
|
waiting,
|
||||||
|
{ kind: "joining" },
|
||||||
|
MediaConnectionState.Connecting,
|
||||||
|
connecting,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"joined, media still connecting",
|
||||||
|
connecting,
|
||||||
|
joined,
|
||||||
|
MediaConnectionState.Connecting,
|
||||||
|
connecting,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"both links up",
|
||||||
|
connecting,
|
||||||
|
joined,
|
||||||
|
MediaConnectionState.Connected,
|
||||||
|
connected,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"media dropped after connecting",
|
||||||
|
connected,
|
||||||
|
joined,
|
||||||
|
MediaConnectionState.Reconnecting,
|
||||||
|
reconnecting,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"homeserver dropped after connecting",
|
||||||
|
connected,
|
||||||
|
{ kind: "reconnecting", reason: "sync" },
|
||||||
|
MediaConnectionState.Connected,
|
||||||
|
{ kind: "reconnecting", reason: "sync" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"the matrix side wins over the media side",
|
||||||
|
reconnecting,
|
||||||
|
{ kind: "reconnecting", reason: "membership" },
|
||||||
|
MediaConnectionState.Reconnecting,
|
||||||
|
{ kind: "reconnecting", reason: "membership" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"membership reconnecting before the media ever came up",
|
||||||
|
connecting,
|
||||||
|
{ kind: "reconnecting", reason: "sync" },
|
||||||
|
MediaConnectionState.Connecting,
|
||||||
|
connecting,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"back from a reconnect",
|
||||||
|
reconnecting,
|
||||||
|
joined,
|
||||||
|
MediaConnectionState.Connected,
|
||||||
|
connected,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a membership error",
|
||||||
|
connected,
|
||||||
|
{ kind: "failed", error },
|
||||||
|
MediaConnectionState.Connected,
|
||||||
|
{ kind: "failed", error },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a media connection error",
|
||||||
|
connected,
|
||||||
|
joined,
|
||||||
|
error,
|
||||||
|
{ kind: "failed", error },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"failed stays failed",
|
||||||
|
{ kind: "failed", error },
|
||||||
|
joined,
|
||||||
|
MediaConnectionState.Connected,
|
||||||
|
{ kind: "failed", error },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"left",
|
||||||
|
connected,
|
||||||
|
{ kind: "left" },
|
||||||
|
MediaConnectionState.Stopped,
|
||||||
|
{ kind: "left" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"left after an error",
|
||||||
|
{ kind: "failed", error },
|
||||||
|
{ kind: "left" },
|
||||||
|
MediaConnectionState.Stopped,
|
||||||
|
{ kind: "left" },
|
||||||
|
],
|
||||||
|
])("%s", (_name, previous, membership, media, expected) => {
|
||||||
|
expect(participationState(previous, membership, media)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps a plain media error", () => {
|
||||||
|
const state = participationState(connected, joined, new Error("boom"));
|
||||||
|
expect(state.kind).toBe("failed");
|
||||||
|
if (state.kind === "failed") {
|
||||||
|
expect(state.error).toBeInstanceOf(MatrixRTCError);
|
||||||
|
expect(state.error.cause).toBeInstanceOf(Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
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 ParticipationState } from "./api";
|
||||||
|
import { toMatrixRTCError } from "./errors";
|
||||||
|
import { type MembershipState } from "./matrixrtc/LocalMembership";
|
||||||
|
import { MediaConnectionState } from "./media-backend/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The membership combined with the local media connection, in priority
|
||||||
|
* order; `left` and `failed` are absorbing. "Connected once" is tracked here
|
||||||
|
* rather than in the membership because `connected` needs both links.
|
||||||
|
*/
|
||||||
|
export function participationState(
|
||||||
|
previous: ParticipationState,
|
||||||
|
membership: MembershipState,
|
||||||
|
media: MediaConnectionState | Error,
|
||||||
|
): ParticipationState {
|
||||||
|
if (membership.kind === "left") return { kind: "left" };
|
||||||
|
if (previous.kind === "failed") return previous;
|
||||||
|
if (membership.kind === "failed") return membership;
|
||||||
|
if (media instanceof Error)
|
||||||
|
return { kind: "failed", error: toMatrixRTCError(media) };
|
||||||
|
if (membership.kind === "waitingForTransport") return membership;
|
||||||
|
if (membership.kind === "joining") return { kind: "connecting" };
|
||||||
|
const reason =
|
||||||
|
membership.kind === "reconnecting"
|
||||||
|
? membership.reason
|
||||||
|
: media === MediaConnectionState.Connected
|
||||||
|
? null
|
||||||
|
: "media";
|
||||||
|
if (reason === null) return { kind: "connected" };
|
||||||
|
const wasConnected =
|
||||||
|
previous.kind === "connected" || previous.kind === "reconnecting";
|
||||||
|
return wasConnected
|
||||||
|
? { kind: "reconnecting", reason }
|
||||||
|
: { kind: "connecting" };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user