Start refactoring some business logic into view models
As Element Call grows in complexity, it has become a pain point that our business logic remains so tightly coupled to the UI code. In particular, this has made testing difficult, and the complex semantics of React hooks are not a great match for arbitrary business logic. Here, I show the beginnings of what it would look like for us to adopt the MVVM pattern. I've created a CallViewModel and TileViewModel that expose their state to the UI as rxjs Observables, as well as a couple of helper functions for consuming view models in React code. This should contain no user-visible changes, but we need to watch out for regressions particularly around focus switching and promotion of speakers, because this was the logic I chose to refactor first.
This commit is contained in:
267
src/state/CallViewModel.ts
Normal file
267
src/state/CallViewModel.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
connectedParticipantsObserver,
|
||||
observeParticipantMedia,
|
||||
} from "@livekit/components-core";
|
||||
import { Room as LivekitRoom, RemoteParticipant } from "livekit-client";
|
||||
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
EMPTY,
|
||||
Observable,
|
||||
combineLatest,
|
||||
concat,
|
||||
mergeAll,
|
||||
of,
|
||||
sample,
|
||||
scan,
|
||||
startWith,
|
||||
takeUntil,
|
||||
zip,
|
||||
} from "rxjs";
|
||||
import { state } from "@react-rxjs/core";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import { useObservable } from "./useObservable";
|
||||
import {
|
||||
ECAddonConnectionState,
|
||||
ECConnectionState,
|
||||
} from "../livekit/useECConnectionState";
|
||||
import { usePrevious } from "../usePrevious";
|
||||
import {
|
||||
TileViewModel,
|
||||
UserMediaTileViewModel,
|
||||
ScreenShareTileViewModel,
|
||||
} from "./TileViewModel";
|
||||
|
||||
// Represents something that should get a tile on the layout,
|
||||
// ie. a user's video feed or a screen share feed.
|
||||
export interface TileDescriptor<T> {
|
||||
id: string;
|
||||
focused: boolean;
|
||||
isPresenter: boolean;
|
||||
isSpeaker: boolean;
|
||||
hasVideo: boolean;
|
||||
local: boolean;
|
||||
largeBaseSize: boolean;
|
||||
placeNear?: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// How long we wait after a focus switch before showing the real participant
|
||||
// list again
|
||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||
|
||||
function findMatrixMember(
|
||||
room: MatrixRoom,
|
||||
id: string,
|
||||
): RoomMember | undefined {
|
||||
if (!id) return undefined;
|
||||
|
||||
const parts = id.split(":");
|
||||
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
|
||||
if (parts.length < 3) {
|
||||
logger.warn(
|
||||
"Livekit participants ID doesn't look like a userId:deviceId combination",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
parts.pop();
|
||||
const userId = parts.join(":");
|
||||
|
||||
return room.getMember(userId) ?? undefined;
|
||||
}
|
||||
|
||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||
export class CallViewModel extends ViewModel {
|
||||
private readonly rawRemoteParticipants = state(
|
||||
connectedParticipantsObserver(this.livekitRoom),
|
||||
);
|
||||
|
||||
// Lists of participants to "hold" on display, even if LiveKit claims that
|
||||
// they've left
|
||||
private readonly remoteParticipantHolds = zip(
|
||||
this.connectionState,
|
||||
this.rawRemoteParticipants.pipe(sample(this.connectionState)),
|
||||
(s, ps) => {
|
||||
// Whenever we switch focuses, we should retain all the previous
|
||||
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
||||
// give their clients time to switch over and avoid jarring layout shifts
|
||||
if (s === ECAddonConnectionState.ECSwitchingFocus) {
|
||||
return concat(
|
||||
// Hold these participants
|
||||
of({ hold: ps }),
|
||||
// Wait for time to pass and the connection state to have changed
|
||||
Promise.all([
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||
),
|
||||
new Promise<void>((resolve) => {
|
||||
const subscription = this.connectionState
|
||||
.pipe(takeUntil(this.destroyed))
|
||||
.subscribe((s) => {
|
||||
if (s !== ECAddonConnectionState.ECSwitchingFocus) {
|
||||
resolve();
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
});
|
||||
}),
|
||||
// Then unhold them
|
||||
]).then(() => Promise.resolve({ unhold: ps })),
|
||||
);
|
||||
} else {
|
||||
return EMPTY;
|
||||
}
|
||||
},
|
||||
).pipe(
|
||||
mergeAll(),
|
||||
// Aggregate the hold instructions into a single list showing which
|
||||
// participants are being held
|
||||
scan(
|
||||
(holds, instruction) =>
|
||||
"hold" in instruction
|
||||
? [instruction.hold, ...holds]
|
||||
: holds.filter((h) => h !== instruction.unhold),
|
||||
[] as RemoteParticipant[][],
|
||||
),
|
||||
startWith([]),
|
||||
);
|
||||
|
||||
private readonly remoteParticipants = combineLatest(
|
||||
[this.rawRemoteParticipants, this.remoteParticipantHolds],
|
||||
(raw, holds) => {
|
||||
const result = [...raw];
|
||||
const resultIds = new Set(result.map((p) => p.identity));
|
||||
|
||||
// Incorporate the held participants into the list
|
||||
for (const hold of holds) {
|
||||
for (const p of hold) {
|
||||
if (!resultIds.has(p.identity)) {
|
||||
result.push(p);
|
||||
resultIds.add(p.identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
public readonly tiles = state(
|
||||
combineLatest([
|
||||
this.remoteParticipants,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
]).pipe(
|
||||
scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
|
||||
const ps = [localParticipant, ...remoteParticipants];
|
||||
const tilesById = new Map(ts.map((t) => [t.id, t]));
|
||||
const now = Date.now();
|
||||
let allGhosts = true;
|
||||
|
||||
const newTiles = ps.flatMap((p) => {
|
||||
const id = p.identity;
|
||||
const member = findMatrixMember(this.matrixRoom, id);
|
||||
allGhosts &&= member === undefined;
|
||||
const spokeRecently =
|
||||
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
|
||||
|
||||
// We always start with a local participant with the empty string as
|
||||
// their ID before we're connected, this is fine and we'll be in
|
||||
// "all ghosts" mode.
|
||||
if (id !== "" && member === undefined) {
|
||||
logger.warn(
|
||||
`Ruh, roh! No matrix member found for SFU participant '${id}': creating g-g-g-ghost!`,
|
||||
);
|
||||
}
|
||||
|
||||
const userMediaTile: TileDescriptor<TileViewModel> = {
|
||||
id,
|
||||
focused: false,
|
||||
isPresenter: p.isScreenShareEnabled,
|
||||
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
|
||||
hasVideo: p.isCameraEnabled,
|
||||
local: p.isLocal,
|
||||
largeBaseSize: false,
|
||||
data:
|
||||
tilesById.get(id)?.data ??
|
||||
new UserMediaTileViewModel(id, member, p),
|
||||
};
|
||||
|
||||
if (p.isScreenShareEnabled) {
|
||||
const screenShareId = `${id}:screen-share`;
|
||||
const screenShareTile: TileDescriptor<TileViewModel> = {
|
||||
id: screenShareId,
|
||||
focused: true,
|
||||
isPresenter: false,
|
||||
isSpeaker: false,
|
||||
hasVideo: true,
|
||||
local: p.isLocal,
|
||||
largeBaseSize: true,
|
||||
placeNear: id,
|
||||
data:
|
||||
tilesById.get(id)?.data ??
|
||||
new ScreenShareTileViewModel(id, member, p),
|
||||
};
|
||||
return [userMediaTile, screenShareTile];
|
||||
} else {
|
||||
return [userMediaTile];
|
||||
}
|
||||
});
|
||||
|
||||
// If every item is a ghost, that probably means we're still connecting
|
||||
// and shouldn't bother showing anything yet
|
||||
return allGhosts ? [] : newTiles;
|
||||
}, [] as TileDescriptor<TileViewModel>[]),
|
||||
),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||
private readonly matrixRoom: MatrixRoom,
|
||||
private readonly livekitRoom: LivekitRoom,
|
||||
private readonly connectionState: Observable<ECConnectionState>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export function useCallViewModel(
|
||||
matrixRoom: MatrixRoom,
|
||||
livekitRoom: LivekitRoom,
|
||||
connectionState: ECConnectionState,
|
||||
): CallViewModel {
|
||||
const prevMatrixRoom = usePrevious(matrixRoom);
|
||||
const prevLivekitRoom = usePrevious(livekitRoom);
|
||||
const connectionStateObservable = useObservable(connectionState);
|
||||
|
||||
const vm = useRef<CallViewModel>();
|
||||
if (matrixRoom !== prevMatrixRoom || livekitRoom !== prevLivekitRoom) {
|
||||
vm.current?.destroy();
|
||||
vm.current = new CallViewModel(
|
||||
matrixRoom,
|
||||
livekitRoom,
|
||||
connectionStateObservable,
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => vm.current?.destroy(), []);
|
||||
|
||||
return vm.current!;
|
||||
}
|
||||
53
src/state/TileViewModel.ts
Normal file
53
src/state/TileViewModel.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { LocalParticipant, RemoteParticipant } from "livekit-client";
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
export abstract class TileViewModel {
|
||||
// TODO: Properly separate the data layer from the UI layer by keeping the
|
||||
// member and LiveKit participant objects internal. The only LiveKit-specific
|
||||
// thing we need to expose here is a TrackReference for the video, everything
|
||||
// else should be simple strings, flags, and callbacks.
|
||||
public abstract readonly id: string;
|
||||
public abstract readonly member: RoomMember | undefined;
|
||||
public abstract readonly sfuParticipant: LocalParticipant | RemoteParticipant;
|
||||
}
|
||||
|
||||
// Right now it looks kind of pointless to have user media and screen share be
|
||||
// represented by two classes rather than a single flag, but this will come in
|
||||
// handy when we go to move more business logic out of VideoTile and into this
|
||||
// file
|
||||
|
||||
export class UserMediaTileViewModel extends TileViewModel {
|
||||
public constructor(
|
||||
public readonly id: string,
|
||||
public readonly member: RoomMember | undefined,
|
||||
public readonly sfuParticipant: LocalParticipant | RemoteParticipant,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class ScreenShareTileViewModel extends TileViewModel {
|
||||
public constructor(
|
||||
public readonly id: string,
|
||||
public readonly member: RoomMember | undefined,
|
||||
public readonly sfuParticipant: LocalParticipant | RemoteParticipant,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
33
src/state/ViewModel.ts
Normal file
33
src/state/ViewModel.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
/**
|
||||
* An MVVM view model.
|
||||
*/
|
||||
export abstract class ViewModel {
|
||||
protected readonly destroyed = new Subject<void>();
|
||||
|
||||
/**
|
||||
* Instructs the ViewModel to clean up its resources. If you forget to call
|
||||
* this, there may be memory leaks!
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.destroyed.next();
|
||||
this.destroyed.complete();
|
||||
}
|
||||
}
|
||||
38
src/state/subscribe.tsx
Normal file
38
src/state/subscribe.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { FC } from "react";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Subscribe, RemoveSubscribe } from "@react-rxjs/core";
|
||||
|
||||
/**
|
||||
* Wraps a React component that consumes Observables, resulting in a component
|
||||
* that safely subscribes to its Observables before rendering. The component
|
||||
* will return null until the subscriptions are created.
|
||||
*/
|
||||
export function subscribe<P>(render: FC<P>): FC<P> {
|
||||
const InnerComponent: FC<{ p: P }> = ({ p }) => (
|
||||
<RemoveSubscribe>{render(p)}</RemoveSubscribe>
|
||||
);
|
||||
const OuterComponent: FC<P> = (p) => (
|
||||
<Subscribe>
|
||||
<InnerComponent p={p} />
|
||||
</Subscribe>
|
||||
);
|
||||
// Copy over the component's display name, default props, etc.
|
||||
Object.assign(OuterComponent, render);
|
||||
return OuterComponent;
|
||||
}
|
||||
31
src/state/useObservable.ts
Normal file
31
src/state/useObservable.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
/**
|
||||
* React hook that creates an Observable from a changing value. The Observable
|
||||
* replays its current value upon subscription, emits whenever the value
|
||||
* changes, and completes when the component is unmounted.
|
||||
*/
|
||||
export function useObservable<T>(value: T): Observable<T> {
|
||||
const subject = useRef<BehaviorSubject<T>>();
|
||||
subject.current ??= new BehaviorSubject(value);
|
||||
if (value !== subject.current.value) subject.current.next(value);
|
||||
useEffect(() => subject.current!.complete(), []);
|
||||
return subject.current;
|
||||
}
|
||||
Reference in New Issue
Block a user