convert CallViewModel into create function pattern. (with much more

minimal changes thanks to the intermediate class refactor)
This commit is contained in:
Timo K
2025-11-17 18:22:25 +01:00
parent 16e1c59e11
commit 2e2c799f72
5 changed files with 1115 additions and 1123 deletions

View File

@@ -59,7 +59,8 @@ import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle";
import {
CallViewModel,
type CallViewModel,
createCallViewModel$,
type GridMode,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
@@ -128,7 +129,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams;
const vm = new CallViewModel(
const vm = createCallViewModel$(
scope,
props.rtcSession,
props.matrixRoom,

View File

@@ -37,7 +37,7 @@ import {
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { CallViewModel } from "./CallViewModel";
import { createCallViewModel$ } from "./CallViewModel";
import { type Layout } from "../layout-types.ts";
import {
mockLocalParticipant,
@@ -277,7 +277,7 @@ describe("CallViewModel", () => {
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({});
const callVM = new CallViewModel(
const callVM = createCallViewModel$(
testScope(),
fakeRtcSession.asMockedSession(),
matrixRoom,

View File

@@ -172,55 +172,56 @@ type AudioLivekitItem = {
};
/**
* A view model providing all the application logic needed to show the in-call
* UI (may eventually be expanded to cover the lobby and feedback screens in the
* future).
* The return of createCallViewModel$
* This interface represents the root snapshot for the call view. Snapshots in EC with rxjs behave like snapshot trees.
* They are a list of observables and objects containing observables to allow for a very granular update mechanism.
*
* This allows to have one huge call view model that represents the entire view without a unnecessary amount of updates.
*
* (Mocking this interface should allow building a full view in all states.)
*/
// Throughout this class and related code we must distinguish between MatrixRTC
// state and LiveKit state. We use the common terminology of room "members", RTC
// "memberships", and LiveKit "participants".
export class CallViewModel {
export interface CallViewModel {
// lifecycle
public autoLeave$: Observable<AutoLeaveReason>;
autoLeave$: Observable<AutoLeaveReason>;
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
public callPickupState$: Behavior<
callPickupState$: Behavior<
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
>;
public leave$: Observable<"user" | AutoLeaveReason>;
leave$: Observable<"user" | AutoLeaveReason>;
/** Call to initiate hangup. Use in conbination with connectino state track the async hangup process. */
public hangup: () => void;
hangup: () => void;
// joining
public join: () => LocalMemberConnectionState;
join: () => LocalMemberConnectionState;
// screen sharing
/**
* Callback to toggle screen sharing. If null, screen sharing is not possible.
*/
public toggleScreenSharing: (() => void) | null;
toggleScreenSharing: (() => void) | null;
/**
* Whether we are sharing our screen.
*/
public sharingScreen$: Behavior<boolean>;
sharingScreen$: Behavior<boolean>;
// UI interactions
/**
* Callback for when the user taps the call view.
*/
public tapScreen: () => void;
tapScreen: () => void;
/**
* Callback for when the user taps the call's controls.
*/
public tapControls: () => void;
tapControls: () => void;
/**
* Callback for when the user hovers over the call view.
*/
public hoverScreen: () => void;
hoverScreen: () => void;
/**
* Callback for when the user stops hovering over the call view.
*/
public unhoverScreen: () => void;
unhoverScreen: () => void;
// errors
/**
@@ -228,7 +229,7 @@ export class CallViewModel {
* This is a fatal error that prevents the call from being created/joined.
* Should render a blocking error screen.
*/
public configError$: Behavior<ElementCallError | null>;
configError$: Behavior<ElementCallError | null>;
// participants and counts
/**
@@ -238,15 +239,15 @@ export class CallViewModel {
* - There can be multiple participants for one Matrix user if they join from
* multiple devices.
*/
public participantCount$: Behavior<number>;
participantCount$: Behavior<number>;
/** Participants sorted by livekit room so they can be used in the audio rendering */
public audioParticipants$: Behavior<AudioLivekitItem[]>;
audioParticipants$: Behavior<AudioLivekitItem[]>;
/** List of participants raising their hand */
public handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
public reactions$: Behavior<Record<string, ReactionOption>>;
reactions$: Behavior<Record<string, ReactionOption>>;
public ringOverlay$: Behavior<null | {
ringOverlay$: Behavior<null | {
name: string;
/** roomId or userId for the avatar generation. */
idForAvatar: string;
@@ -254,27 +255,27 @@ export class CallViewModel {
avatarMxc?: string;
}>;
// sounds and events
public joinSoundEffect$: Observable<void>;
public leaveSoundEffect$: Observable<void>;
joinSoundEffect$: Observable<void>;
leaveSoundEffect$: Observable<void>;
/**
* Emits an event every time a new hand is raised in
* the call.
*/
public newHandRaised$: Observable<{ value: number; playSounds: boolean }>;
newHandRaised$: Observable<{ value: number; playSounds: boolean }>;
/**
* Emits an event every time a new screenshare is started in
* the call.
*/
public newScreenShare$: Observable<{ value: number; playSounds: boolean }>;
newScreenShare$: Observable<{ value: number; playSounds: boolean }>;
/**
* Emits an array of reactions that should be played.
*/
public audibleReactions$: Observable<string[]>;
audibleReactions$: Observable<string[]>;
/**
* Emits an array of reactions that should be visible on the screen.
*/
// DISCUSSION move this into a reaction file
public visibleReactions$: Behavior<
visibleReactions$: Behavior<
{ sender: string; emoji: string; startX: number }[]
>;
@@ -282,43 +283,43 @@ export class CallViewModel {
/**
* The general shape of the window.
*/
public windowMode$: Behavior<WindowMode>;
public spotlightExpanded$: Behavior<boolean>;
public toggleSpotlightExpanded$: Behavior<(() => void) | null>;
public gridMode$: Behavior<GridMode>;
public setGridMode: (value: GridMode) => void;
windowMode$: Behavior<WindowMode>;
spotlightExpanded$: Behavior<boolean>;
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
gridMode$: Behavior<GridMode>;
setGridMode: (value: GridMode) => void;
// media view models and layout
public grid$: Behavior<UserMediaViewModel[]>;
public spotlight$: Behavior<MediaViewModel[]>;
public pip$: Behavior<UserMediaViewModel | null>;
grid$: Behavior<UserMediaViewModel[]>;
spotlight$: Behavior<MediaViewModel[]>;
pip$: Behavior<UserMediaViewModel | null>;
/**
* The layout of tiles in the call interface.
*/
public layout$: Behavior<Layout>;
layout$: Behavior<Layout>;
/**
* The current generation of the tile store, exposed for debugging purposes.
*/
public tileStoreGeneration$: Behavior<number>;
public showSpotlightIndicators$: Behavior<boolean>;
public showSpeakingIndicators$: Behavior<boolean>;
tileStoreGeneration$: Behavior<number>;
showSpotlightIndicators$: Behavior<boolean>;
showSpeakingIndicators$: Behavior<boolean>;
// header/footer visibility
public showHeader$: Behavior<boolean>;
public showFooter$: Behavior<boolean>;
showHeader$: Behavior<boolean>;
showFooter$: Behavior<boolean>;
// audio routing
/**
* Whether audio is currently being output through the earpiece.
*/
public earpieceMode$: Behavior<boolean>;
earpieceMode$: Behavior<boolean>;
/**
* Callback to toggle between the earpiece and the loudspeaker.
*
* This will be `null` in case the target does not exist in the list
* of available audio outputs.
*/
public audioOutputSwitcher$: Behavior<{
audioOutputSwitcher$: Behavior<{
targetOutput: "earpiece" | "speaker";
switch: () => void;
} | null>;
@@ -333,10 +334,17 @@ export class CallViewModel {
// down, for example, and we want to avoid making people worry that the app is
// in a split-brained state.
// DISCUSSION own membership manager ALSO this probably can be simplifis
public reconnecting$: Behavior<boolean>;
// THIS has to be the last public field declaration
public constructor(
reconnecting$: Behavior<boolean>;
}
/**
* A view model providing all the application logic needed to show the in-call
* UI (may eventually be expanded to cover the lobby and feedback screens in the
* future).
*/
// Throughout this class and related code we must distinguish between MatrixRTC
// state and LiveKit state. We use the common terminology of room "members", RTC
// "memberships", and LiveKit "participants".
export function createCallViewModel$(
scope: ObservableScope,
// A call is permanently tied to a single Matrix room
matrixRTCSession: MatrixRTCSession,
@@ -347,7 +355,7 @@ export class CallViewModel {
handsRaisedSubject$: Observable<Record<string, RaisedHandInfo>>,
reactionsSubject$: Observable<Record<string, ReactionInfo>>,
trackProcessorState$: Behavior<ProcessorState>,
) {
): CallViewModel {
const userId = matrixRoom.client.getUserId()!;
const deviceId = matrixRoom.client.getDeviceId()!;
@@ -407,9 +415,7 @@ export class CallViewModel {
combineLatest(
[localTransport$, membershipsAndTransports.transports$],
(localTransport, transports) => {
const localTransportAsArray = localTransport
? [localTransport]
: [];
const localTransportAsArray = localTransport ? [localTransport] : [];
return transports.mapInner((transports) => [
...localTransportAsArray,
...transports,
@@ -457,8 +463,7 @@ export class CallViewModel {
(memberships) =>
memberships.value.find(
(membership) =>
membership.userId === userId &&
membership.deviceId === deviceId,
membership.userId === userId && membership.deviceId === deviceId,
) ?? null,
),
),
@@ -492,10 +497,7 @@ export class CallViewModel {
const { callPickupState$, autoLeave$ } = createCallNotificationLifecycle$({
scope: scope,
memberships$: memberships$,
sentCallNotification$: createSentCallNotification$(
scope,
matrixRTCSession,
),
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
receivedDecline$: createReceivedDecline$(matrixRoom),
options: options,
localUser: { userId: userId, deviceId: deviceId },
@@ -516,8 +518,7 @@ export class CallViewModel {
matrixRoomMembers$.pipe(
map(
(roomMembersMap) =>
roomMembersMap.size === 1 &&
roomMembersMap.get(userId) !== undefined,
roomMembersMap.size === 1 && roomMembersMap.get(userId) !== undefined,
),
),
);
@@ -780,10 +781,7 @@ export class CallViewModel {
matrixLivekitMembers$.pipe(map((ms) => ms.value.length)),
);
const leaveSoundEffect$ = combineLatest([
callPickupState$,
userMedia$,
]).pipe(
const leaveSoundEffect$ = combineLatest([callPickupState$, userMedia$]).pipe(
// Until the call is successful, do not play a leave sound.
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
skipWhile(([c]) => c !== null && c !== "success"),
@@ -869,9 +867,7 @@ export class CallViewModel {
return bins.length === 0
? of([])
: combineLatest(bins, (...bins) =>
bins
.sort(([, bin1], [, bin2]) => bin1 - bin2)
.map(([m]) => m.vm),
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
);
}),
distinctUntilChanged(shallowEquals),
@@ -1092,9 +1088,7 @@ export class CallViewModel {
oneOnOne === null
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
grid.length > smallMobileCallThreshold ||
spotlight.some(
(vm) => vm instanceof ScreenShareViewModel,
)
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
? spotlightPortraitLayoutMedia$
: gridLayoutMedia$,
).pipe(switchAll())
@@ -1130,9 +1124,7 @@ export class CallViewModel {
const visibleTiles$ = new Subject<number>();
const setVisibleTiles = (value: number): void => visibleTiles$.next(value);
const layoutInternals$ = scope.behavior<
LayoutScanState & { layout: Layout }
>(
const layoutInternals$ = scope.behavior<LayoutScanState & { layout: Layout }>(
combineLatest([
layoutMedia$,
visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
@@ -1309,10 +1301,7 @@ export class CallViewModel {
*/
const earpieceMode$ = scope.behavior<boolean>(
combineLatest(
[
mediaDevices.audioOutput.available$,
mediaDevices.audioOutput.selected$,
],
[mediaDevices.audioOutput.available$, mediaDevices.audioOutput.selected$],
(available, selected) =>
selected !== undefined &&
available.get(selected.id)?.type === "earpiece",
@@ -1330,10 +1319,7 @@ export class CallViewModel {
switch: () => void;
} | null>(
combineLatest(
[
mediaDevices.audioOutput.available$,
mediaDevices.audioOutput.selected$,
],
[mediaDevices.audioOutput.available$, mediaDevices.audioOutput.selected$],
(available, selected) => {
const selectionType = selected && available.get(selected.id)?.type;
@@ -1366,8 +1352,7 @@ export class CallViewModel {
Record<string, ReactionOption>,
{ sender: string; emoji: string; startX: number }[]
>((acc, latest) => {
const newSet: { sender: string; emoji: string; startX: number }[] =
[];
const newSet: { sender: string; emoji: string; startX: number }[] = [];
for (const [sender, reaction] of Object.entries(latest)) {
const startX =
acc.find((v) => v.sender === sender && v.emoji)?.startX ??
@@ -1440,53 +1425,54 @@ export class CallViewModel {
const toggleScreenSharing = localMembership.toggleScreenSharing;
const join = localMembership.requestConnect;
join(); // TODO-MULTI-SFU: Use this view model for the lobby as well, and only call this once 'join' is clicked?
// TODO-MULTI-SFU: Use this view model for the lobby as well, and only call this once 'join' is clicked?
join();
return {
autoLeave$: autoLeave$,
callPickupState$: callPickupState$,
ringOverlay$: ringOverlay$,
leave$: leave$,
hangup: (): void => userHangup$.next(),
join: join,
toggleScreenSharing: toggleScreenSharing,
sharingScreen$: sharingScreen$,
this.autoLeave$ = autoLeave$;
this.callPickupState$ = callPickupState$;
this.ringOverlay$ = ringOverlay$;
this.leave$ = leave$;
this.hangup = (): void => userHangup$.next();
this.join = join;
this.toggleScreenSharing = toggleScreenSharing;
this.sharingScreen$ = sharingScreen$;
tapScreen: (): void => screenTap$.next(),
tapControls: (): void => controlsTap$.next(),
hoverScreen: (): void => screenHover$.next(),
unhoverScreen: (): void => screenUnhover$.next(),
this.tapScreen = (): void => screenTap$.next();
this.tapControls = (): void => controlsTap$.next();
this.hoverScreen = (): void => screenHover$.next();
this.unhoverScreen = (): void => screenUnhover$.next();
configError$: localMembership.configError$,
participantCount$: participantCount$,
audioParticipants$: audioParticipants$,
this.configError$ = localMembership.configError$;
this.participantCount$ = participantCount$;
this.audioParticipants$ = audioParticipants$;
handsRaised$: handsRaised$,
reactions$: reactions$,
joinSoundEffect$: joinSoundEffect$,
leaveSoundEffect$: leaveSoundEffect$,
newHandRaised$: newHandRaised$,
newScreenShare$: newScreenShare$,
audibleReactions$: audibleReactions$,
visibleReactions$: visibleReactions$,
this.handsRaised$ = handsRaised$;
this.reactions$ = reactions$;
this.joinSoundEffect$ = joinSoundEffect$;
this.leaveSoundEffect$ = leaveSoundEffect$;
this.newHandRaised$ = newHandRaised$;
this.newScreenShare$ = newScreenShare$;
this.audibleReactions$ = audibleReactions$;
this.visibleReactions$ = visibleReactions$;
this.windowMode$ = windowMode$;
this.spotlightExpanded$ = spotlightExpanded$;
this.toggleSpotlightExpanded$ = toggleSpotlightExpanded$;
this.gridMode$ = gridMode$;
this.setGridMode = setGridMode;
this.grid$ = grid$;
this.spotlight$ = spotlight$;
this.pip$ = pip$;
this.layout$ = layout$;
this.tileStoreGeneration$ = tileStoreGeneration$;
this.showSpotlightIndicators$ = showSpotlightIndicators$;
this.showSpeakingIndicators$ = showSpeakingIndicators$;
this.showHeader$ = showHeader$;
this.showFooter$ = showFooter$;
this.earpieceMode$ = earpieceMode$;
this.audioOutputSwitcher$ = audioOutputSwitcher$;
this.reconnecting$ = reconnecting$;
}
windowMode$: windowMode$,
spotlightExpanded$: spotlightExpanded$,
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
gridMode$: gridMode$,
setGridMode: setGridMode,
grid$: grid$,
spotlight$: spotlight$,
pip$: pip$,
layout$: layout$,
tileStoreGeneration$: tileStoreGeneration$,
showSpotlightIndicators$: showSpotlightIndicators$,
showSpeakingIndicators$: showSpeakingIndicators$,
showHeader$: showHeader$,
showFooter$: showFooter$,
earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: reconnecting$,
};
}
// TODO-MULTI-SFU // Setup and update the keyProvider which was create by `createRoom` was a thing before. Now we never update if the E2EEsystem changes
// do we need this?

View File

@@ -24,7 +24,11 @@ import * as ComponentsCore from "@livekit/components-core";
import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { E2eeType } from "../../e2ee/e2eeType";
import { type RaisedHandInfo, type ReactionInfo } from "../../reactions";
import { CallViewModel, type CallViewModelOptions } from "./CallViewModel";
import {
type CallViewModel,
createCallViewModel$,
type CallViewModelOptions,
} from "./CallViewModel";
import {
mockConfig,
mockLivekitRoom,
@@ -154,7 +158,7 @@ export function withCallViewModel(
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>({});
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
const vm = new CallViewModel(
const vm = createCallViewModel$(
testScope(),
rtcSession.asMockedSession(),
room,

View File

@@ -20,7 +20,8 @@ import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
import { E2eeType } from "../e2ee/e2eeType";
import {
CallViewModel,
type CallViewModel,
createCallViewModel$,
type CallViewModelOptions,
} from "../state/CallViewModel/CallViewModel";
import {
@@ -145,7 +146,7 @@ export function getBasicCallViewModelEnvironment(
// const remoteParticipants$ = of([aliceParticipant]);
const vm = new CallViewModel(
const vm = createCallViewModel$(
testScope(),
rtcSession.asMockedSession(),
matrixRoom,