Show encryption key status from LiveKit (#2700)

* Refactor to make encryption system available in view models

* WIP show encryption errors from LiveKit

* Missing CSS

* Show encryption status based on LK and RTC

* Lint

* Lint

* Fix tests

* Update wording

* Refactor

* Lint
This commit is contained in:
Hugh Nimmo-Smith 2024-11-06 11:12:46 +00:00 committed by GitHub
parent bc0ab92394
commit c45f724279
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 287 additions and 11 deletions

View File

@ -64,6 +64,12 @@
"crypto_version": "Crypto version: {{version}}", "crypto_version": "Crypto version: {{version}}",
"device_id": "Device ID: {{id}}", "device_id": "Device ID: {{id}}",
"disconnected_banner": "Connectivity to the server has been lost.", "disconnected_banner": "Connectivity to the server has been lost.",
"e2ee_encryption_status": {
"connecting": "Connecting...",
"key_invalid": "The end-to-end encrypted media key for this person is invalid",
"key_missing": "You haven't received the current end-to-end encrypted media key for this person yet",
"password_invalid": "This person is using a different password so you won't be able to communicate with them"
},
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>", "full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>", "full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
"group_call_loader": { "group_call_loader": {

View File

@ -194,6 +194,15 @@ function withCallViewModel(
), ),
); );
const roomEventSelectorSpy = vi
.spyOn(ComponentsCore, "roomEventSelector")
.mockImplementation((room, eventType) => of());
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants },
);
const vm = new CallViewModel( const vm = new CallViewModel(
mockMatrixRoom({ mockMatrixRoom({
client: { client: {
@ -201,7 +210,7 @@ function withCallViewModel(
} as Partial<MatrixClient> as MatrixClient, } as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => members.get(userId) ?? null, getMember: (userId) => members.get(userId) ?? null,
}), }),
mockLivekitRoom({ localParticipant }), liveKitRoom,
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
@ -213,6 +222,7 @@ function withCallViewModel(
participantsSpy!.mockRestore(); participantsSpy!.mockRestore();
mediaSpy!.mockRestore(); mediaSpy!.mockRestore();
eventsSpy!.mockRestore(); eventsSpy!.mockRestore();
roomEventSelectorSpy!.mockRestore();
}); });
continuation(vm); continuation(vm);

View File

@ -226,6 +226,7 @@ class UserMedia {
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) { ) {
this.vm = participant.isLocal this.vm = participant.isLocal
? new LocalUserMediaViewModel( ? new LocalUserMediaViewModel(
@ -233,12 +234,14 @@ class UserMedia {
member, member,
participant as LocalParticipant, participant as LocalParticipant,
encryptionSystem, encryptionSystem,
livekitRoom,
) )
: new RemoteUserMediaViewModel( : new RemoteUserMediaViewModel(
id, id,
member, member,
participant as RemoteParticipant, participant as RemoteParticipant,
encryptionSystem, encryptionSystem,
livekitRoom,
); );
this.speaker = this.vm.speaking.pipe( this.speaker = this.vm.speaking.pipe(
@ -282,12 +285,14 @@ class ScreenShare {
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
liveKitRoom: LivekitRoom,
) { ) {
this.vm = new ScreenShareViewModel( this.vm = new ScreenShareViewModel(
id, id,
member, member,
participant, participant,
encryptionSystem, encryptionSystem,
liveKitRoom,
); );
} }
@ -428,6 +433,7 @@ export class CallViewModel extends ViewModel {
member, member,
p, p,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom,
), ),
]; ];
@ -441,6 +447,7 @@ export class CallViewModel extends ViewModel {
member, member,
p, p,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom,
), ),
]; ];
} }

View File

@ -11,6 +11,7 @@ import {
VideoSource, VideoSource,
observeParticipantEvents, observeParticipantEvents,
observeParticipantMedia, observeParticipantMedia,
roomEventSelector,
} from "@livekit/components-core"; } from "@livekit/components-core";
import { import {
LocalParticipant, LocalParticipant,
@ -21,6 +22,9 @@ import {
Track, Track,
TrackEvent, TrackEvent,
facingModeFromLocalTrack, facingModeFromLocalTrack,
Room as LivekitRoom,
RoomEvent as LivekitRoomEvent,
RemoteTrack,
} from "livekit-client"; } from "livekit-client";
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix"; import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
import { import {
@ -28,13 +32,18 @@ import {
Observable, Observable,
Subject, Subject,
combineLatest, combineLatest,
distinctUntilChanged,
distinctUntilKeyChanged, distinctUntilKeyChanged,
filter,
fromEvent, fromEvent,
interval,
map, map,
merge, merge,
of, of,
shareReplay,
startWith, startWith,
switchMap, switchMap,
throttleTime,
} from "rxjs"; } from "rxjs";
import { useEffect } from "react"; import { useEffect } from "react";
@ -81,6 +90,115 @@ export function observeTrackReference(
); );
} }
function observeRemoteTrackReceivingOkay(
participant: Participant,
source: Track.Source,
): Observable<boolean | undefined> {
let lastStats: {
framesDecoded: number | undefined;
framesDropped: number | undefined;
framesReceived: number | undefined;
} = {
framesDecoded: undefined,
framesDropped: undefined,
framesReceived: undefined,
};
return combineLatest([
observeTrackReference(participant, source),
interval(1000).pipe(startWith(0)),
]).pipe(
switchMap(async ([trackReference]) => {
const track = trackReference.publication?.track;
if (!track || !(track instanceof RemoteTrack)) {
return undefined;
}
const report = await track.getRTCStatsReport();
if (!report) {
return undefined;
}
for (const v of report.values()) {
if (v.type === "inbound-rtp") {
const { framesDecoded, framesDropped, framesReceived } =
v as RTCInboundRtpStreamStats;
return {
framesDecoded,
framesDropped,
framesReceived,
};
}
}
return undefined;
}),
filter((newStats) => !!newStats),
map((newStats): boolean | undefined => {
const oldStats = lastStats;
lastStats = newStats;
if (
typeof newStats.framesReceived === "number" &&
typeof oldStats.framesReceived === "number" &&
typeof newStats.framesDecoded === "number" &&
typeof oldStats.framesDecoded === "number"
) {
const framesReceivedDelta =
newStats.framesReceived - oldStats.framesReceived;
const framesDecodedDelta =
newStats.framesDecoded - oldStats.framesDecoded;
// if we received >0 frames and managed to decode >0 frames then we treat that as success
if (framesReceivedDelta > 0) {
return framesDecodedDelta > 0;
}
}
// no change
return undefined;
}),
filter((x) => typeof x === "boolean"),
startWith(undefined),
);
}
function encryptionErrorObservable(
room: LivekitRoom,
participant: Participant,
encryptionSystem: EncryptionSystem,
criteria: string,
): Observable<boolean> {
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
map((e) => {
const [err] = e;
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
return (
// Ideally we would pull the participant identity from the field on the error.
// However, it gets lost in the serialization process between workers.
// So, instead we do a string match
(err?.message.includes(participant.identity) &&
err?.message.includes(criteria)) ??
false
);
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
return !!err?.message.includes(criteria);
}
return false;
}),
throttleTime(1000), // Throttle to avoid spamming the UI
startWith(false),
);
}
export enum EncryptionStatus {
Connecting,
Okay,
KeyMissing,
KeyInvalid,
PasswordInvalid,
}
abstract class BaseMediaViewModel extends ViewModel { abstract class BaseMediaViewModel extends ViewModel {
/** /**
* Whether the media belongs to the local user. * Whether the media belongs to the local user.
@ -95,6 +213,8 @@ abstract class BaseMediaViewModel extends ViewModel {
*/ */
public readonly unencryptedWarning: Observable<boolean>; public readonly unencryptedWarning: Observable<boolean>;
public readonly encryptionStatus: Observable<EncryptionStatus>;
public constructor( public constructor(
/** /**
* An opaque identifier for this media. * An opaque identifier for this media.
@ -110,6 +230,7 @@ abstract class BaseMediaViewModel extends ViewModel {
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
livekitRoom: LivekitRoom,
) { ) {
super(); super();
const audio = observeTrackReference(participant, audioSource).pipe( const audio = observeTrackReference(participant, audioSource).pipe(
@ -124,7 +245,64 @@ abstract class BaseMediaViewModel extends ViewModel {
encryptionSystem.kind !== E2eeType.NONE && encryptionSystem.kind !== E2eeType.NONE &&
(a.publication?.isEncrypted === false || (a.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false), v.publication?.isEncrypted === false),
).pipe(this.scope.state()); ).pipe(distinctUntilChanged(), shareReplay(1));
if (participant.isLocal || encryptionSystem.kind === E2eeType.NONE) {
this.encryptionStatus = of(EncryptionStatus.Okay).pipe(
this.scope.state(),
);
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
this.encryptionStatus = combineLatest([
encryptionErrorObservable(
livekitRoom,
participant,
encryptionSystem,
"MissingKey",
),
encryptionErrorObservable(
livekitRoom,
participant,
encryptionSystem,
"InvalidKey",
),
observeRemoteTrackReceivingOkay(participant, audioSource),
observeRemoteTrackReceivingOkay(participant, videoSource),
]).pipe(
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
if (keyMissing) return EncryptionStatus.KeyMissing;
if (keyInvalid) return EncryptionStatus.KeyInvalid;
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
return undefined; // no change
}),
filter((x) => !!x),
startWith(EncryptionStatus.Connecting),
this.scope.state(),
);
} else {
this.encryptionStatus = combineLatest([
encryptionErrorObservable(
livekitRoom,
participant,
encryptionSystem,
"InvalidKey",
),
observeRemoteTrackReceivingOkay(participant, audioSource),
observeRemoteTrackReceivingOkay(participant, videoSource),
]).pipe(
map(
([keyInvalid, audioOkay, videoOkay]):
| EncryptionStatus
| undefined => {
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
return undefined; // no change
},
),
filter((x) => !!x),
startWith(EncryptionStatus.Connecting),
this.scope.state(),
);
}
} }
} }
@ -171,6 +349,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) { ) {
super( super(
id, id,
@ -179,6 +358,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
encryptionSystem, encryptionSystem,
Track.Source.Microphone, Track.Source.Microphone,
Track.Source.Camera, Track.Source.Camera,
livekitRoom,
); );
const media = observeParticipantMedia(participant).pipe(this.scope.state()); const media = observeParticipantMedia(participant).pipe(this.scope.state());
@ -228,8 +408,9 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant, participant: LocalParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) { ) {
super(id, member, participant, encryptionSystem); super(id, member, participant, encryptionSystem, livekitRoom);
} }
} }
@ -288,8 +469,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
member: RoomMember | undefined, member: RoomMember | undefined,
participant: RemoteParticipant, participant: RemoteParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) { ) {
super(id, member, participant, encryptionSystem); super(id, member, participant, encryptionSystem, livekitRoom);
// Sync the local volume with LiveKit // Sync the local volume with LiveKit
this.localVolume this.localVolume
@ -321,6 +503,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) { ) {
super( super(
id, id,
@ -329,6 +512,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
encryptionSystem, encryptionSystem,
Track.Source.ScreenShareAudio, Track.Source.ScreenShareAudio,
Track.Source.ScreenShare, Track.Source.ScreenShare,
livekitRoom,
); );
} }
} }

View File

@ -81,6 +81,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
const { t } = useTranslation(); const { t } = useTranslation();
const video = useObservableEagerState(vm.video); const video = useObservableEagerState(vm.video);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus);
const audioEnabled = useObservableEagerState(vm.audioEnabled); const audioEnabled = useObservableEagerState(vm.audioEnabled);
const videoEnabled = useObservableEagerState(vm.videoEnabled); const videoEnabled = useObservableEagerState(vm.videoEnabled);
const speaking = useObservableEagerState(vm.speaking); const speaking = useObservableEagerState(vm.speaking);
@ -122,6 +123,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
video={video} video={video}
member={vm.member} member={vm.member}
unencryptedWarning={unencryptedWarning} unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus}
videoEnabled={videoEnabled} videoEnabled={videoEnabled}
videoFit={cropVideo ? "cover" : "contain"} videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, styles.tile, { className={classNames(className, styles.tile, {

View File

@ -85,7 +85,7 @@ unconditionally select the container so we can use cqmin units */
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
grid-template-areas: ". ." "nameTag button"; grid-template-areas: "status status" "nameTag button";
gap: var(--cpd-space-1x); gap: var(--cpd-space-1x);
place-items: start; place-items: start;
} }
@ -106,6 +106,25 @@ unconditionally select the container so we can use cqmin units */
max-inline-size: 100%; max-inline-size: 100%;
} }
.status {
grid-area: status;
justify-self: center;
align-self: start;
padding: var(--cpd-space-1x);
padding-block: var(--cpd-space-1x);
color: var(--cpd-color-text-primary);
background-color: var(--cpd-color-bg-canvas-default);
display: flex;
align-items: center;
border-radius: var(--cpd-radius-pill-effect);
user-select: none;
overflow: hidden;
box-shadow: var(--small-drop-shadow);
box-sizing: border-box;
max-inline-size: 100%;
text-align: center;
}
.nameTag > svg, .nameTag > svg,
.nameTag > span { .nameTag > span {
flex-shrink: 0; flex-shrink: 0;

View File

@ -17,6 +17,7 @@ import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./MediaView.module.css"; import styles from "./MediaView.module.css";
import { Avatar } from "../Avatar"; import { Avatar } from "../Avatar";
import { EncryptionStatus } from "../state/MediaViewModel";
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator"; import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
import { showHandRaisedTimer, useSetting } from "../settings/settings"; import { showHandRaisedTimer, useSetting } from "../settings/settings";
@ -31,6 +32,7 @@ interface Props extends ComponentProps<typeof animated.div> {
member: RoomMember | undefined; member: RoomMember | undefined;
videoEnabled: boolean; videoEnabled: boolean;
unencryptedWarning: boolean; unencryptedWarning: boolean;
encryptionStatus: EncryptionStatus;
nameTagLeadingIcon?: ReactNode; nameTagLeadingIcon?: ReactNode;
displayName: string; displayName: string;
primaryButton?: ReactNode; primaryButton?: ReactNode;
@ -54,6 +56,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
nameTagLeadingIcon, nameTagLeadingIcon,
displayName, displayName,
primaryButton, primaryButton,
encryptionStatus,
raisedHandTime, raisedHandTime,
raisedHandOnClick, raisedHandOnClick,
...props ...props
@ -69,7 +72,11 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
<animated.div <animated.div
className={classNames(styles.media, className, { className={classNames(styles.media, className, {
[styles.mirror]: mirror, [styles.mirror]: mirror,
[styles.videoMuted]: !videoEnabled, [styles.videoMuted]:
!videoEnabled ||
![EncryptionStatus.Connecting, EncryptionStatus.Okay].includes(
encryptionStatus,
),
})} })}
style={style} style={style}
ref={ref} ref={ref}
@ -95,6 +102,20 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
)} )}
</div> </div>
<div className={styles.fg}> <div className={styles.fg}>
{encryptionStatus !== EncryptionStatus.Okay && (
<div className={styles.status}>
<Text as="span" size="sm" weight="medium" className={styles.name}>
{encryptionStatus === EncryptionStatus.Connecting &&
t("e2ee_encryption_status.connecting")}
{encryptionStatus === EncryptionStatus.KeyMissing &&
t("e2ee_encryption_status.key_missing")}
{encryptionStatus === EncryptionStatus.KeyInvalid &&
t("e2ee_encryption_status.key_invalid")}
{encryptionStatus === EncryptionStatus.PasswordInvalid &&
t("e2ee_encryption_status.password_invalid")}
</Text>
</div>
)}
<RaisedHandIndicator <RaisedHandIndicator
raisedHandTime={raisedHandTime} raisedHandTime={raisedHandTime}
minature={avatarSize < 96} minature={avatarSize < 96}

View File

@ -31,6 +31,7 @@ import { RoomMember } from "matrix-js-sdk/src/matrix";
import { MediaView } from "./MediaView"; import { MediaView } from "./MediaView";
import styles from "./SpotlightTile.module.css"; import styles from "./SpotlightTile.module.css";
import { import {
EncryptionStatus,
LocalUserMediaViewModel, LocalUserMediaViewModel,
MediaViewModel, MediaViewModel,
ScreenShareViewModel, ScreenShareViewModel,
@ -51,6 +52,7 @@ interface SpotlightItemBaseProps {
video: TrackReferenceOrPlaceholder; video: TrackReferenceOrPlaceholder;
member: RoomMember | undefined; member: RoomMember | undefined;
unencryptedWarning: boolean; unencryptedWarning: boolean;
encryptionStatus: EncryptionStatus;
displayName: string; displayName: string;
"aria-hidden"?: boolean; "aria-hidden"?: boolean;
} }
@ -132,6 +134,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
const displayName = useDisplayName(vm); const displayName = useDisplayName(vm);
const video = useObservableEagerState(vm.video); const video = useObservableEagerState(vm.video);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus);
// Hook this item up to the intersection observer // Hook this item up to the intersection observer
useEffect(() => { useEffect(() => {
@ -158,6 +161,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
member: vm.member, member: vm.member,
unencryptedWarning, unencryptedWarning,
displayName, displayName,
encryptionStatus,
"aria-hidden": ariaHidden, "aria-hidden": ariaHidden,
}; };

View File

@ -4,7 +4,7 @@ Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { map } from "rxjs"; import { map, Observable, of } from "rxjs";
import { RunHelpers, TestScheduler } from "rxjs/testing"; import { RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, vi } from "vitest"; import { expect, vi } from "vitest";
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix"; import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
@ -99,8 +99,27 @@ export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom; return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
} }
export function mockLivekitRoom(room: Partial<LivekitRoom>): LivekitRoom { export function mockLivekitRoom(
return { ...mockEmitter(), ...room } as Partial<LivekitRoom> as LivekitRoom; room: Partial<LivekitRoom>,
{
remoteParticipants,
}: { remoteParticipants?: Observable<RemoteParticipant[]> } = {},
): LivekitRoom {
const livekitRoom = {
...mockEmitter(),
...room,
} as Partial<LivekitRoom> as LivekitRoom;
if (remoteParticipants) {
livekitRoom.remoteParticipants = new Map();
remoteParticipants.subscribe((newRemoteParticipants) => {
livekitRoom.remoteParticipants.clear();
newRemoteParticipants.forEach((p) => {
livekitRoom.remoteParticipants.set(p.identity, p);
});
});
}
return livekitRoom;
} }
export function mockLocalParticipant( export function mockLocalParticipant(
@ -119,13 +138,15 @@ export async function withLocalMedia(
member: Partial<RoomMember>, member: Partial<RoomMember>,
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>, continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
): Promise<void> { ): Promise<void> {
const localParticipant = mockLocalParticipant({});
const vm = new LocalUserMediaViewModel( const vm = new LocalUserMediaViewModel(
"local", "local",
mockMember(member), mockMember(member),
mockLocalParticipant({}), localParticipant,
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
mockLivekitRoom({ localParticipant }),
); );
try { try {
await continuation(vm); await continuation(vm);
@ -152,13 +173,15 @@ export async function withRemoteMedia(
participant: Partial<RemoteParticipant>, participant: Partial<RemoteParticipant>,
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>, continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
): Promise<void> { ): Promise<void> {
const remoteParticipant = mockRemoteParticipant(participant);
const vm = new RemoteUserMediaViewModel( const vm = new RemoteUserMediaViewModel(
"remote", "remote",
mockMember(member), mockMember(member),
mockRemoteParticipant(participant), remoteParticipant,
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
mockLivekitRoom({}, { remoteParticipants: of([remoteParticipant]) }),
); );
try { try {
await continuation(vm); await continuation(vm);