Require ObservableScopes of state holders to be specified explicitly
Previously we had a ViewModel class which was responsible for little more than creating an ObservableScope. However, since this ObservableScope would be created implicitly upon view model construction, it became a tad bit harder for callers to remember to eventually end the scope (as you wouldn't just have to remember to end ObservableScopes, but also to destroy ViewModels). Requiring the scope to be specified explicitly by the caller also makes it possible for the caller to reuse the scope for other purposes, reducing the number of scopes mentally in flight that need tending to, and for all state holders (not just view models) to be handled uniformly by helper functions such as generateKeyed$.
This commit is contained in:
@@ -6,14 +6,16 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
|
||||
/**
|
||||
* The top-level state holder for the application.
|
||||
*/
|
||||
export class AppViewModel extends ViewModel {
|
||||
export class AppViewModel {
|
||||
public readonly mediaDevices = new MediaDevices(this.scope);
|
||||
|
||||
// TODO: Move more application logic here. The CallViewModel, at the very
|
||||
// least, ought to be accessible from this object.
|
||||
|
||||
public constructor(private readonly scope: ObservableScope) {}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
mockMediaDevices,
|
||||
mockMuteStates,
|
||||
mockConfig,
|
||||
testScope,
|
||||
} from "../utils/test";
|
||||
import {
|
||||
ECAddonConnectionState,
|
||||
@@ -89,7 +90,6 @@ import {
|
||||
localRtcMember,
|
||||
localRtcMemberDevice2,
|
||||
} from "../utils/test-fixtures";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
@@ -347,6 +347,7 @@ function withCallViewModel(
|
||||
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
|
||||
|
||||
const vm = new CallViewModel(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
room,
|
||||
mediaDevices,
|
||||
@@ -361,7 +362,6 @@ function withCallViewModel(
|
||||
);
|
||||
|
||||
onTestFinished(() => {
|
||||
vm!.destroy();
|
||||
participantsSpy!.mockRestore();
|
||||
mediaSpy!.mockRestore();
|
||||
eventsSpy!.mockRestore();
|
||||
@@ -402,6 +402,7 @@ test("test missing RTC config error", async () => {
|
||||
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({});
|
||||
|
||||
const callVM = new CallViewModel(
|
||||
testScope(),
|
||||
fakeRtcSession.asMockedSession(),
|
||||
matrixRoom,
|
||||
mockMediaDevices({}),
|
||||
@@ -1630,9 +1631,7 @@ test("audio output changes when toggling earpiece mode", () => {
|
||||
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(of([]));
|
||||
|
||||
const scope = new ObservableScope();
|
||||
onTestFinished(() => scope.end());
|
||||
const devices = new MediaDevices(scope);
|
||||
const devices = new MediaDevices(testScope());
|
||||
|
||||
window.controls.setAvailableAudioDevices([
|
||||
{ id: "speaker", name: "Speaker", isSpeaker: true },
|
||||
|
||||
@@ -73,7 +73,6 @@ import {
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
type MediaViewModel,
|
||||
@@ -84,7 +83,7 @@ import {
|
||||
import {
|
||||
accumulate,
|
||||
and$,
|
||||
finalizeValue,
|
||||
generateKeyed$,
|
||||
pauseWhen,
|
||||
} from "../utils/observable";
|
||||
import {
|
||||
@@ -176,7 +175,7 @@ interface LayoutScanState {
|
||||
|
||||
type MediaItem = UserMedia | ScreenShare;
|
||||
|
||||
export class CallViewModel extends ViewModel {
|
||||
export class CallViewModel {
|
||||
private readonly urlParams = getUrlParams();
|
||||
|
||||
private readonly livekitAlias = getLivekitAlias(this.matrixRTCSession);
|
||||
@@ -755,80 +754,76 @@ export class CallViewModel extends ViewModel {
|
||||
);
|
||||
|
||||
/**
|
||||
* List of MediaItems that we want to display
|
||||
* List of MediaItems that we want to have tiles for.
|
||||
*/
|
||||
private readonly mediaItems$ = this.scope.behavior<MediaItem[]>(
|
||||
combineLatest([this.participantsByRoom$, duplicateTiles.value$]).pipe(
|
||||
scan((prevItems, [participantsByRoom, duplicateTiles]) => {
|
||||
const newItems: Map<string, UserMedia | ScreenShare> = new Map(
|
||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||
for (const {
|
||||
livekitRoom,
|
||||
participants,
|
||||
url,
|
||||
} of participantsByRoom) {
|
||||
for (const { id, participant, member } of participants) {
|
||||
for (let i = 0; i < 1 + duplicateTiles; i++) {
|
||||
const mediaId = `${id}:${i}`;
|
||||
const prevMedia = prevItems.get(mediaId);
|
||||
if (prevMedia instanceof UserMedia)
|
||||
prevMedia.updateParticipant(participant);
|
||||
generateKeyed$<
|
||||
[typeof this.participantsByRoom$.value, number],
|
||||
MediaItem,
|
||||
MediaItem[]
|
||||
>(
|
||||
combineLatest([this.participantsByRoom$, duplicateTiles.value$]),
|
||||
([participantsByRoom, duplicateTiles], createOrGet) => {
|
||||
const items: MediaItem[] = [];
|
||||
|
||||
yield [
|
||||
for (const { livekitRoom, participants, url } of participantsByRoom) {
|
||||
for (const { id, participant, member } of participants) {
|
||||
for (let i = 0; i < 1 + duplicateTiles; i++) {
|
||||
const mediaId = `${id}:${i}`;
|
||||
const item = createOrGet(
|
||||
mediaId,
|
||||
(scope) =>
|
||||
// We create UserMedia with or without a participant.
|
||||
// This will be the initial value of a BehaviourSubject.
|
||||
// Once a participant appears we will update the BehaviourSubject. (see below)
|
||||
new UserMedia(
|
||||
scope,
|
||||
mediaId,
|
||||
// We create UserMedia with or without a participant.
|
||||
// This will be the initial value of a BehaviourSubject.
|
||||
// Once a participant appears we will update the BehaviourSubject. (see above)
|
||||
prevMedia ??
|
||||
new UserMedia(
|
||||
mediaId,
|
||||
member,
|
||||
participant,
|
||||
this.options.encryptionSystem,
|
||||
livekitRoom,
|
||||
url,
|
||||
this.mediaDevices,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map((m) => m.get(id) ?? "[👻]"),
|
||||
),
|
||||
this.handsRaised$.pipe(map((v) => v[id]?.time ?? null)),
|
||||
this.reactions$.pipe(map((v) => v[id] ?? undefined)),
|
||||
),
|
||||
);
|
||||
items.push(item);
|
||||
(item as UserMedia).updateParticipant(participant);
|
||||
|
||||
if (participant?.isScreenShareEnabled) {
|
||||
const screenShareId = `${mediaId}:screen-share`;
|
||||
items.push(
|
||||
createOrGet(
|
||||
screenShareId,
|
||||
(scope) =>
|
||||
new ScreenShare(
|
||||
scope,
|
||||
screenShareId,
|
||||
member,
|
||||
participant,
|
||||
this.options.encryptionSystem,
|
||||
livekitRoom,
|
||||
url,
|
||||
this.mediaDevices,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map((m) => m.get(id) ?? "[👻]"),
|
||||
),
|
||||
this.handsRaised$.pipe(map((v) => v[id]?.time ?? null)),
|
||||
this.reactions$.pipe(map((v) => v[id] ?? undefined)),
|
||||
),
|
||||
];
|
||||
|
||||
if (participant?.isScreenShareEnabled) {
|
||||
const screenShareId = `${mediaId}:screen-share`;
|
||||
yield [
|
||||
screenShareId,
|
||||
prevItems.get(screenShareId) ??
|
||||
new ScreenShare(
|
||||
screenShareId,
|
||||
member,
|
||||
participant,
|
||||
this.options.encryptionSystem,
|
||||
livekitRoom,
|
||||
url,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map((m) => m.get(id) ?? "[👻]"),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}.bind(this)(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
|
||||
return newItems;
|
||||
}, new Map<string, MediaItem>()),
|
||||
map((mediaItems) => [...mediaItems.values()]),
|
||||
finalizeValue((ts) => {
|
||||
for (const t of ts) t.destroy();
|
||||
}),
|
||||
return items;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1739,6 +1734,7 @@ export class CallViewModel extends ViewModel {
|
||||
: null;
|
||||
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
// A call is permanently tied to a single Matrix room
|
||||
private readonly matrixRTCSession: MatrixRTCSession,
|
||||
private readonly matrixRoom: MatrixRoom,
|
||||
@@ -1753,8 +1749,6 @@ export class CallViewModel extends ViewModel {
|
||||
>,
|
||||
private readonly trackProcessorState$: Observable<ProcessorState>,
|
||||
) {
|
||||
super();
|
||||
|
||||
// Start and stop local and remote connections as needed
|
||||
this.connectionInstructions$
|
||||
.pipe(this.scope.bind())
|
||||
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
throttleTime,
|
||||
} from "rxjs";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import { alwaysShowSelf } from "../settings/settings";
|
||||
import { showConnectionStats } from "../settings/settings";
|
||||
import { accumulate } from "../utils/observable";
|
||||
@@ -56,6 +55,7 @@ import { type ReactionOption } from "../reactions";
|
||||
import { platform } from "../Platform";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
|
||||
export function observeTrackReference$(
|
||||
participant: Participant,
|
||||
@@ -216,7 +216,7 @@ export enum EncryptionStatus {
|
||||
PasswordInvalid,
|
||||
}
|
||||
|
||||
abstract class BaseMediaViewModel extends ViewModel {
|
||||
abstract class BaseMediaViewModel {
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
@@ -246,6 +246,7 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
public constructor(
|
||||
protected readonly scope: ObservableScope,
|
||||
/**
|
||||
* An opaque identifier for this media.
|
||||
*/
|
||||
@@ -269,8 +270,6 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
public readonly focusURL: string,
|
||||
public readonly displayName$: Behavior<string>,
|
||||
) {
|
||||
super();
|
||||
|
||||
const audio$ = this.observeTrackReference$(audioSource);
|
||||
this.video$ = this.observeTrackReference$(videoSource);
|
||||
|
||||
@@ -403,6 +402,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
public readonly cropVideo$: Behavior<boolean> = this._cropVideo$;
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
member: RoomMember,
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
|
||||
@@ -414,6 +414,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
public readonly reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
member,
|
||||
participant$,
|
||||
@@ -537,6 +538,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
);
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
member: RoomMember,
|
||||
participant$: Behavior<LocalParticipant | undefined>,
|
||||
@@ -549,6 +551,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
member,
|
||||
participant$,
|
||||
@@ -645,6 +648,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
);
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
member: RoomMember,
|
||||
participant$: Observable<RemoteParticipant | undefined>,
|
||||
@@ -657,6 +661,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
member,
|
||||
participant$,
|
||||
@@ -742,6 +747,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
);
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
member: RoomMember,
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||
@@ -753,6 +759,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
public readonly local: boolean,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
member,
|
||||
participant$,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
|
||||
import { ObservableScope } from "./ObservableScope.ts";
|
||||
import { type ObservableScope } from "./ObservableScope.ts";
|
||||
import { ScreenShareViewModel } from "./MediaViewModel.ts";
|
||||
import type { RoomMember } from "matrix-js-sdk";
|
||||
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
||||
@@ -23,10 +23,10 @@ import type { Behavior } from "./Behavior.ts";
|
||||
* ObservableScope for behaviors that the view model depends on.
|
||||
*/
|
||||
export class ScreenShare {
|
||||
private readonly scope = new ObservableScope();
|
||||
public readonly vm: ScreenShareViewModel;
|
||||
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
id: string,
|
||||
member: RoomMember,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
@@ -37,6 +37,7 @@ export class ScreenShare {
|
||||
displayName$: Observable<string>,
|
||||
) {
|
||||
this.vm = new ScreenShareViewModel(
|
||||
this.scope,
|
||||
id,
|
||||
member,
|
||||
of(participant),
|
||||
@@ -48,9 +49,4 @@ export class ScreenShare {
|
||||
participant.isLocal,
|
||||
);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.scope.end();
|
||||
this.vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,6 @@ class SpotlightTileData {
|
||||
this.maximised$ = new BehaviorSubject(maximised);
|
||||
this.vm = new SpotlightTileViewModel(this.media$, this.maximised$);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
class GridTileData {
|
||||
@@ -65,14 +61,10 @@ class GridTileData {
|
||||
this.media$ = new BehaviorSubject(media);
|
||||
this.vm = new GridTileViewModel(this.media$);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection of tiles to be mapped to a layout.
|
||||
* An immutable collection of tiles to be mapped to a layout.
|
||||
*/
|
||||
export class TileStore {
|
||||
private constructor(
|
||||
@@ -288,13 +280,6 @@ export class TileStoreBuilder {
|
||||
);
|
||||
}
|
||||
|
||||
// Destroy unused tiles
|
||||
if (this.spotlight === null && this.prevSpotlight !== null)
|
||||
this.prevSpotlight.destroy();
|
||||
const gridEntries = new Set(grid);
|
||||
for (const entry of this.prevGrid)
|
||||
if (!gridEntries.has(entry)) entry.destroy();
|
||||
|
||||
return this.construct(this.spotlight, grid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
@@ -14,21 +13,17 @@ function createId(): string {
|
||||
return (nextId++).toString();
|
||||
}
|
||||
|
||||
export class GridTileViewModel extends ViewModel {
|
||||
export class GridTileViewModel {
|
||||
public readonly id = createId();
|
||||
|
||||
public constructor(public readonly media$: Behavior<UserMediaViewModel>) {
|
||||
super();
|
||||
}
|
||||
public constructor(public readonly media$: Behavior<UserMediaViewModel>) {}
|
||||
}
|
||||
|
||||
export class SpotlightTileViewModel extends ViewModel {
|
||||
export class SpotlightTileViewModel {
|
||||
public constructor(
|
||||
public readonly media$: Behavior<MediaViewModel[]>,
|
||||
public readonly maximised$: Behavior<boolean>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
) {}
|
||||
}
|
||||
|
||||
export type TileViewModel = GridTileViewModel | SpotlightTileViewModel;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "livekit-client";
|
||||
import { observeParticipantEvents } from "@livekit/components-core";
|
||||
|
||||
import { ObservableScope } from "./ObservableScope.ts";
|
||||
import { type ObservableScope } from "./ObservableScope.ts";
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
RemoteUserMediaViewModel,
|
||||
@@ -75,11 +75,11 @@ enum SortingBin {
|
||||
* for inclusion in the call layout.
|
||||
*/
|
||||
export class UserMedia {
|
||||
private readonly scope = new ObservableScope();
|
||||
private readonly participant$ = new BehaviorSubject(this.initialParticipant);
|
||||
|
||||
public readonly vm: UserMediaViewModel = this.participant$.value?.isLocal
|
||||
? new LocalUserMediaViewModel(
|
||||
this.scope,
|
||||
this.id,
|
||||
this.member,
|
||||
this.participant$ as Behavior<LocalParticipant>,
|
||||
@@ -92,6 +92,7 @@ export class UserMedia {
|
||||
this.scope.behavior(this.reaction$),
|
||||
)
|
||||
: new RemoteUserMediaViewModel(
|
||||
this.scope,
|
||||
this.id,
|
||||
this.member,
|
||||
this.participant$ as Observable<RemoteParticipant | undefined>,
|
||||
@@ -144,6 +145,7 @@ export class UserMedia {
|
||||
);
|
||||
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
public readonly id: string,
|
||||
private readonly member: RoomMember,
|
||||
private readonly initialParticipant:
|
||||
@@ -168,11 +170,6 @@ export class UserMedia {
|
||||
this.participant$.next(newParticipant);
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.scope.end();
|
||||
this.vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function sharingScreen$(p: Participant): Observable<boolean> {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
|
||||
/**
|
||||
* An MVVM view model.
|
||||
*/
|
||||
export abstract class ViewModel {
|
||||
protected readonly scope = new ObservableScope();
|
||||
|
||||
/**
|
||||
* Instructs the ViewModel to clean up its resources. If you forget to call
|
||||
* this, there may be memory leaks!
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.scope.end();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user