Unify LiveKit and Matrix connection states

This commit is contained in:
Timo K
2025-12-02 19:40:08 +01:00
parent f05d4b158e
commit 2e646bfac1
10 changed files with 238 additions and 233 deletions

View File

@@ -160,6 +160,7 @@ export const GroupCallView: FC<Props> = ({
}, [rtcSession]); }, [rtcSession]);
// TODO move this into the callViewModel LocalMembership.ts // TODO move this into the callViewModel LocalMembership.ts
// We might actually not need this at all. Since we get into fatalError on those errors already?
useTypedEventEmitter( useTypedEventEmitter(
rtcSession, rtcSession,
MatrixRTCSessionEvent.MembershipManagerError, MatrixRTCSessionEvent.MembershipManagerError,

View File

@@ -452,18 +452,14 @@ export function createCallViewModel$(
const localMembership = createLocalMembership$({ const localMembership = createLocalMembership$({
scope: scope, scope: scope,
homeserverConnected$: createHomeserverConnected$( homeserverConnected: createHomeserverConnected$(
scope, scope,
client, client,
matrixRTCSession, matrixRTCSession,
), ),
muteStates: muteStates, muteStates: muteStates,
joinMatrixRTC: async (transport: LivekitTransport) => { joinMatrixRTC: (transport: LivekitTransport) => {
return enterRTCSession( enterRTCSession(matrixRTCSession, transport, connectOptions$.value);
matrixRTCSession,
transport,
connectOptions$.value,
);
}, },
createPublisherFactory: (connection: Connection) => { createPublisherFactory: (connection: Connection) => {
return new Publisher( return new Publisher(
@@ -573,17 +569,6 @@ export function createCallViewModel$(
), ),
); );
/**
* Whether various media/event sources should pretend to be disconnected from
* all network input, even if their connection still technically works.
*/
// We do this when the app is in the 'reconnecting' state, because it might be
// that the LiveKit connection is still functional while the homeserver is
// down, for example, and we want to avoid making people worry that the app is
// in a split-brained state.
// DISCUSSION own membership manager ALSO this probably can be simplifis
const reconnecting$ = localMembership.reconnecting$;
const audioParticipants$ = scope.behavior( const audioParticipants$ = scope.behavior(
matrixLivekitMembers$.pipe( matrixLivekitMembers$.pipe(
switchMap((membersWithEpoch) => { switchMap((membersWithEpoch) => {
@@ -631,7 +616,7 @@ export function createCallViewModel$(
); );
const handsRaised$ = scope.behavior( const handsRaised$ = scope.behavior(
handsRaisedSubject$.pipe(pauseWhen(reconnecting$)), handsRaisedSubject$.pipe(pauseWhen(localMembership.reconnecting$)),
); );
const reactions$ = scope.behavior( const reactions$ = scope.behavior(
@@ -644,7 +629,7 @@ export function createCallViewModel$(
]), ]),
), ),
), ),
pauseWhen(reconnecting$), pauseWhen(localMembership.reconnecting$),
), ),
); );
@@ -735,7 +720,7 @@ export function createCallViewModel$(
livekitRoom$, livekitRoom$,
focusUrl$, focusUrl$,
mediaDevices, mediaDevices,
reconnecting$, localMembership.reconnecting$,
displayName$, displayName$,
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId), matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)), handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)),
@@ -827,11 +812,17 @@ export function createCallViewModel$(
}), }),
); );
const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> = const shouldLeave$: Observable<
merge( "user" | "timeout" | "decline" | "allOthersLeft"
autoLeave$, > = merge(
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)), autoLeave$,
).pipe(scope.share); merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
).pipe(scope.share);
shouldLeave$.pipe(scope.bind()).subscribe((reason) => {
logger.info(`Call left due to ${reason}`);
localMembership.requestDisconnect();
});
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | null>( const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | null>(
userMedia$.pipe( userMedia$.pipe(
@@ -1453,7 +1444,7 @@ export function createCallViewModel$(
autoLeave$: autoLeave$, autoLeave$: autoLeave$,
callPickupState$: callPickupState$, callPickupState$: callPickupState$,
ringOverlay$: ringOverlay$, ringOverlay$: ringOverlay$,
leave$: leave$, leave$: shouldLeave$,
hangup: (): void => userHangup$.next(), hangup: (): void => userHangup$.next(),
join: localMembership.requestConnect, join: localMembership.requestConnect,
toggleScreenSharing: toggleScreenSharing, toggleScreenSharing: toggleScreenSharing,
@@ -1500,7 +1491,7 @@ export function createCallViewModel$(
showFooter$: showFooter$, showFooter$: showFooter$,
earpieceMode$: earpieceMode$, earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$, audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: reconnecting$, reconnecting$: localMembership.reconnecting$,
}; };
} }

View File

@@ -97,106 +97,106 @@ describe("createHomeserverConnected$", () => {
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is // LLM generated test cases. They are a bit overkill but I improved the mocking so it is
// easy enough to read them so I think they can stay. // easy enough to read them so I think they can stay.
it("is false when sync state is not Syncing", () => { it("is false when sync state is not Syncing", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
it("remains false while membership status is not Connected even if sync is Syncing", () => { it("remains false while membership status is not Connected even if sync is Syncing", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(hsConnected$.value).toBe(false); // membership still disconnected expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
}); });
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => { it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
// Make sync loop OK // Make sync loop OK
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
// 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$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
it("becomes true only when all three conditions are satisfied", () => { it("becomes true only when all three conditions are satisfied", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
// 1. Sync loop connected // 1. Sync loop connected
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(hsConnected$.value).toBe(false); // not yet membership connected expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
// 2. Membership connected // 2. Membership connected
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); // probablyLeft is false expect(hsConnected.combined$.value).toBe(true); // probablyLeft is false
}); });
it("drops back to false when sync loop leaves Syncing", () => { it("drops back to false when sync loop leaves Syncing", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
// Reach connected state // Reach connected state
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
// Sync loop error => should flip false // Sync loop error => should flip false
client.setSyncState(SyncState.Error); client.setSyncState(SyncState.Error);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
it("drops back to false when membership status becomes disconnected", () => { it("drops back to false when membership status becomes disconnected", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
session.setMembershipStatus(Status.Disconnected); session.setMembershipStatus(Status.Disconnected);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
it("drops to false when ProbablyLeft is emitted after being true", () => { it("drops to false when ProbablyLeft is emitted after being true", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => { it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
// 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$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
}); });
it("composite sequence reflects each individual failure reason", () => { it("composite sequence reflects each individual failure reason", () => {
const hsConnected$ = createHomeserverConnected$(scope, client, session); const hsConnected = createHomeserverConnected$(scope, client, session);
// Initially false (sync error + disconnected + not probably left) // Initially false (sync error + disconnected + not probably left)
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
// Fix sync only // Fix sync only
client.setSyncState(SyncState.Syncing); client.setSyncState(SyncState.Syncing);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
// Fix membership // Fix membership
session.setMembershipStatus(Status.Connected); session.setMembershipStatus(Status.Connected);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
// Introduce probablyLeft -> false // Introduce probablyLeft -> false
session.setProbablyLeft(true); session.setProbablyLeft(true);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
// Restore notProbablyLeft -> true again // Restore notProbablyLeft -> true again
session.setProbablyLeft(false); session.setProbablyLeft(false);
expect(hsConnected$.value).toBe(true); expect(hsConnected.combined$.value).toBe(true);
// Drop sync -> false // Drop sync -> false
client.setSyncState(SyncState.Error); client.setSyncState(SyncState.Error);
expect(hsConnected$.value).toBe(false); expect(hsConnected.combined$.value).toBe(false);
}); });
}); });

View File

@@ -25,6 +25,11 @@ import { type NodeStyleEventEmitter } from "../../../utils/test";
*/ */
const logger = rootLogger.getChild("[HomeserverConnected]"); const logger = rootLogger.getChild("[HomeserverConnected]");
export interface HomeserverConnected {
combined$: Behavior<boolean>;
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.
@@ -39,7 +44,7 @@ export function createHomeserverConnected$(
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">, client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter & matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">, Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
): Behavior<boolean> { ): HomeserverConnected {
const syncing$ = ( const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]> fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe( ).pipe(
@@ -47,12 +52,15 @@ export function createHomeserverConnected$(
map(([state]) => state === SyncState.Syncing), map(([state]) => state === SyncState.Syncing),
); );
const membershipConnected$ = fromEvent( const rtsSession$ = scope.behavior<Status>(
matrixRTCSession, fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
MembershipManagerEvent.StatusChanged, map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
).pipe( ),
startWith(null), Status.Unknown,
map(() => matrixRTCSession.membershipStatus === Status.Connected), );
const membershipConnected$ = rtsSession$.pipe(
map((status) => status === Status.Connected),
); );
// This is basically notProbablyLeft$ // This is basically notProbablyLeft$
@@ -71,15 +79,13 @@ export function createHomeserverConnected$(
map(() => matrixRTCSession.probablyLeft !== true), map(() => matrixRTCSession.probablyLeft !== true),
); );
const connectedCombined$ = and$( const combined$ = scope.behavior(
syncing$, and$(syncing$, membershipConnected$, certainlyConnected$).pipe(
membershipConnected$, tap((connected) => {
certainlyConnected$, logger.info(`Homeserver connected update: ${connected}`);
).pipe( }),
tap((connected) => { ),
logger.info(`Homeserver connected update: ${connected}`);
}),
); );
return scope.behavior(connectedCombined$); return { combined$, rtsSession$ };
} }

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
Status,
type LivekitTransport, type LivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
@@ -51,7 +52,7 @@ vi.mock("@livekit/components-core", () => ({
describe("LocalMembership", () => { describe("LocalMembership", () => {
describe("enterRTCSession", () => { describe("enterRTCSession", () => {
it("It joins the correct Session", async () => { it("It joins the correct Session", () => {
const focusFromOlderMembership = { const focusFromOlderMembership = {
type: "livekit", type: "livekit",
livekit_service_url: "http://my-oldest-member-service-url.com", livekit_service_url: "http://my-oldest-member-service-url.com",
@@ -107,7 +108,7 @@ describe("LocalMembership", () => {
joinRoomSession: vi.fn(), joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession; }) as unknown as MatrixRTCSession;
await enterRTCSession( enterRTCSession(
mockedSession, mockedSession,
{ {
livekit_alias: "roomId", livekit_alias: "roomId",
@@ -136,7 +137,7 @@ describe("LocalMembership", () => {
); );
}); });
it("It should not fail with configuration error if homeserver config has livekit url but not fallback", async () => { it("It should not fail with configuration error if homeserver config has livekit url but not fallback", () => {
mockConfig({}); mockConfig({});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({ vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
"org.matrix.msc4143.rtc_foci": [ "org.matrix.msc4143.rtc_foci": [
@@ -165,7 +166,7 @@ describe("LocalMembership", () => {
joinRoomSession: vi.fn(), joinRoomSession: vi.fn(),
}) as unknown as MatrixRTCSession; }) as unknown as MatrixRTCSession;
await enterRTCSession( enterRTCSession(
mockedSession, mockedSession,
{ {
livekit_alias: "roomId", livekit_alias: "roomId",
@@ -190,7 +191,6 @@ describe("LocalMembership", () => {
leaveRoomSession: () => {}, leaveRoomSession: () => {},
} as unknown as MatrixRTCSession, } as unknown as MatrixRTCSession,
muteStates: mockMuteStates(), muteStates: mockMuteStates(),
isHomeserverConnected: constant(true),
trackProcessorState$: constant({ trackProcessorState$: constant({
supported: false, supported: false,
processor: undefined, processor: undefined,
@@ -198,7 +198,10 @@ describe("LocalMembership", () => {
logger: logger, logger: logger,
createPublisherFactory: vi.fn(), createPublisherFactory: vi.fn(),
joinMatrixRTC: async (): Promise<void> => {}, joinMatrixRTC: async (): Promise<void> => {},
homeserverConnected$: constant(true), homeserverConnected: {
combined$: constant(true),
rtsSession$: constant(Status.Connected),
},
}; };
it("throws error on missing RTC config error", () => { it("throws error on missing RTC config error", () => {
@@ -258,8 +261,7 @@ describe("LocalMembership", () => {
} as unknown as LocalParticipant, } as unknown as LocalParticipant,
}), }),
state$: constant({ state$: constant({
state: "ConnectedToLkRoom", state: LivekitConnectionState.Connected,
livekitConnectionState$: constant(LivekitConnectionState.Connected),
}), }),
transport: aTransport, transport: aTransport,
} as unknown as Connection, } as unknown as Connection,
@@ -268,7 +270,7 @@ describe("LocalMembership", () => {
connectionManagerData.add( connectionManagerData.add(
{ {
state$: constant({ state$: constant({
state: "ConnectedToLkRoom", state: LivekitConnectionState.Connected,
}), }),
transport: bTransport, transport: bTransport,
} as unknown as Connection, } as unknown as Connection,
@@ -443,7 +445,7 @@ describe("LocalMembership", () => {
connectionManagerData$.next(new Epoch(connectionManagerData)); connectionManagerData$.next(new Epoch(connectionManagerData));
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: RTCBackendState.Initialized, state: LivekitConnectionState.Connected,
}); });
expect(publisherFactory).toHaveBeenCalledOnce(); expect(publisherFactory).toHaveBeenCalledOnce();
expect(localMembership.tracks$.value.length).toBe(0); expect(localMembership.tracks$.value.length).toBe(0);
@@ -473,7 +475,7 @@ describe("LocalMembership", () => {
publishResolver.resolve(); publishResolver.resolve();
await flushPromises(); await flushPromises();
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: RTCBackendState.Connected, state: RTCBackendState.ConnectedAndPublishing,
}); });
expect(publishers[0].stopPublishing).not.toHaveBeenCalled(); expect(publishers[0].stopPublishing).not.toHaveBeenCalled();
@@ -482,7 +484,7 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// stays in connected state because it is stopped before the update to tracks update the state. // stays in connected state because it is stopped before the update to tracks update the state.
expect(localMembership.connectionState.livekit$.value).toStrictEqual({ expect(localMembership.connectionState.livekit$.value).toStrictEqual({
state: RTCBackendState.Connected, state: RTCBackendState.ConnectedAndPublishing,
}); });
// stop all tracks after ending scopes // stop all tracks after ending scopes
expect(publishers[0].stopPublishing).toHaveBeenCalled(); expect(publishers[0].stopPublishing).toHaveBeenCalled();

View File

@@ -11,10 +11,11 @@ import {
ParticipantEvent, ParticipantEvent,
type LocalParticipant, type LocalParticipant,
type ScreenShareCaptureOptions, type ScreenShareCaptureOptions,
ConnectionState, ConnectionState as LivekitConnectionState,
} from "livekit-client"; } from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core"; import { observeParticipantEvents } from "@livekit/components-core";
import { import {
Status as RTCSessionStatus,
type LivekitTransport, type LivekitTransport,
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
@@ -27,7 +28,7 @@ import {
map, map,
type Observable, type Observable,
of, of,
scan, pairwise,
startWith, startWith,
switchMap, switchMap,
tap, tap,
@@ -37,10 +38,9 @@ import { deepCompare } from "matrix-js-sdk/lib/utils";
import { constant, type Behavior } from "../../Behavior"; import { constant, type Behavior } from "../../Behavior";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager";
import { ObservableScope } from "../../ObservableScope"; import { type ObservableScope } from "../../ObservableScope";
import { type Publisher } from "./Publisher"; import { type Publisher } from "./Publisher";
import { type MuteStates } from "../../MuteStates"; import { type MuteStates } from "../../MuteStates";
import { and$ } from "../../../utils/observable";
import { import {
ElementCallError, ElementCallError,
MembershipManagerError, MembershipManagerError,
@@ -51,7 +51,11 @@ import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import { MatrixRTCMode } from "../../../settings/settings.ts"; import { MatrixRTCMode } from "../../../settings/settings.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { type Connection } from "../remoteMembers/Connection.ts"; import {
type ConnectionState,
type Connection,
} from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts";
export enum RTCBackendState { export enum RTCBackendState {
Error = "error", Error = "error",
@@ -59,47 +63,32 @@ export enum RTCBackendState {
WaitingForTransport = "waiting_for_transport", WaitingForTransport = "waiting_for_transport",
/** A connection appeared so we can initialise the publisher */ /** A connection appeared so we can initialise the publisher */
WaitingForConnection = "waiting_for_connection", WaitingForConnection = "waiting_for_connection",
/** Connection and transport arrived, publisher Initialized */ /** Implies lk connection is connected */
Initialized = "Initialized",
CreatingTracks = "creating_tracks", CreatingTracks = "creating_tracks",
/** Implies lk connection is connected */
ReadyToPublish = "ready_to_publish", ReadyToPublish = "ready_to_publish",
/** Implies lk connection is connected */
WaitingToPublish = "waiting_to_publish", WaitingToPublish = "waiting_to_publish",
Connected = "connected", /** Implies lk connection is connected */
Disconnected = "disconnected", ConnectedAndPublishing = "fully_connected",
Disconnecting = "disconnecting",
} }
type LocalMemberRtcBackendState = type LocalMemberRTCBackendState =
| { state: RTCBackendState.Error; error: ElementCallError } | { state: RTCBackendState.Error; error: ElementCallError }
| { state: RTCBackendState.WaitingForTransport } | { state: Exclude<RTCBackendState, RTCBackendState.Error> }
| { state: RTCBackendState.WaitingForConnection } | ConnectionState;
| { state: RTCBackendState.Initialized }
| { state: RTCBackendState.CreatingTracks }
| { state: RTCBackendState.ReadyToPublish }
| { state: RTCBackendState.WaitingToPublish }
| { state: RTCBackendState.Connected }
| { state: RTCBackendState.Disconnected }
| { state: RTCBackendState.Disconnecting };
export enum MatrixState { export enum MatrixAdditionalState {
WaitingForTransport = "waiting_for_transport", WaitingForTransport = "waiting_for_transport",
Ready = "ready",
Connecting = "connecting",
Connected = "connected",
Disconnected = "disconnected",
Error = "Error",
} }
type LocalMemberMatrixState = type LocalMemberMatrixState =
| { state: MatrixState.Connected } | { state: MatrixAdditionalState.WaitingForTransport }
| { state: MatrixState.WaitingForTransport } | { state: "Error"; error: Error }
| { state: MatrixState.Ready } | { state: RTCSessionStatus };
| { state: MatrixState.Connecting }
| { state: MatrixState.Disconnected }
| { state: MatrixState.Error; error: Error };
export interface LocalMemberConnectionState { export interface LocalMemberConnectionState {
livekit$: Behavior<LocalMemberRtcBackendState>; livekit$: Behavior<LocalMemberRTCBackendState>;
matrix$: Behavior<LocalMemberMatrixState>; matrix$: Behavior<LocalMemberMatrixState>;
} }
@@ -122,8 +111,8 @@ interface Props {
muteStates: MuteStates; muteStates: MuteStates;
connectionManager: IConnectionManager; connectionManager: IConnectionManager;
createPublisherFactory: (connection: Connection) => Publisher; createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: (transport: LivekitTransport) => Promise<void>; joinMatrixRTC: (transport: LivekitTransport) => void;
homeserverConnected$: Behavior<boolean>; homeserverConnected: HomeserverConnected;
localTransport$: Behavior<LivekitTransport | null>; localTransport$: Behavior<LivekitTransport | null>;
matrixRTCSession: Pick< matrixRTCSession: Pick<
MatrixRTCSession, MatrixRTCSession,
@@ -149,7 +138,7 @@ export const createLocalMembership$ = ({
scope, scope,
connectionManager, connectionManager,
localTransport$: localTransportCanThrow$, localTransport$: localTransportCanThrow$,
homeserverConnected$, homeserverConnected,
createPublisherFactory, createPublisherFactory,
joinMatrixRTC, joinMatrixRTC,
logger: parentLogger, logger: parentLogger,
@@ -175,10 +164,14 @@ export const createLocalMembership$ = ({
tracks$: Behavior<LocalTrack[]>; tracks$: Behavior<LocalTrack[]>;
participant$: Behavior<LocalParticipant | null>; participant$: Behavior<LocalParticipant | null>;
connection$: Behavior<Connection | null>; connection$: Behavior<Connection | null>;
homeserverConnected$: Behavior<boolean>; /** Shorthand for connectionState.matrix.state === Status.Reconnecting
// this needs to be discussed * Direct translation to the js-sdk membership manager connection `Status`.
/** @deprecated use state instead*/ */
reconnecting$: Behavior<boolean>; reconnecting$: Behavior<boolean>;
/** Shorthand for connectionState.matrix.state === Status.Disconnected
* Direct translation to the js-sdk membership manager connection `Status`.
*/
disconnected$: Behavior<boolean>;
} => { } => {
const logger = parentLogger.getChild("[LocalMembership]"); const logger = parentLogger.getChild("[LocalMembership]");
logger.debug(`Creating local membership..`); logger.debug(`Creating local membership..`);
@@ -232,49 +225,31 @@ export const createLocalMembership$ = ({
// * Whether we are "fully" connected to the call. Accounts for both the // * Whether we are "fully" connected to the call. Accounts for both the
// * connection to the MatrixRTC session and the LiveKit publish connection. // * connection to the MatrixRTC session and the LiveKit publish connection.
// */ // */
const connected$ = scope.behavior( // TODO remove this and just make it one single check of livekitConnectionState$
and$( // const connected$ = scope.behavior(
homeserverConnected$.pipe( // localConnectionState$.pipe(
tap((v) => logger.debug("matrix: Connected state changed", v)), // switchMap((state) => {
), // logger.debug("livekit: Connected state changed", state);
localConnectionState$.pipe( // if (!state) return of(false);
switchMap((state) => { // if (state.state === "ConnectedToLkRoom") {
logger.debug("livekit: Connected state changed", state); // logger.debug(
if (!state) return of(false); // "livekit: Connected state changed (inner livekitConnectionState$)",
if (state.state === "ConnectedToLkRoom") { // state.livekitConnectionState$.value,
logger.debug( // );
"livekit: Connected state changed (inner livekitConnectionState$)", // return state.livekitConnectionState$.pipe(
state.livekitConnectionState$.value, // map((lkState) => lkState === ConnectionState.Connected),
); // );
return state.livekitConnectionState$.pipe( // }
map((lkState) => lkState === ConnectionState.Connected), // return of(false);
); // }),
} // ),
return of(false); // );
}),
),
).pipe(tap((v) => logger.debug("combined: Connected state changed", v))),
);
// MATRIX RELATED // MATRIX RELATED
// /**
// * Whether we should tell the user that we're reconnecting to the call.
// */
// DISCUSSION is there a better way to do this?
// sth that is more deriectly implied from the membership manager of the js sdk. (fromEvent(matrixRTCSession, Reconnecting)) ??? or similar
const reconnecting$ = scope.behavior( const reconnecting$ = scope.behavior(
connected$.pipe( homeserverConnected.rtsSession$.pipe(
// We are reconnecting if we previously had some successful initial map((sessionStatus) => sessionStatus === RTCSessionStatus.Reconnecting),
// connection but are now disconnected
scan(
({ connectedPreviously }, connectedNow) => ({
connectedPreviously: connectedPreviously || connectedNow,
reconnecting: connectedPreviously && !connectedNow,
}),
{ connectedPreviously: false, reconnecting: false },
),
map(({ reconnecting }) => reconnecting),
), ),
); );
@@ -374,8 +349,9 @@ export const createLocalMembership$ = ({
logger.error("Multiple Livkit Errors:", e); logger.error("Multiple Livkit Errors:", e);
else fatalLivekitError$.next(e); else fatalLivekitError$.next(e);
}; };
const livekitState$: Behavior<LocalMemberRtcBackendState> = scope.behavior( const livekitState$: Behavior<LocalMemberRTCBackendState> = scope.behavior(
combineLatest([ combineLatest([
localConnectionState$,
publisher$, publisher$,
localTransport$, localTransport$,
tracks$.pipe( tracks$.pipe(
@@ -389,10 +365,12 @@ export const createLocalMembership$ = ({
map(() => true), map(() => true),
startWith(false), startWith(false),
), ),
// TODO use local connection state here to give the full pciture of the livekit state!
fatalLivekitError$, fatalLivekitError$,
]).pipe( ]).pipe(
map( map(
([ ([
localConnectionState,
publisher, publisher,
localTransport, localTransport,
tracks, tracks,
@@ -411,13 +389,21 @@ export const createLocalMembership$ = ({
const hasTracks = tracks.length > 0; const hasTracks = tracks.length > 0;
if (!localTransport) if (!localTransport)
return { state: RTCBackendState.WaitingForTransport }; return { state: RTCBackendState.WaitingForTransport };
if (!publisher) if (!localConnectionState)
return { state: RTCBackendState.WaitingForConnection }; return { state: RTCBackendState.WaitingForConnection };
if (!shouldStartTracks) return { state: RTCBackendState.Initialized }; if (
localConnectionState.state !== LivekitConnectionState.Connected ||
!publisher
)
// pass through the localConnectionState while we do not yet have a publisher or the state
// of the connection is not yet connected
return { state: localConnectionState.state };
if (!shouldStartTracks)
return { state: LivekitConnectionState.Connected };
if (!hasTracks) return { state: RTCBackendState.CreatingTracks }; if (!hasTracks) return { state: RTCBackendState.CreatingTracks };
if (!shouldConnect) return { state: RTCBackendState.ReadyToPublish }; if (!shouldConnect) return { state: RTCBackendState.ReadyToPublish };
if (!publishing) return { state: RTCBackendState.WaitingToPublish }; if (!publishing) return { state: RTCBackendState.WaitingToPublish };
return { state: RTCBackendState.Connected }; return { state: RTCBackendState.ConnectedAndPublishing };
}, },
), ),
distinctUntilChanged(deepCompare), distinctUntilChanged(deepCompare),
@@ -431,58 +417,70 @@ export const createLocalMembership$ = ({
else fatalMatrixError$.next(e); else fatalMatrixError$.next(e);
}; };
const matrixState$: Behavior<LocalMemberMatrixState> = scope.behavior( const matrixState$: Behavior<LocalMemberMatrixState> = scope.behavior(
combineLatest([ combineLatest([localTransport$, homeserverConnected.rtsSession$]).pipe(
localTransport$, map(([localTransport, rtcSessionStatus]) => {
connectRequested$, if (!localTransport)
homeserverConnected$, return { state: MatrixAdditionalState.WaitingForTransport };
]).pipe( return { state: rtcSessionStatus };
map(([localTransport, connectRequested, homeserverConnected]) => {
if (!localTransport) return { state: MatrixState.WaitingForTransport };
if (!connectRequested) return { state: MatrixState.Ready };
if (!homeserverConnected) return { state: MatrixState.Connecting };
return { state: MatrixState.Connected };
}), }),
), ),
); );
// Keep matrix rtc session in sync with localTransport$, connectRequested$ and muteStates.video.enabled$ // inform the widget about the connect and disconnect intent from the user.
scope
.behavior(connectRequested$.pipe(pairwise(), scope.bind()), [
undefined,
connectRequested$.value,
])
.subscribe(([prev, current]) => {
if (!widget) return;
if (!prev && current) {
try {
void widget.api.transport.send(ElementWidgetActions.JoinCall, {});
} catch (e) {
logger.error("Failed to send join action", e);
}
}
if (prev && !current) {
try {
void widget?.api.transport.send(ElementWidgetActions.HangupCall, {});
} catch (e) {
logger.error("Failed to send hangup action", e);
}
}
});
combineLatest([muteStates.video.enabled$, homeserverConnected.combined$])
.pipe(scope.bind())
.subscribe(([videoEnabled, connected]) => {
if (!connected) return;
void matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio");
});
// Keep matrix rtc session in sync with localTransport$, connectRequested$
scope.reconcile( scope.reconcile(
scope.behavior(combineLatest([localTransport$, connectRequested$])), scope.behavior(combineLatest([localTransport$, connectRequested$])),
async ([transport, shouldConnect]) => { async ([transport, shouldConnect]) => {
if (!transport) return;
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration.
if (!shouldConnect) return; if (!shouldConnect) return;
if (!transport) return;
try { try {
await joinMatrixRTC(transport); joinMatrixRTC(transport);
} catch (error) { } catch (error) {
logger.error("Error entering RTC session", error); logger.error("Error entering RTC session", error);
if (error instanceof Error) if (error instanceof Error)
setMatrixError(new MembershipManagerError(error)); setMatrixError(new MembershipManagerError(error));
} }
// Update our member event when our mute state changes. return Promise.resolve(async (): Promise<void> => {
const callIntentScope = new ObservableScope();
// because this uses its own scope, we can start another reconciliation for the duration of one connection.
callIntentScope.reconcile(
muteStates.video.enabled$,
async (videoEnabled) =>
matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio"),
);
return async (): Promise<void> => {
callIntentScope.end();
try { try {
// Update matrixRTCSession to allow udpating the transport without leaving the session! // TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
await matrixRTCSession.leaveRoomSession(); await matrixRTCSession.leaveRoomSession(1000);
} catch (e) { } catch (e) {
logger.error("Error leaving RTC session", e); logger.error("Error leaving RTC session", e);
} }
try { });
await widget?.api.transport.send(ElementWidgetActions.HangupCall, {});
} catch (e) {
logger.error("Failed to send hangup action", e);
}
};
}, },
); );
@@ -497,7 +495,7 @@ export const createLocalMembership$ = ({
// pause tracks during the initial joining sequence too until we're sure // pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen. // that our own media is displayed on screen.
// TODO refactor this based no livekitState$ // TODO refactor this based no livekitState$
combineLatest([participant$, homeserverConnected$]) combineLatest([participant$, homeserverConnected.combined$])
.pipe(scope.bind()) .pipe(scope.bind())
.subscribe(([participant, connected]) => { .subscribe(([participant, connected]) => {
if (!participant) return; if (!participant) return;
@@ -590,8 +588,15 @@ export const createLocalMembership$ = ({
}, },
tracks$, tracks$,
participant$, participant$,
homeserverConnected$,
reconnecting$, reconnecting$,
disconnected$: scope.behavior(
matrixState$.pipe(
map(
(sessionStatus) =>
sessionStatus.state === RTCSessionStatus.Disconnected,
),
),
),
sharingScreen$, sharingScreen$,
toggleScreenSharing, toggleScreenSharing,
connection$: localConnection$, connection$: localConnection$,
@@ -626,11 +631,11 @@ interface EnterRTCSessionOptions {
* @throws If the widget could not send ElementWidgetActions.JoinCall action. * @throws If the widget could not send ElementWidgetActions.JoinCall action.
*/ */
// Exported for unit testing // Exported for unit testing
export async function enterRTCSession( export function enterRTCSession(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
transport: LivekitTransport, transport: LivekitTransport,
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions, { encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
): Promise<void> { ): void {
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId); PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
@@ -669,7 +674,4 @@ export async function enterRTCSession(
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0, unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
}, },
); );
if (widget) {
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
}
} }

View File

@@ -53,8 +53,7 @@ describe("Publisher", () => {
scope = new ObservableScope(); scope = new ObservableScope();
connection = { connection = {
state$: constant({ state$: constant({
state: "ConnectedToLkRoom", state: LivekitConenctionState.Connected,
livekitConnectionState$: constant(LivekitConenctionState.Connected),
}), }),
livekitRoom: mockLivekitRoom({ livekitRoom: mockLivekitRoom({
localParticipant: mockLocalParticipant({}), localParticipant: mockLocalParticipant({}),

View File

@@ -160,7 +160,7 @@ export class Publisher {
const { promise, resolve, reject } = Promise.withResolvers<void>(); const { promise, resolve, reject } = Promise.withResolvers<void>();
const sub = this.connection.state$.subscribe((s) => { const sub = this.connection.state$.subscribe((s) => {
switch (s.state) { switch (s.state) {
case "ConnectedToLkRoom": case LivekitConnectionState.Connected:
resolve(); resolve();
break; break;
case "FailedToStart": case "FailedToStart":

View File

@@ -125,7 +125,10 @@ function setupRemoteConnection(): Connection {
}; };
}); });
fakeLivekitRoom.connect.mockResolvedValue(undefined); fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
fakeLivekitRoom.state = LivekitConnectionState.Connected;
return Promise.resolve();
});
return new Connection(opts, logger); return new Connection(opts, logger);
} }
@@ -309,7 +312,7 @@ describe("Start connection states", () => {
capturedState = capturedStates.pop(); capturedState = capturedStates.pop();
if (capturedState && capturedState?.state === "FailedToStart") { if (capturedState && capturedState.state === "FailedToStart") {
expect(capturedState.error.message).toContain( expect(capturedState.error.message).toContain(
"Failed to connect to livekit", "Failed to connect to livekit",
); );
@@ -345,7 +348,7 @@ describe("Start connection states", () => {
const connectingState = capturedStates.shift(); const connectingState = capturedStates.shift();
expect(connectingState?.state).toEqual("ConnectingToLkRoom"); expect(connectingState?.state).toEqual("ConnectingToLkRoom");
const connectedState = capturedStates.shift(); const connectedState = capturedStates.shift();
expect(connectedState?.state).toEqual("ConnectedToLkRoom"); expect(connectedState?.state).toEqual("connected");
}); });
it("shutting down the scope should stop the connection", async () => { it("shutting down the scope should stop the connection", async () => {

View File

@@ -12,7 +12,7 @@ import {
} from "@livekit/components-core"; } from "@livekit/components-core";
import { import {
ConnectionError, ConnectionError,
type ConnectionState as LivekitConenctionState, type ConnectionState as LivekitConnectionState,
type Room as LivekitRoom, type Room as LivekitRoom,
type LocalParticipant, type LocalParticipant,
type RemoteParticipant, type RemoteParticipant,
@@ -47,17 +47,17 @@ export interface ConnectionOpts {
/** Optional factory to create the LiveKit room, mainly for testing purposes. */ /** Optional factory to create the LiveKit room, mainly for testing purposes. */
livekitRoomFactory: () => LivekitRoom; livekitRoomFactory: () => LivekitRoom;
} }
export enum ConnectionAdditionalState {
Initialized = "Initialized",
FetchingConfig = "FetchingConfig",
// FailedToStart = "FailedToStart",
Stopped = "Stopped",
ConnectingToLkRoom = "ConnectingToLkRoom",
}
export type ConnectionState = export type ConnectionState =
| { state: "Initialized" } | { state: ConnectionAdditionalState }
| { state: "FetchingConfig" } | { state: LivekitConnectionState }
| { state: "ConnectingToLkRoom" } | { state: "FailedToStart"; error: Error };
| {
state: "ConnectedToLkRoom";
livekitConnectionState$: Behavior<LivekitConenctionState>;
}
| { state: "FailedToStart"; error: Error }
| { state: "Stopped" };
/** /**
* A connection to a Matrix RTC LiveKit backend. * A connection to a Matrix RTC LiveKit backend.
@@ -67,7 +67,7 @@ export type ConnectionState =
export class Connection { export class Connection {
// Private Behavior // Private Behavior
private readonly _state$ = new BehaviorSubject<ConnectionState>({ private readonly _state$ = new BehaviorSubject<ConnectionState>({
state: "Initialized", state: ConnectionAdditionalState.Initialized,
}); });
/** /**
@@ -118,14 +118,14 @@ export class Connection {
this.stopped = false; this.stopped = false;
try { try {
this._state$.next({ this._state$.next({
state: "FetchingConfig", state: ConnectionAdditionalState.FetchingConfig,
}); });
const { url, jwt } = await this.getSFUConfigWithOpenID(); const { url, jwt } = await this.getSFUConfigWithOpenID();
// If we were stopped while fetching the config, don't proceed to connect // If we were stopped while fetching the config, don't proceed to connect
if (this.stopped) return; if (this.stopped) return;
this._state$.next({ this._state$.next({
state: "ConnectingToLkRoom", state: ConnectionAdditionalState.ConnectingToLkRoom,
}); });
try { try {
await this.livekitRoom.connect(url, jwt); await this.livekitRoom.connect(url, jwt);
@@ -154,12 +154,13 @@ export class Connection {
// If we were stopped while connecting, don't proceed to update state. // If we were stopped while connecting, don't proceed to update state.
if (this.stopped) return; if (this.stopped) return;
this._state$.next({ connectionStateObserver(this.livekitRoom)
state: "ConnectedToLkRoom", .pipe(this.scope.bind())
livekitConnectionState$: this.scope.behavior( .subscribe((lkState) => {
connectionStateObserver(this.livekitRoom), this._state$.next({
), state: lkState,
}); });
});
} catch (error) { } catch (error) {
this.logger.debug(`Failed to connect to LiveKit room: ${error}`); this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
this._state$.next({ this._state$.next({
@@ -191,7 +192,7 @@ export class Connection {
if (this.stopped) return; if (this.stopped) return;
await this.livekitRoom.disconnect(); await this.livekitRoom.disconnect();
this._state$.next({ this._state$.next({
state: "Stopped", state: ConnectionAdditionalState.Stopped,
}); });
this.stopped = true; this.stopped = true;
} }