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:
Robin
2025-10-16 13:57:08 -04:00
parent 2c66e11a0a
commit 717c7420f9
18 changed files with 271 additions and 194 deletions

View File

@@ -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())