use updated state logic in the sdk

This commit is contained in:
Timo K.
2026-10-14 10:23:29 +02:00
committed by Timo
parent cec5031f62
commit 598ec42390
5 changed files with 69 additions and 70 deletions
+6 -3
View File
@@ -147,8 +147,8 @@ export async function createMatrixRTCSdk(
vm.leave$
.pipe(
switchMap(() =>
participation.status$.pipe(
filter((status) => status === "left"),
participation.state$.pipe(
filter((state) => state.kind === "left"),
take(1),
),
),
@@ -164,7 +164,10 @@ export async function createMatrixRTCSdk(
},
leave: (): void => vm?.leave(),
stop: (): void => scope.end(),
connected$: over((p) => p.connected$, false),
connected$: over(
(p) => p.state$.pipe(map((state) => state.kind === "connected")),
false,
),
localMember$: over((p) => p.localMember$, null),
remoteMembers$: over((p) => p.remoteMembers$, []),
// Attested members only: the SDK has matched the sender
+16 -13
View File
@@ -261,19 +261,22 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
test("the selected audio output is handed to the client as it changes", () => {
const { mediaDevices, selected$ } = withAudioOutput();
withCallViewModel({ mediaDevices }, (_vm, _session, { rtcParticipation: participation }) => {
// Nothing selected yet, so the browser's choice stands
expect(participation.setAudioOutputDeviceId).not.toHaveBeenCalled();
selected$.next({ id: "speaker", virtualEarpiece: false });
expect(participation.setAudioOutputDeviceId).toHaveBeenCalledWith(
"speaker",
);
selected$.next({ id: "headphones", virtualEarpiece: false });
expect(participation.setAudioOutputDeviceId).toHaveBeenLastCalledWith(
"headphones",
);
expect(participation.setAudioOutputDeviceId).toHaveBeenCalledTimes(2);
});
withCallViewModel(
{ mediaDevices },
(_vm, _session, { rtcParticipation: participation }) => {
// Nothing selected yet, so the browser's choice stands
expect(participation.setAudioOutputDeviceId).not.toHaveBeenCalled();
selected$.next({ id: "speaker", virtualEarpiece: false });
expect(participation.setAudioOutputDeviceId).toHaveBeenCalledWith(
"speaker",
);
selected$.next({ id: "headphones", virtualEarpiece: false });
expect(participation.setAudioOutputDeviceId).toHaveBeenLastCalledWith(
"headphones",
);
expect(participation.setAudioOutputDeviceId).toHaveBeenCalledTimes(2);
},
);
});
test("a host that controls the audio devices keeps the output from the client", () => {
+28 -30
View File
@@ -295,7 +295,7 @@ export interface CallViewModel {
/** Call to initiate hangup. Use in conbination with reconnection state track the async hangup process. */
hangup: () => void;
/** Leaves the participation. The async leave can then be observed via connected$. */
/** Leaves the participation. The leave can then be observed via `leave$`. */
leave: () => void;
// screen sharing
/**
@@ -442,9 +442,6 @@ export interface CallViewModel {
/** Connected once, and currently not: to the homeserver, the session or the media transport. */
reconnecting$: Behavior<boolean>;
/** Connected to the homeserver, the session and the media transport, all three. */
connected$: Behavior<boolean>;
}
/**
@@ -627,8 +624,12 @@ export function createCallViewModel$(
localUser: { userId, deviceId },
});
const reconnecting$ = scope.behavior(
rtcParticipation.state$.pipe(map((state) => state.kind === "reconnecting")),
);
const handsRaised$ = scope.behavior(
handsRaisedSubject$.pipe(pauseWhen(rtcParticipation.reconnecting$)),
handsRaisedSubject$.pipe(pauseWhen(reconnecting$)),
);
const reactions$ = scope.behavior(
@@ -641,7 +642,7 @@ export function createCallViewModel$(
]),
),
),
pauseWhen(rtcParticipation.reconnecting$),
pauseWhen(reconnecting$),
),
);
@@ -677,7 +678,7 @@ export function createCallViewModel$(
member.transport$.pipe(map((transport) => transport?.id)),
),
mediaDevices,
pretendToBeDisconnected$: rtcParticipation.reconnecting$,
pretendToBeDisconnected$: reconnecting$,
displayName$: member.displayName$,
mxcAvatarUrl$: member.avatarUrl$,
handRaised$: scope.behavior(
@@ -1509,25 +1510,21 @@ export function createCallViewModel$(
};
let reconnectStart: { time: number; reason: DisconnectReason } | null = null;
rtcParticipation.disconnectReason$
.pipe(distinctUntilChanged(), pairwise(), scope.bind())
.subscribe(([prev, reason]) => {
if (reason !== null) {
// Only the loss of a connection that existed counts as a reconnect,
// not the startup phase
if (prev === null) reconnectStart ??= { time: Date.now(), reason };
} else if (reconnectStart !== null) {
const reason =
reconnectStart.reason === "media" ? "livekit" : reconnectStart.reason;
PosthogAnalytics.instance.eventCallReconnecting.track(
matrixRoom.roomId,
reason,
(Date.now() - reconnectStart.time) / 1000,
);
PosthogAnalytics.instance.eventCallEnded.cacheReconnecting(reason);
reconnectStart = null;
}
});
rtcParticipation.state$.pipe(scope.bind()).subscribe((state) => {
if (state.kind === "reconnecting") {
reconnectStart ??= { time: Date.now(), reason: state.reason };
} else if (state.kind === "connected" && reconnectStart !== null) {
const reason =
reconnectStart.reason === "media" ? "livekit" : reconnectStart.reason;
PosthogAnalytics.instance.eventCallReconnecting.track(
matrixRoom.roomId,
reason,
(Date.now() - reconnectStart.time) / 1000,
);
PosthogAnalytics.instance.eventCallEnded.cacheReconnecting(reason);
reconnectStart = null;
}
});
return {
autoLeave$: autoLeave$,
@@ -1545,8 +1542,10 @@ export function createCallViewModel$(
unhoverScreen: (): void => screenUnhover$.next(),
fatalError$: scope.behavior(
rtcParticipation.fatalError$.pipe(
map((error) => error && fromMatrixRTCError(error)),
rtcParticipation.state$.pipe(
map((state) =>
state.kind === "failed" ? fromMatrixRTCError(state.error) : null,
),
),
),
participantCount$: participantCount$,
@@ -1582,8 +1581,7 @@ export function createCallViewModel$(
overflowing$,
earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: rtcParticipation.reconnecting$,
connected$: rtcParticipation.connected$,
reconnecting$,
screenShareError$,
dismissScreenShareError: (): void => screenShareError$.next(null),
};
@@ -11,13 +11,14 @@ import {
constant,
E2eeType,
type LocalRTCMember,
type ParticipationState,
type RTCParticipation,
type MatrixRTCMode,
type ObservableScope,
type RemoteRTCMember,
} from "@element-hq/matrixrtc-sdk";
import { SyncState } from "matrix-js-sdk/lib/sync";
import { BehaviorSubject, combineLatest, map, of, switchMap } from "rxjs";
import { BehaviorSubject, map, of, switchMap } from "rxjs";
import { ClientEvent, type RoomMember, type MatrixClient } from "matrix-js-sdk";
import EventEmitter from "events";
@@ -244,11 +245,13 @@ export function withCallViewModel(mode: MatrixRTCMode) {
const rtcParticipation = mockRTCParticipation(scope, {
localMember$,
remoteMembers$,
connected$,
reconnecting$: scope.behavior(
combineLatest([connected$, of(initialSyncState)]).pipe(
switchMap(() => connected$),
map((connected) => !connected),
state$: scope.behavior(
connected$.pipe(
map((connected): ParticipationState =>
connected
? { kind: "connected" }
: { kind: "reconnecting", reason: "media" },
),
),
),
});
@@ -279,6 +282,11 @@ export function withCallViewModel(mode: MatrixRTCMode) {
);
void mode;
continuation(vm, rtcSession, { raisedHands$, rtcParticipation }, setSyncState);
continuation(
vm,
rtcSession,
{ raisedHands$, rtcParticipation },
setSyncState,
);
};
}
+4 -17
View File
@@ -63,7 +63,6 @@ import {
type AudioMediaTrack,
type Behavior,
constant,
type DisconnectReason,
type EncryptionError,
type LocalAudioMediaTrack,
type LocalRTCMember,
@@ -71,7 +70,7 @@ import {
type RTCMember,
type RTCParticipation,
type RTCSlot,
type MatrixRTCError,
type ParticipationState,
type PublishRequest,
type RemoteRTCMember,
type TransportMetadata,
@@ -494,10 +493,7 @@ export function mockRTCMember(
export interface MockParticipationInputs {
localMember$?: Behavior<LocalRTCMember | null>;
remoteMembers$?: Behavior<RemoteRTCMember[]>;
connected$?: Behavior<boolean>;
reconnecting$?: Behavior<boolean>;
disconnectReason$?: Behavior<DisconnectReason | null>;
fatalError$?: Behavior<MatrixRTCError | null>;
state$?: Behavior<ParticipationState>;
keyRotationSuppressed$?: Behavior<boolean>;
connectedTransports$?: Behavior<TransportMetadata[]>;
}
@@ -511,10 +507,7 @@ export function mockRTCParticipation(
{
localMember$ = constant(null),
remoteMembers$ = constant([]),
connected$ = constant(true),
reconnecting$ = constant(false),
disconnectReason$ = constant(null),
fatalError$ = constant(null),
state$ = constant<ParticipationState>({ kind: "connected" }),
keyRotationSuppressed$ = constant(false),
connectedTransports$ = constant([]),
}: MockParticipationInputs = {},
@@ -537,13 +530,7 @@ export function mockRTCParticipation(
const participation: RTCParticipation = {
slot,
leave: vi.fn(),
status$: scope.behavior(
connected$.pipe(map((c) => (c ? "connected" : "connecting"))),
),
connected$,
reconnecting$,
disconnectReason$,
fatalError$,
state$,
localMember$,
remoteMembers$,
publish: vi.fn(async (request: PublishRequest) =>