use sdk client -> participation/slot seperation

This commit is contained in:
Timo K.
2026-10-14 10:23:29 +02:00
committed by Timo
parent 7c5d1324e7
commit a5a0efe92b
9 changed files with 141 additions and 116 deletions
+6 -6
View File
@@ -26,7 +26,7 @@ import {
mockMuteStates, mockMuteStates,
mockRtcMembership, mockRtcMembership,
type MockRTCSession, type MockRTCSession,
mockMatrixRTCClient, mockRTCSlot,
mockRTCMember, mockRTCMember,
} from "../utils/test"; } from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
@@ -69,11 +69,11 @@ vi.mock("../tile/GridTile");
vi.mock("../tile/SpotlightTile"); vi.mock("../tile/SpotlightTile");
vi.mock("../e2ee/sharedKeyManagement"); vi.mock("../e2ee/sharedKeyManagement");
vi.mock("../tracks/MemberAudioRenderer"); vi.mock("../tracks/MemberAudioRenderer");
// ActiveCall builds the client itself; the view is what these tests are about // ActiveCall builds the slot itself; the view is what these tests are about
const createMatrixRTCClientMock = vi.hoisted(() => vi.fn()); const createRTCSlotMock = vi.hoisted(() => vi.fn());
vi.mock("@element-hq/matrixrtc-sdk", async (importOriginal) => ({ vi.mock("@element-hq/matrixrtc-sdk", async (importOriginal) => ({
...(await importOriginal<typeof MatrixRTCSdk>()), ...(await importOriginal<typeof MatrixRTCSdk>()),
createMatrixRTCClient: createMatrixRTCClientMock, createRTCSlot: createRTCSlotMock,
})); }));
vi.mock("react-use-measure", () => ({ vi.mock("react-use-measure", () => ({
default: (): [() => void, object] => [(): void => {}, {}], default: (): [() => void, object] => [(): void => {}, {}],
@@ -97,8 +97,8 @@ let useRoomEncryptionSystemMock: MockedFunction<typeof useRoomEncryptionSystem>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
createMatrixRTCClientMock.mockImplementation((scope: ObservableScope) => createRTCSlotMock.mockImplementation((scope: ObservableScope) =>
mockMatrixRTCClient(scope, { mockRTCSlot(scope, {
localMember$: constant( localMember$: constant(
mockRTCMember(true, { mockRTCMember(true, {
membership: localRtcMember, membership: localRtcMember,
+23 -20
View File
@@ -69,8 +69,9 @@ import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { ReactionsOverlay } from "./ReactionsOverlay"; import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import { import {
createMatrixRTCClient, createRTCSlot,
type MatrixRTCClientOptions, type RTCParticipationOptions,
type RTCSlotOptions,
constant, constant,
} from "@element-hq/matrixrtc-sdk"; } from "@element-hq/matrixrtc-sdk";
import { map } from "rxjs"; import { map } from "rxjs";
@@ -161,22 +162,20 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
urlParams; urlParams;
const capture = captureSettings(); const capture = captureSettings();
const rtcClient = createMatrixRTCClient( const slot = createRTCSlot(
scope, scope,
props.client, props.client,
props.matrixRoom, props.matrixRoom,
{ rtcSlotOptions(props.e2eeSystem),
...matrixRTCClientOptions(urlParams, props.e2eeSystem),
publish: initialPublishRequests(
props.muteStates,
mediaDevices,
capture,
),
},
); );
const rtcParticipation = slot.join({
...rtcParticipationOptions(urlParams),
publish: initialPublishRequests(props.muteStates, mediaDevices, capture),
});
const vm = createCallViewModel$( const vm = createCallViewModel$(
scope, scope,
rtcClient, rtcParticipation,
props.matrixRoom, props.matrixRoom,
mediaDevices, mediaDevices,
props.muteStates, props.muteStates,
@@ -199,8 +198,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
reactionsReader.raisedHands$, reactionsReader.raisedHands$,
reactionsReader.reactions$, reactionsReader.reactions$,
); );
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
vm.join();
setVm(vm); setVm(vm);
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft); vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
@@ -793,20 +790,26 @@ export function captureSettings(): CaptureSettings {
* switches and the devices, so the caller adds that. Shared with the legacy * switches and the devices, so the caller adds that. Shared with the legacy
* SDK bundle so that a widget built from it behaves like the app. * SDK bundle so that a widget built from it behaves like the app.
*/ */
export function matrixRTCClientOptions( export function rtcSlotOptions(
urlParams: ReturnType<typeof useUrlParams>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
): Omit<MatrixRTCClientOptions, "publish"> { ): RTCSlotOptions {
const config = Config.get();
const session = config.matrix_rtc_session;
return { return {
encryptionSystem, encryptionSystem,
// matrix_rtc_mode in config.json overrides the user's Developer Settings // matrix_rtc_mode in config.json overrides the user's Developer Settings
// choice. We merely sample the current mode here, so the user would need // choice. We merely sample the current mode here, so the user would need
// to manually rejoin to switch to a different one. // to manually rejoin to switch to a different one.
matrixRTCMode: matrixRTCMode:
(config.matrix_rtc_mode as MatrixRTCMode | undefined) ?? (Config.get().matrix_rtc_mode as MatrixRTCMode | undefined) ??
matrixRTCModeSetting.value$.value, matrixRTCModeSetting.value$.value,
};
}
export function rtcParticipationOptions(
urlParams: ReturnType<typeof useUrlParams>,
): Omit<RTCParticipationOptions, "publish"> {
const config = Config.get();
const session = config.matrix_rtc_session;
return {
sendNotificationType: urlParams.sendNotificationType, sendNotificationType: urlParams.sendNotificationType,
applicationData: urlParams.callIntent applicationData: urlParams.callIntent
? { "m.call.intent": urlParams.callIntent } ? { "m.call.intent": urlParams.callIntent }
@@ -261,16 +261,18 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
test("the selected audio output is handed to the client as it changes", () => { test("the selected audio output is handed to the client as it changes", () => {
const { mediaDevices, selected$ } = withAudioOutput(); const { mediaDevices, selected$ } = withAudioOutput();
withCallViewModel({ mediaDevices }, (_vm, _session, { rtcClient }) => { withCallViewModel({ mediaDevices }, (_vm, _session, { rtcParticipation: participation }) => {
// Nothing selected yet, so the browser's choice stands // Nothing selected yet, so the browser's choice stands
expect(rtcClient.setAudioOutputDeviceId).not.toHaveBeenCalled(); expect(participation.setAudioOutputDeviceId).not.toHaveBeenCalled();
selected$.next({ id: "speaker", virtualEarpiece: false }); selected$.next({ id: "speaker", virtualEarpiece: false });
expect(rtcClient.setAudioOutputDeviceId).toHaveBeenCalledWith("speaker"); expect(participation.setAudioOutputDeviceId).toHaveBeenCalledWith(
"speaker",
);
selected$.next({ id: "headphones", virtualEarpiece: false }); selected$.next({ id: "headphones", virtualEarpiece: false });
expect(rtcClient.setAudioOutputDeviceId).toHaveBeenLastCalledWith( expect(participation.setAudioOutputDeviceId).toHaveBeenLastCalledWith(
"headphones", "headphones",
); );
expect(rtcClient.setAudioOutputDeviceId).toHaveBeenCalledTimes(2); expect(participation.setAudioOutputDeviceId).toHaveBeenCalledTimes(2);
}); });
}); });
@@ -278,9 +280,9 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
const { mediaDevices, selected$ } = withAudioOutput(); const { mediaDevices, selected$ } = withAudioOutput();
withCallViewModel( withCallViewModel(
{ mediaDevices }, { mediaDevices },
(_vm, _session, { rtcClient }) => { (_vm, _session, { rtcParticipation: participation }) => {
selected$.next({ id: "earpiece", virtualEarpiece: true }); selected$.next({ id: "earpiece", virtualEarpiece: true });
expect(rtcClient.setAudioOutputDeviceId).not.toHaveBeenCalled(); expect(participation.setAudioOutputDeviceId).not.toHaveBeenCalled();
}, },
{ controlledAudioDevices: true }, { controlledAudioDevices: true },
); );
+37 -41
View File
@@ -17,7 +17,7 @@ import {
type LocalMediaTrack, type LocalMediaTrack,
type LocalRTCMember, type LocalRTCMember,
type LocalVideoMediaTrack, type LocalVideoMediaTrack,
type MatrixRTCClient, type RTCParticipation,
type ObservableScope, type ObservableScope,
pauseWhen, pauseWhen,
type PublishRequest, type PublishRequest,
@@ -158,7 +158,7 @@ export function publishRequest(
}; };
} }
/** What the client publishes at the join: the sources whose mute switch is on. */ /** What the participation publishes at the join: the sources whose mute switch is on. */
export function initialPublishRequests( export function initialPublishRequests(
muteStates: MuteStates, muteStates: MuteStates,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
@@ -295,12 +295,7 @@ export interface CallViewModel {
/** Call to initiate hangup. Use in conbination with reconnection state track the async hangup process. */ /** Call to initiate hangup. Use in conbination with reconnection state track the async hangup process. */
hangup: () => void; hangup: () => void;
// joining /** Leaves the participation. The async leave can then be observed via connected$. */
join: () => void;
/**
* calls requestDisconnect. The async leave state can than be observed via connected$
*/
leave: () => void; leave: () => void;
// screen sharing // screen sharing
/** /**
@@ -457,12 +452,12 @@ export interface CallViewModel {
* UI (may eventually be expanded to cover the lobby and feedback screens in the * UI (may eventually be expanded to cover the lobby and feedback screens in the
* future). * future).
* *
* The session itself — memberships, transports, media — is the client's; this * The slot and the participation — memberships, transports, media — are the
* adds everything that makes it a call. * SDK's; this adds everything that makes it a call.
*/ */
export function createCallViewModel$( export function createCallViewModel$(
scope: ObservableScope, scope: ObservableScope,
rtcClient: MatrixRTCClient, rtcParticipation: RTCParticipation,
// A call is permanently tied to a single Matrix room // A call is permanently tied to a single Matrix room
matrixRoom: MatrixRoom, matrixRoom: MatrixRoom,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
@@ -492,7 +487,7 @@ export function createCallViewModel$(
const localTracks$ = scope.behavior< const localTracks$ = scope.behavior<
(LocalAudioMediaTrack | LocalVideoMediaTrack)[] (LocalAudioMediaTrack | LocalVideoMediaTrack)[]
>( >(
rtcClient.localMember$.pipe( rtcParticipation.localMember$.pipe(
switchMap((member) => member?.tracks$ ?? of(null)), switchMap((member) => member?.tracks$ ?? of(null)),
map((tracks) => tracks ?? []), map((tracks) => tracks ?? []),
), ),
@@ -534,10 +529,10 @@ export function createCallViewModel$(
return desired; return desired;
} }
if (!enabled) return false; if (!enabled) return false;
const member = rtcClient.localMember$.value;
if (member === null) return false;
try { try {
await member.publish(publishRequest(source, mediaDevices, capture)); await rtcParticipation.publish(
publishRequest(source, mediaDevices, capture),
);
return true; return true;
} catch (e) { } catch (e) {
logger.error(`Failed to publish the ${source}`, e); logger.error(`Failed to publish the ${source}`, e);
@@ -587,7 +582,7 @@ export function createCallViewModel$(
.pipe(scope.bind()) .pipe(scope.bind())
.subscribe((selected) => { .subscribe((selected) => {
if (selected === undefined) return; if (selected === undefined) return;
rtcClient.setAudioOutputDeviceId(selected.id).catch((e) => { rtcParticipation.setAudioOutputDeviceId(selected.id).catch((e) => {
logger.error("Failed to switch the audio output device", e); logger.error("Failed to switch the audio output device", e);
}); });
}); });
@@ -604,7 +599,7 @@ export function createCallViewModel$(
const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>( const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>(
combineLatest( combineLatest(
[rtcClient.localMember$, rtcClient.remoteMembers$], [rtcParticipation.localMember$, rtcParticipation.remoteMembers$],
(local, remote) => (local === null ? remote : [local, ...remote]), (local, remote) => (local === null ? remote : [local, ...remote]),
), ),
); );
@@ -633,7 +628,7 @@ export function createCallViewModel$(
}); });
const handsRaised$ = scope.behavior( const handsRaised$ = scope.behavior(
handsRaisedSubject$.pipe(pauseWhen(rtcClient.reconnecting$)), handsRaisedSubject$.pipe(pauseWhen(rtcParticipation.reconnecting$)),
); );
const reactions$ = scope.behavior( const reactions$ = scope.behavior(
@@ -646,7 +641,7 @@ export function createCallViewModel$(
]), ]),
), ),
), ),
pauseWhen(rtcClient.reconnecting$), pauseWhen(rtcParticipation.reconnecting$),
), ),
); );
@@ -663,7 +658,10 @@ export function createCallViewModel$(
for (const member of members) { for (const member of members) {
const mediaId = `${member.userId}:${member.deviceId}`; const mediaId = `${member.userId}:${member.deviceId}`;
for (let dup = 0; dup < 1 + duplicateTiles; dup++) { for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield { keys: [dup, mediaId, member.id], data: member }; yield {
keys: [dup, mediaId, member.rtcBackendIdentity],
data: member,
};
} }
} }
}, },
@@ -672,14 +670,14 @@ export function createCallViewModel$(
return createWrappedUserMedia(scope, { return createWrappedUserMedia(scope, {
id: `${mediaId}:${dup}`, id: `${mediaId}:${dup}`,
userId: member.userId, userId: member.userId,
rtcBackendIdentity: member.id, rtcBackendIdentity: member.rtcBackendIdentity,
member, member,
encryptionSystem: options.encryptionSystem, encryptionSystem: options.encryptionSystem,
focusUrl$: scope.behavior( focusUrl$: scope.behavior(
member.transport$.pipe(map((transport) => transport?.id)), member.transport$.pipe(map((transport) => transport?.id)),
), ),
mediaDevices, mediaDevices,
pretendToBeDisconnected$: rtcClient.reconnecting$, pretendToBeDisconnected$: rtcParticipation.reconnecting$,
displayName$: member.displayName$, displayName$: member.displayName$,
mxcAvatarUrl$: member.avatarUrl$, mxcAvatarUrl$: member.avatarUrl$,
handRaised$: scope.behavior( handRaised$: scope.behavior(
@@ -761,9 +759,12 @@ export function createCallViewModel$(
* - There can be multiple participants for one Matrix user if they join from * - There can be multiple participants for one Matrix user if they join from
* multiple devices. * multiple devices.
*/ */
const participantCount$ = rtcClient.memberCount$; // One per membership, so one user may count several times
const participantCount$ = scope.behavior(
rtcParticipation.slot.members$.pipe(map((members) => members.length)),
);
const keyRotationSuppressed$ = rtcClient.keyRotationSuppressed$; const keyRotationSuppressed$ = rtcParticipation.keyRotationSuppressed$;
const leaveSoundEffect$ = userMedia$.pipe( const leaveSoundEffect$ = userMedia$.pipe(
pairwise(), pairwise(),
@@ -1473,15 +1474,13 @@ export function createCallViewModel$(
(hideScreensharing || !("getDisplayMedia" in (navigator.mediaDevices ?? {})) (hideScreensharing || !("getDisplayMedia" in (navigator.mediaDevices ?? {}))
? null ? null
: (): void => { : (): void => {
const member = rtcClient.localMember$.value;
if (member === null) return;
const track = screenShare$.value; const track = screenShare$.value;
(track === undefined (track === undefined
? member.publish({ ? rtcParticipation.publish({
source: "screenShare", source: "screenShare",
capture: capture.screenShare, capture: capture.screenShare,
}) })
: member.unpublish(track.id) : rtcParticipation.unpublish("screenShare")
).catch((e: unknown) => { ).catch((e: unknown) => {
logger.error( logger.error(
`Screen share ${track === undefined ? "start" : "stop"} failed`, `Screen share ${track === undefined ? "start" : "stop"} failed`,
@@ -1495,24 +1494,22 @@ export function createCallViewModel$(
}); });
}); });
// Tell the host and the analytics about the user's joins and leaves // Tell the host and the analytics about the user's joins and leaves. The
const join = (): void => { // participation is joined by the time the view model is built on it
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(matrixRoom.roomId); PosthogAnalytics.instance.eventCallStarted.track(matrixRoom.roomId);
rtcClient.join();
hostBridge.notifyJoined().catch((e) => { hostBridge.notifyJoined().catch((e) => {
logger.error("Failed to notify the host that we joined", e); logger.error("Failed to notify the host that we joined", e);
}); });
};
const leave = (): void => { const leave = (): void => {
rtcClient.leave(); rtcParticipation.leave();
hostBridge.notifyHungUp().catch((e) => { hostBridge.notifyHungUp().catch((e) => {
logger.error("Failed to notify the host that we hung up", e); logger.error("Failed to notify the host that we hung up", e);
}); });
}; };
let reconnectStart: { time: number; reason: DisconnectReason } | null = null; let reconnectStart: { time: number; reason: DisconnectReason } | null = null;
rtcClient.disconnectReason$ rtcParticipation.disconnectReason$
.pipe(distinctUntilChanged(), pairwise(), scope.bind()) .pipe(distinctUntilChanged(), pairwise(), scope.bind())
.subscribe(([prev, reason]) => { .subscribe(([prev, reason]) => {
if (reason !== null) { if (reason !== null) {
@@ -1538,7 +1535,6 @@ export function createCallViewModel$(
ringingStatusLocation: header === HeaderStyle.AppBar ? "app_bar" : "tile", ringingStatusLocation: header === HeaderStyle.AppBar ? "app_bar" : "tile",
leave$: leave$, leave$: leave$,
hangup: (): void => userHangup$.next(), hangup: (): void => userHangup$.next(),
join,
leave, leave,
toggleScreenSharing: toggleScreenSharing, toggleScreenSharing: toggleScreenSharing,
sharingScreen$: sharingScreen$, sharingScreen$: sharingScreen$,
@@ -1549,15 +1545,15 @@ export function createCallViewModel$(
unhoverScreen: (): void => screenUnhover$.next(), unhoverScreen: (): void => screenUnhover$.next(),
fatalError$: scope.behavior( fatalError$: scope.behavior(
rtcClient.fatalError$.pipe( rtcParticipation.fatalError$.pipe(
map((error) => error && fromMatrixRTCError(error)), map((error) => error && fromMatrixRTCError(error)),
), ),
), ),
participantCount$: participantCount$, participantCount$: participantCount$,
keyRotationSuppressed$: keyRotationSuppressed$, keyRotationSuppressed$: keyRotationSuppressed$,
localMember$: rtcClient.localMember$, localMember$: rtcParticipation.localMember$,
remoteMembers$: rtcClient.remoteMembers$, remoteMembers$: rtcParticipation.remoteMembers$,
connectedTransports$: rtcClient.connectedTransports$, connectedTransports$: rtcParticipation.connectedTransports$,
handsRaised$: handsRaised$, handsRaised$: handsRaised$,
reactions$: reactions$, reactions$: reactions$,
joinSoundEffect$: joinSoundEffect$, joinSoundEffect$: joinSoundEffect$,
@@ -1586,8 +1582,8 @@ export function createCallViewModel$(
overflowing$, overflowing$,
earpieceMode$: earpieceMode$, earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$, audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: rtcClient.reconnecting$, reconnecting$: rtcParticipation.reconnecting$,
connected$: rtcClient.connected$, connected$: rtcParticipation.connected$,
screenShareError$, screenShareError$,
dismissScreenShareError: (): void => screenShareError$.next(null), dismissScreenShareError: (): void => screenShareError$.next(null),
}; };
@@ -11,7 +11,7 @@ import {
constant, constant,
E2eeType, E2eeType,
type LocalRTCMember, type LocalRTCMember,
type MatrixRTCClient, type RTCParticipation,
type MatrixRTCMode, type MatrixRTCMode,
type ObservableScope, type ObservableScope,
type RemoteRTCMember, type RemoteRTCMember,
@@ -33,7 +33,7 @@ import {
mockConfig, mockConfig,
mockMatrixRoom, mockMatrixRoom,
mockMatrixRoomMember, mockMatrixRoomMember,
mockMatrixRTCClient, mockRTCParticipation,
mockAudioTrack, mockAudioTrack,
mockMediaDevices, mockMediaDevices,
mockVideoTrack, mockVideoTrack,
@@ -112,8 +112,8 @@ export function withCallViewModel(mode: MatrixRTCMode) {
rtcSession: MockRTCSession, rtcSession: MockRTCSession,
subjects: { subjects: {
raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>>; raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>>;
/** The client the view model was built on, with spies for its calls. */ /** The participation the view model was built on, with spies for its calls. */
rtcClient: MatrixRTCClient; rtcParticipation: RTCParticipation;
}, },
setSyncState: (value: SyncState) => void, setSyncState: (value: SyncState) => void,
) => void, ) => void,
@@ -241,7 +241,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
), ),
), ),
); );
const rtcClient = mockMatrixRTCClient(scope, { const rtcParticipation = mockRTCParticipation(scope, {
localMember$, localMember$,
remoteMembers$, remoteMembers$,
connected$, connected$,
@@ -260,7 +260,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
const vm = createCallViewModel$( const vm = createCallViewModel$(
scope, scope,
rtcClient, rtcParticipation,
room, room,
mediaDevices, mediaDevices,
muteStates, muteStates,
@@ -279,6 +279,6 @@ export function withCallViewModel(mode: MatrixRTCMode) {
); );
void mode; void mode;
continuation(vm, rtcSession, { raisedHands$, rtcClient }, setSyncState); continuation(vm, rtcSession, { raisedHands$, rtcParticipation }, setSyncState);
}; };
} }
+2 -4
View File
@@ -13,6 +13,7 @@ import {
type EncryptionSystem, type EncryptionSystem,
type MediaTrack, type MediaTrack,
type ObservableScope, type ObservableScope,
type MemberMedia,
type RTCMember, type RTCMember,
trackBySource$, trackBySource$,
type VideoMediaTrack, type VideoMediaTrack,
@@ -64,10 +65,7 @@ export interface BaseMemberMediaViewModel extends BaseMediaViewModel {
} }
/** The member whose media this is, as far as the media view models read it. */ /** The member whose media this is, as far as the media view models read it. */
export type MediaMember = Pick< export type MediaMember = Pick<RTCMember, "local"> & MemberMedia;
RTCMember,
"local" | "tracks$" | "encryptionError$"
>;
export interface MemberMediaInputs extends BaseMediaViewModel { export interface MemberMediaInputs extends BaseMediaViewModel {
member: MediaMember; member: MediaMember;
+1 -1
View File
@@ -100,7 +100,7 @@ export const MemberAudioRenderer: FC<MemberAudioRendererProps> = ({
<div style={{ display: "none" }}> <div style={{ display: "none" }}>
{members.map((member) => ( {members.map((member) => (
<MemberAudio <MemberAudio
key={member.id} key={member.rtcBackendIdentity}
tracks$={member.tracks$} tracks$={member.tracks$}
muted={muted} muted={muted}
plugins={plugins} plugins={plugins}
+3 -3
View File
@@ -26,7 +26,7 @@ import {
import { import {
mockConfig, mockConfig,
mockMatrixRoom, mockMatrixRoom,
mockMatrixRTCClient, mockRTCParticipation,
mockMediaDevices, mockMediaDevices,
mockMuteStates, mockMuteStates,
MockRTCSession, MockRTCSession,
@@ -160,7 +160,7 @@ export function getBasicCallViewModelEnvironment(
const muteStates = mockMuteStates(); const muteStates = mockMuteStates();
const mediaDevices = mediaDevicesOverride ?? mockMediaDevices({}); const mediaDevices = mediaDevicesOverride ?? mockMediaDevices({});
// Every membership is a member whose media has arrived // Every membership is a member whose media has arrived
const rtcClient = mockMatrixRTCClient(scope, { const rtcParticipation = mockRTCParticipation(scope, {
localMember$: scope.behavior( localMember$: scope.behavior(
rtcMemberships$.pipe( rtcMemberships$.pipe(
map((memberships) => map((memberships) =>
@@ -196,7 +196,7 @@ export function getBasicCallViewModelEnvironment(
}); });
const vm = createCallViewModel$( const vm = createCallViewModel$(
scope, scope,
rtcClient, rtcParticipation,
matrixRoom, matrixRoom,
mediaDevices, mediaDevices,
muteStates, muteStates,
+48 -22
View File
@@ -5,6 +5,7 @@ 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 { import {
BehaviorSubject,
combineLatest, combineLatest,
map, map,
NEVER, NEVER,
@@ -67,7 +68,9 @@ import {
type LocalAudioMediaTrack, type LocalAudioMediaTrack,
type LocalRTCMember, type LocalRTCMember,
type LocalVideoMediaTrack, type LocalVideoMediaTrack,
type MatrixRTCClient, type RTCMember,
type RTCParticipation,
type RTCSlot,
type MatrixRTCError, type MatrixRTCError,
type PublishRequest, type PublishRequest,
type RemoteRTCMember, type RemoteRTCMember,
@@ -467,13 +470,14 @@ export function mockRTCMember(
resolved$: constant(undefined), resolved$: constant(undefined),
}; };
const base = { const base = {
id: membership.rtcBackendIdentity, rtcBackendIdentity: membership.rtcBackendIdentity,
userId: membership.userId, userId: membership.userId,
deviceId: membership.deviceId, deviceId: membership.deviceId,
membership$: constant(membership), memberId: membership.memberId,
displayName$: constant(member.rawDisplayName ?? membership.userId), displayName$: constant(member.rawDisplayName ?? membership.userId),
avatarUrl$: constant(member.getMxcAvatarUrl()), avatarUrl$: constant(member.getMxcAvatarUrl()),
transport$: constant(transport), transport$: constant(transport),
applicationData$: constant(membership.applicationData),
encryptionError$, encryptionError$,
}; };
if (!local) return { ...base, local: false, tracks$ }; if (!local) return { ...base, local: false, tracks$ };
@@ -484,18 +488,10 @@ export function mockRTCMember(
tracks$: tracks$ as Behavior< tracks$: tracks$ as Behavior<
(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null (LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null
>, >,
publish: vi.fn(async (request: PublishRequest) =>
Promise.resolve(
request.source === "microphone"
? mockAudioTrack()
: mockVideoTrack({ source: request.source }),
),
),
unpublish: vi.fn(async () => Promise.resolve()),
}; };
} }
export interface MockClientInputs { export interface MockParticipationInputs {
localMember$?: Behavior<LocalRTCMember | null>; localMember$?: Behavior<LocalRTCMember | null>;
remoteMembers$?: Behavior<RemoteRTCMember[]>; remoteMembers$?: Behavior<RemoteRTCMember[]>;
connected$?: Behavior<boolean>; connected$?: Behavior<boolean>;
@@ -506,8 +502,11 @@ export interface MockClientInputs {
connectedTransports$?: Behavior<TransportMetadata[]>; connectedTransports$?: Behavior<TransportMetadata[]>;
} }
/** A client that does nothing but hold the state a test hands it. */ /**
export function mockMatrixRTCClient( * A participation that does nothing but hold the state a test hands it, in a
* slot whose members are the same ones without their media.
*/
export function mockRTCParticipation(
scope: ObservableScope, scope: ObservableScope,
{ {
localMember$ = constant(null), localMember$ = constant(null),
@@ -518,10 +517,25 @@ export function mockMatrixRTCClient(
fatalError$ = constant(null), fatalError$ = constant(null),
keyRotationSuppressed$ = constant(false), keyRotationSuppressed$ = constant(false),
connectedTransports$ = constant([]), connectedTransports$ = constant([]),
}: MockClientInputs = {}, }: MockParticipationInputs = {},
): MatrixRTCClient { ): RTCParticipation {
return { const members$ = scope.behavior<RTCMember[]>(
join: vi.fn(), combineLatest([localMember$, remoteMembers$], (local, remote) =>
local === null ? remote : [local, ...remote],
),
);
const participation$ = new BehaviorSubject<RTCParticipation | null>(null);
const slot: RTCSlot = {
roomId: "!room:example.org",
application: "m.call",
id: "ROOM",
status$: constant("open"),
members$,
participation$,
join: vi.fn(() => participation$.value!),
};
const participation: RTCParticipation = {
slot,
leave: vi.fn(), leave: vi.fn(),
status$: scope.behavior( status$: scope.behavior(
connected$.pipe(map((c) => (c ? "connected" : "connecting"))), connected$.pipe(map((c) => (c ? "connected" : "connecting"))),
@@ -532,18 +546,30 @@ export function mockMatrixRTCClient(
fatalError$, fatalError$,
localMember$, localMember$,
remoteMembers$, remoteMembers$,
memberCount$: scope.behavior( publish: vi.fn(async (request: PublishRequest) =>
combineLatest( Promise.resolve(
[localMember$, remoteMembers$], request.source === "microphone"
(local, remote) => remote.length + (local === null ? 0 : 1), ? mockAudioTrack()
: mockVideoTrack({ source: request.source }),
), ),
), ),
unpublish: vi.fn(async () => Promise.resolve()),
keyRotationSuppressed$, keyRotationSuppressed$,
connectedTransports$, connectedTransports$,
setAudioOutputDeviceId: vi.fn(async () => Promise.resolve()), setAudioOutputDeviceId: vi.fn(async () => Promise.resolve()),
sendData: vi.fn(async () => Promise.resolve()), sendData: vi.fn(async () => Promise.resolve()),
data$: NEVER, data$: NEVER,
}; };
participation$.next(participation);
return participation;
}
/** The slot of a `mockRTCParticipation`, whose `join()` returns that participation. */
export function mockRTCSlot(
scope: ObservableScope,
inputs: MockParticipationInputs = {},
): RTCSlot {
return mockRTCParticipation(scope, inputs).slot;
} }
export function mockConfig( export function mockConfig(