mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-04 05:37:22 +00:00
Merge pull request #3537 from robintown/connection-leaks
Fix resource leaks when we stop using a connection
This commit is contained in:
@@ -24,6 +24,7 @@ import { App } from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
import { Initializer } from "./initializer";
|
||||
import { AppViewModel } from "./state/AppViewModel";
|
||||
import { globalScope } from "./state/ObservableScope";
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
@@ -61,7 +62,7 @@ Initializer.initBeforeReact()
|
||||
.then(() => {
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App vm={new AppViewModel()} />,
|
||||
<App vm={new AppViewModel(globalScope)} />,
|
||||
</StrictMode>,
|
||||
);
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { getBasicRTCSession } from "../utils/test-viewmodel";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
import { testScope, withTestScheduler } from "../utils/test";
|
||||
import { ElementCallReactionEventType, ReactionSet } from ".";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -37,6 +37,7 @@ test("handles a hand raised reaction", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { raisedHands$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
);
|
||||
schedule("ab", {
|
||||
@@ -85,6 +86,7 @@ test("handles a redaction", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { raisedHands$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
);
|
||||
schedule("abc", {
|
||||
@@ -148,6 +150,7 @@ test("handles waiting for event decryption", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { raisedHands$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
);
|
||||
schedule("abc", {
|
||||
@@ -217,6 +220,7 @@ test("hands rejecting events without a proper membership", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { raisedHands$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
);
|
||||
schedule("ab", {
|
||||
@@ -261,7 +265,10 @@ test("handles a reaction", () => {
|
||||
|
||||
withTestScheduler(({ schedule, time, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { reactions$ } = new ReactionsReader(rtcSession.asMockedSession());
|
||||
const { reactions$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
);
|
||||
schedule(`abc`, {
|
||||
a: () => {},
|
||||
b: () => {
|
||||
@@ -317,7 +324,10 @@ test("ignores bad reaction events", () => {
|
||||
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { reactions$ } = new ReactionsReader(rtcSession.asMockedSession());
|
||||
const { reactions$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
);
|
||||
schedule("ab", {
|
||||
a: () => {},
|
||||
b: () => {
|
||||
@@ -439,7 +449,10 @@ test("that reactions cannot be spammed", () => {
|
||||
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
renderHook(() => {
|
||||
const { reactions$ } = new ReactionsReader(rtcSession.asMockedSession());
|
||||
const { reactions$ } = new ReactionsReader(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
);
|
||||
schedule("abcd", {
|
||||
a: () => {},
|
||||
b: () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
EventType,
|
||||
RoomEvent as MatrixRoomEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { BehaviorSubject, delay, type Subscription } from "rxjs";
|
||||
import { BehaviorSubject, delay } from "rxjs";
|
||||
|
||||
import {
|
||||
ElementCallReactionEventType,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type RaisedHandInfo,
|
||||
type ReactionInfo,
|
||||
} from ".";
|
||||
import { type ObservableScope } from "../state/ObservableScope";
|
||||
|
||||
export const REACTION_ACTIVE_TIME_MS = 3000;
|
||||
|
||||
@@ -54,12 +55,13 @@ export class ReactionsReader {
|
||||
*/
|
||||
public readonly reactions$ = this.reactionsSubject$.asObservable();
|
||||
|
||||
private readonly reactionsSub: Subscription;
|
||||
|
||||
public constructor(private readonly rtcSession: MatrixRTCSession) {
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
private readonly rtcSession: MatrixRTCSession,
|
||||
) {
|
||||
// Hide reactions after a given time.
|
||||
this.reactionsSub = this.reactionsSubject$
|
||||
.pipe(delay(REACTION_ACTIVE_TIME_MS))
|
||||
this.reactionsSubject$
|
||||
.pipe(delay(REACTION_ACTIVE_TIME_MS), this.scope.bind())
|
||||
.subscribe((reactions) => {
|
||||
const date = new Date();
|
||||
const nextEntries = Object.fromEntries(
|
||||
@@ -71,15 +73,38 @@ export class ReactionsReader {
|
||||
this.reactionsSubject$.next(nextEntries);
|
||||
});
|
||||
|
||||
// TODO: Convert this class to the functional reactive style and get rid of
|
||||
// all this manual setup and teardown for event listeners
|
||||
|
||||
this.rtcSession.room.on(MatrixRoomEvent.Timeline, this.handleReactionEvent);
|
||||
this.scope.onEnd(() =>
|
||||
this.rtcSession.room.off(
|
||||
MatrixRoomEvent.Timeline,
|
||||
this.handleReactionEvent,
|
||||
),
|
||||
);
|
||||
|
||||
this.rtcSession.room.on(
|
||||
MatrixRoomEvent.Redaction,
|
||||
this.handleReactionEvent,
|
||||
);
|
||||
this.scope.onEnd(() =>
|
||||
this.rtcSession.room.off(
|
||||
MatrixRoomEvent.Redaction,
|
||||
this.handleReactionEvent,
|
||||
),
|
||||
);
|
||||
|
||||
this.rtcSession.room.client.on(
|
||||
MatrixEventEvent.Decrypted,
|
||||
this.handleReactionEvent,
|
||||
);
|
||||
this.scope.onEnd(() =>
|
||||
this.rtcSession.room.client.off(
|
||||
MatrixEventEvent.Decrypted,
|
||||
this.handleReactionEvent,
|
||||
),
|
||||
);
|
||||
|
||||
// We listen for a local echo to get the real event ID, as timeline events
|
||||
// may still be sending.
|
||||
@@ -87,11 +112,23 @@ export class ReactionsReader {
|
||||
MatrixRoomEvent.LocalEchoUpdated,
|
||||
this.handleReactionEvent,
|
||||
);
|
||||
this.scope.onEnd(() =>
|
||||
this.rtcSession.room.off(
|
||||
MatrixRoomEvent.LocalEchoUpdated,
|
||||
this.handleReactionEvent,
|
||||
),
|
||||
);
|
||||
|
||||
rtcSession.on(
|
||||
this.rtcSession.on(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
this.onMembershipsChanged,
|
||||
);
|
||||
this.scope.onEnd(() =>
|
||||
this.rtcSession.off(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
this.onMembershipsChanged,
|
||||
),
|
||||
);
|
||||
|
||||
// Run this once to ensure we have fetched the state from the call.
|
||||
this.onMembershipsChanged([]);
|
||||
@@ -309,31 +346,4 @@ export class ReactionsReader {
|
||||
this.removeRaisedHand(targetUser);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop listening for events.
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.rtcSession.off(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
this.onMembershipsChanged,
|
||||
);
|
||||
this.rtcSession.room.off(
|
||||
MatrixRoomEvent.Timeline,
|
||||
this.handleReactionEvent,
|
||||
);
|
||||
this.rtcSession.room.off(
|
||||
MatrixRoomEvent.Redaction,
|
||||
this.handleReactionEvent,
|
||||
);
|
||||
this.rtcSession.room.client.off(
|
||||
MatrixEventEvent.Decrypted,
|
||||
this.handleReactionEvent,
|
||||
);
|
||||
this.rtcSession.room.off(
|
||||
MatrixRoomEvent.LocalEchoUpdated,
|
||||
this.handleReactionEvent,
|
||||
);
|
||||
this.reactionsSub.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
||||
import ringtoneOgg from "../sound/ringtone.ogg?url";
|
||||
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
|
||||
import { type Layout } from "../state/layout-types.ts";
|
||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
|
||||
const maxTapDurationMs = 400;
|
||||
|
||||
@@ -129,8 +130,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
|
||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||
useEffect(() => {
|
||||
const reactionsReader = new ReactionsReader(props.rtcSession);
|
||||
const scope = new ObservableScope();
|
||||
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
|
||||
const vm = new CallViewModel(
|
||||
scope,
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
mediaDevices,
|
||||
@@ -146,11 +149,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
);
|
||||
setVm(vm);
|
||||
|
||||
const sub = vm.leave$.subscribe(props.onLeft);
|
||||
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
|
||||
return (): void => {
|
||||
vm.destroy();
|
||||
sub.unsubscribe();
|
||||
reactionsReader.destroy();
|
||||
scope.end();
|
||||
};
|
||||
}, [
|
||||
props.rtcSession,
|
||||
|
||||
@@ -108,9 +108,7 @@ function mockMediaDevices(
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
});
|
||||
const scope = new ObservableScope();
|
||||
onTestFinished(() => scope.end());
|
||||
return new MediaDevices(scope);
|
||||
return new MediaDevices(testScope());
|
||||
}
|
||||
|
||||
describe("useMuteStates VITE_PACKAGE='full' (SPA) mode", () => {
|
||||
|
||||
@@ -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 {
|
||||
@@ -117,11 +116,7 @@ import {
|
||||
} from "../rtcSessionHelpers";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
|
||||
import {
|
||||
type Connection,
|
||||
type ConnectionOpts,
|
||||
RemoteConnection,
|
||||
} from "./Connection";
|
||||
import { type Connection, RemoteConnection } from "./Connection";
|
||||
import { type MuteStates } from "./MuteStates";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { type ProcessorState } from "../livekit/TrackProcessorContext";
|
||||
@@ -176,7 +171,15 @@ interface LayoutScanState {
|
||||
|
||||
type MediaItem = UserMedia | ScreenShare;
|
||||
|
||||
export class CallViewModel extends ViewModel {
|
||||
/**
|
||||
* 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 class CallViewModel {
|
||||
private readonly urlParams = getUrlParams();
|
||||
|
||||
private readonly livekitAlias = getLivekitAlias(this.matrixRTCSession);
|
||||
@@ -370,26 +373,36 @@ export class CallViewModel extends ViewModel {
|
||||
*/
|
||||
private readonly localConnection$: Behavior<Async<PublishConnection> | null> =
|
||||
this.scope.behavior(
|
||||
this.localTransport$.pipe(
|
||||
map(
|
||||
(transport) =>
|
||||
transport &&
|
||||
mapAsync(transport, (transport) => {
|
||||
const opts: ConnectionOpts = {
|
||||
transport,
|
||||
client: this.matrixRTCSession.room.client,
|
||||
scope: this.scope,
|
||||
remoteTransports$: this.remoteTransports$,
|
||||
};
|
||||
return new PublishConnection(
|
||||
opts,
|
||||
this.mediaDevices,
|
||||
this.muteStates,
|
||||
this.e2eeLivekitOptions(),
|
||||
this.scope.behavior(this.trackProcessorState$),
|
||||
);
|
||||
}),
|
||||
),
|
||||
generateKeyed$<
|
||||
Async<LivekitTransport> | null,
|
||||
PublishConnection,
|
||||
Async<PublishConnection> | null
|
||||
>(
|
||||
this.localTransport$,
|
||||
(transport, createOrGet) =>
|
||||
transport &&
|
||||
mapAsync(transport, (transport) =>
|
||||
createOrGet(
|
||||
// Stable key that uniquely idenifies the transport
|
||||
JSON.stringify({
|
||||
url: transport.livekit_service_url,
|
||||
alias: transport.livekit_alias,
|
||||
}),
|
||||
(scope) =>
|
||||
new PublishConnection(
|
||||
{
|
||||
transport,
|
||||
client: this.matrixRoom.client,
|
||||
scope,
|
||||
remoteTransports$: this.remoteTransports$,
|
||||
},
|
||||
this.mediaDevices,
|
||||
this.muteStates,
|
||||
this.e2eeLivekitOptions(),
|
||||
this.scope.behavior(this.trackProcessorState$),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -416,61 +429,47 @@ export class CallViewModel extends ViewModel {
|
||||
* is *distinct* from the local transport.
|
||||
*/
|
||||
private readonly remoteConnections$ = this.scope.behavior(
|
||||
this.transports$.pipe(
|
||||
accumulate(new Map<string, Connection>(), (prev, transports) => {
|
||||
const next = new Map<string, Connection>();
|
||||
generateKeyed$<typeof this.transports$.value, Connection, Connection[]>(
|
||||
this.transports$,
|
||||
(transports, createOrGet) => {
|
||||
const connections: Connection[] = [];
|
||||
|
||||
// Until the local transport becomes ready we have no idea which
|
||||
// transports will actually need a dedicated remote connection
|
||||
if (transports?.local.state === "ready") {
|
||||
const oldestMembership = this.matrixRTCSession.getOldestMembership();
|
||||
// TODO: Handle custom transport.livekit_alias values here
|
||||
const localServiceUrl = transports.local.value.livekit_service_url;
|
||||
const remoteServiceUrls = new Set(
|
||||
transports.remote.flatMap(({ membership, transport }) => {
|
||||
const t = membership.getTransport(oldestMembership ?? membership);
|
||||
return t &&
|
||||
isLivekitTransport(t) &&
|
||||
t.livekit_service_url !== localServiceUrl
|
||||
? [t.livekit_service_url]
|
||||
: [];
|
||||
}),
|
||||
transports.remote.map(
|
||||
({ transport }) => transport.livekit_service_url,
|
||||
),
|
||||
);
|
||||
remoteServiceUrls.delete(localServiceUrl);
|
||||
|
||||
for (const remoteServiceUrl of remoteServiceUrls) {
|
||||
let nextConnection = prev.get(remoteServiceUrl);
|
||||
if (!nextConnection) {
|
||||
logger.log(
|
||||
"SFU remoteConnections$ construct new connection: ",
|
||||
for (const remoteServiceUrl of remoteServiceUrls)
|
||||
connections.push(
|
||||
createOrGet(
|
||||
remoteServiceUrl,
|
||||
);
|
||||
|
||||
const args: ConnectionOpts = {
|
||||
transport: {
|
||||
type: "livekit",
|
||||
livekit_service_url: remoteServiceUrl,
|
||||
livekit_alias: this.livekitAlias,
|
||||
},
|
||||
client: this.matrixRTCSession.room.client,
|
||||
scope: this.scope,
|
||||
remoteTransports$: this.remoteTransports$,
|
||||
};
|
||||
nextConnection = new RemoteConnection(
|
||||
args,
|
||||
this.e2eeLivekitOptions(),
|
||||
);
|
||||
} else {
|
||||
logger.log(
|
||||
"SFU remoteConnections$ use prev connection: ",
|
||||
remoteServiceUrl,
|
||||
);
|
||||
}
|
||||
next.set(remoteServiceUrl, nextConnection);
|
||||
}
|
||||
(scope) =>
|
||||
new RemoteConnection(
|
||||
{
|
||||
transport: {
|
||||
type: "livekit",
|
||||
livekit_service_url: remoteServiceUrl,
|
||||
livekit_alias: this.livekitAlias,
|
||||
},
|
||||
client: this.matrixRoom.client,
|
||||
scope,
|
||||
remoteTransports$: this.remoteTransports$,
|
||||
},
|
||||
this.e2eeLivekitOptions(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return next;
|
||||
}),
|
||||
map((transports) => [...transports.values()]),
|
||||
return connections;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -755,80 +754,78 @@ 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[]
|
||||
>(
|
||||
// Generate a collection of MediaItems from the list of expected (whether
|
||||
// present or missing) LiveKit participants.
|
||||
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 +1736,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 +1751,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())
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type CallMembership,
|
||||
type LivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { BehaviorSubject, combineLatest, type Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
@@ -218,6 +219,9 @@ export class Connection {
|
||||
public readonly livekitRoom: LivekitRoom,
|
||||
opts: ConnectionOpts,
|
||||
) {
|
||||
logger.log(
|
||||
`[Connection] Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`,
|
||||
);
|
||||
const { transport, client, scope, remoteTransports$ } = opts;
|
||||
|
||||
this.transport = transport;
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
withLocalMedia,
|
||||
withRemoteMedia,
|
||||
createLocalMedia,
|
||||
createRemoteMedia,
|
||||
withTestScheduler,
|
||||
} from "../utils/test";
|
||||
import { getValue } from "../utils/observable";
|
||||
@@ -42,92 +42,89 @@ vi.mock("../Platform", () => ({
|
||||
|
||||
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
||||
|
||||
test("control a participant's volume", async () => {
|
||||
test("control a participant's volume", () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
await withRemoteMedia(rtcMembership, {}, { setVolume: setVolumeSpy }, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab---c---d|", {
|
||||
a() {
|
||||
// Try muting by toggling
|
||||
vm.toggleLocallyMuted();
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
b() {
|
||||
// Try unmuting by dragging the slider back up
|
||||
vm.setLocalVolume(0.6);
|
||||
vm.setLocalVolume(0.8);
|
||||
vm.commitLocalVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.6);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
c() {
|
||||
// Try muting by dragging the slider back down
|
||||
vm.setLocalVolume(0.2);
|
||||
vm.setLocalVolume(0);
|
||||
vm.commitLocalVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.2);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
d() {
|
||||
// Try unmuting by toggling
|
||||
vm.toggleLocallyMuted();
|
||||
// The volume should return to the last non-zero committed volume
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", {
|
||||
a: 1,
|
||||
b: 0,
|
||||
c: 0.6,
|
||||
d: 0.8,
|
||||
e: 0.2,
|
||||
f: 0,
|
||||
g: 0.8,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const vm = createRemoteMedia(rtcMembership, {}, { setVolume: setVolumeSpy });
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab---c---d|", {
|
||||
a() {
|
||||
// Try muting by toggling
|
||||
vm.toggleLocallyMuted();
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
b() {
|
||||
// Try unmuting by dragging the slider back up
|
||||
vm.setLocalVolume(0.6);
|
||||
vm.setLocalVolume(0.8);
|
||||
vm.commitLocalVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.6);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
c() {
|
||||
// Try muting by dragging the slider back down
|
||||
vm.setLocalVolume(0.2);
|
||||
vm.setLocalVolume(0);
|
||||
vm.commitLocalVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.2);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
d() {
|
||||
// Try unmuting by toggling
|
||||
vm.toggleLocallyMuted();
|
||||
// The volume should return to the last non-zero committed volume
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", {
|
||||
a: 1,
|
||||
b: 0,
|
||||
c: 0.6,
|
||||
d: 0.8,
|
||||
e: 0.2,
|
||||
f: 0,
|
||||
g: 0.8,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("toggle fit/contain for a participant's video", async () => {
|
||||
await withRemoteMedia(rtcMembership, {}, {}, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab|", {
|
||||
a: () => vm.toggleFitContain(),
|
||||
b: () => vm.toggleFitContain(),
|
||||
});
|
||||
expectObservable(vm.cropVideo$).toBe("abc", {
|
||||
a: true,
|
||||
b: false,
|
||||
c: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
test("toggle fit/contain for a participant's video", () => {
|
||||
const vm = createRemoteMedia(rtcMembership, {}, {});
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab|", {
|
||||
a: () => vm.toggleFitContain(),
|
||||
b: () => vm.toggleFitContain(),
|
||||
});
|
||||
expectObservable(vm.cropVideo$).toBe("abc", {
|
||||
a: true,
|
||||
b: false,
|
||||
c: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("local media remembers whether it should always be shown", async () => {
|
||||
await withLocalMedia(
|
||||
test("local media remembers whether it should always be shown", () => {
|
||||
const vm1 = createLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({}),
|
||||
mockMediaDevices({}),
|
||||
(vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
||||
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
|
||||
}),
|
||||
);
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm1.setAlwaysShow(false) });
|
||||
expectObservable(vm1.alwaysShow$).toBe("ab", { a: true, b: false });
|
||||
});
|
||||
|
||||
// Next local media should start out *not* always shown
|
||||
await withLocalMedia(
|
||||
const vm2 = createLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({}),
|
||||
mockMediaDevices({}),
|
||||
(vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
||||
expectObservable(vm.alwaysShow$).toBe("ab", { a: false, b: true });
|
||||
}),
|
||||
);
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm2.setAlwaysShow(true) });
|
||||
expectObservable(vm2.alwaysShow$).toBe("ab", { a: false, b: true });
|
||||
});
|
||||
});
|
||||
|
||||
test("switch cameras", async () => {
|
||||
@@ -164,7 +161,7 @@ test("switch cameras", async () => {
|
||||
|
||||
const selectVideoInput = vi.fn();
|
||||
|
||||
await withLocalMedia(
|
||||
const vm = createLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({
|
||||
@@ -179,27 +176,26 @@ test("switch cameras", async () => {
|
||||
select: selectVideoInput,
|
||||
},
|
||||
}),
|
||||
async (vm) => {
|
||||
// Switch to back camera
|
||||
getValue(vm.switchCamera$)!();
|
||||
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
|
||||
facingMode: "environment",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(selectVideoInput).toHaveBeenCalledTimes(1);
|
||||
expect(selectVideoInput).toHaveBeenCalledWith("back camera");
|
||||
});
|
||||
expect(deviceId).toBe("back camera");
|
||||
|
||||
// Switch to front camera
|
||||
getValue(vm.switchCamera$)!();
|
||||
expect(restartTrack).toHaveBeenCalledTimes(2);
|
||||
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
|
||||
await waitFor(() => {
|
||||
expect(selectVideoInput).toHaveBeenCalledTimes(2);
|
||||
expect(selectVideoInput).toHaveBeenLastCalledWith("front camera");
|
||||
});
|
||||
expect(deviceId).toBe("front camera");
|
||||
},
|
||||
);
|
||||
|
||||
// Switch to back camera
|
||||
getValue(vm.switchCamera$)!();
|
||||
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
|
||||
facingMode: "environment",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(selectVideoInput).toHaveBeenCalledTimes(1);
|
||||
expect(selectVideoInput).toHaveBeenCalledWith("back camera");
|
||||
});
|
||||
expect(deviceId).toBe("back camera");
|
||||
|
||||
// Switch to front camera
|
||||
getValue(vm.switchCamera$)!();
|
||||
expect(restartTrack).toHaveBeenCalledTimes(2);
|
||||
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
|
||||
await waitFor(() => {
|
||||
expect(selectVideoInput).toHaveBeenCalledTimes(2);
|
||||
expect(selectVideoInput).toHaveBeenLastCalledWith("front camera");
|
||||
});
|
||||
expect(deviceId).toBe("front camera");
|
||||
});
|
||||
|
||||
@@ -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$,
|
||||
|
||||
@@ -58,7 +58,7 @@ export class PublishConnection extends Connection {
|
||||
trackerProcessorState$: Behavior<ProcessorState>,
|
||||
) {
|
||||
const { scope } = args;
|
||||
logger.info("[LivekitRoom] Create LiveKit room");
|
||||
logger.info("[PublishConnection] Create LiveKit room");
|
||||
const { controlledAudioDevices } = getUrlParams();
|
||||
|
||||
const factory =
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { axe } from "vitest-axe";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { GridTile } from "./GridTile";
|
||||
import { mockRtcMembership, withRemoteMedia } from "../utils/test";
|
||||
import { mockRtcMembership, createRemoteMedia } from "../utils/test";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import type { CallViewModel } from "../state/CallViewModel";
|
||||
@@ -25,7 +25,7 @@ global.IntersectionObserver = class MockIntersectionObserver {
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
test("GridTile is accessible", async () => {
|
||||
await withRemoteMedia(
|
||||
const vm = createRemoteMedia(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
@@ -36,41 +36,40 @@ test("GridTile is accessible", async () => {
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||
},
|
||||
async (vm) => {
|
||||
const fakeRtcSession = {
|
||||
);
|
||||
|
||||
const fakeRtcSession = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
room: {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
client: {
|
||||
getUserId: () => null,
|
||||
getDeviceId: () => null,
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
room: {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
client: {
|
||||
getUserId: () => null,
|
||||
getDeviceId: () => null,
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
},
|
||||
},
|
||||
memberships: [],
|
||||
} as unknown as MatrixRTCSession;
|
||||
const cVm = {
|
||||
reactions$: constant({}),
|
||||
handsRaised$: constant({}),
|
||||
} as Partial<CallViewModel> as CallViewModel;
|
||||
const { container } = render(
|
||||
<ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}>
|
||||
<GridTile
|
||||
vm={new GridTileViewModel(constant(vm))}
|
||||
onOpenProfile={() => {}}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
showSpeakingIndicators
|
||||
focusable={true}
|
||||
/>
|
||||
</ReactionsSenderProvider>,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Name should be visible
|
||||
screen.getByText("Alice");
|
||||
},
|
||||
},
|
||||
memberships: [],
|
||||
} as unknown as MatrixRTCSession;
|
||||
const cVm = {
|
||||
reactions$: constant({}),
|
||||
handsRaised$: constant({}),
|
||||
} as Partial<CallViewModel> as CallViewModel;
|
||||
const { container } = render(
|
||||
<ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}>
|
||||
<GridTile
|
||||
vm={new GridTileViewModel(constant(vm))}
|
||||
onOpenProfile={() => {}}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
showSpeakingIndicators
|
||||
focusable={true}
|
||||
/>
|
||||
</ReactionsSenderProvider>,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Name should be visible
|
||||
screen.getByText("Alice");
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
withLocalMedia,
|
||||
withRemoteMedia,
|
||||
createLocalMedia,
|
||||
createRemoteMedia,
|
||||
} from "../utils/test";
|
||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import { constant } from "../state/Behavior";
|
||||
@@ -27,62 +27,53 @@ global.IntersectionObserver = class MockIntersectionObserver {
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
test("SpotlightTile is accessible", async () => {
|
||||
await withRemoteMedia(
|
||||
const vm1 = createRemoteMedia(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
{},
|
||||
async (vm1) => {
|
||||
await withLocalMedia(
|
||||
mockRtcMembership("@bob:example.org", "BBBB"),
|
||||
{
|
||||
rawDisplayName: "Bob",
|
||||
getMxcAvatarUrl: () => "mxc://dlskf",
|
||||
},
|
||||
mockLocalParticipant({}),
|
||||
mockMediaDevices({}),
|
||||
async (vm2) => {
|
||||
const user = userEvent.setup();
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm1, vm2]),
|
||||
constant(false),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
focusable={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Alice should be in the spotlight, with her name and avatar on the
|
||||
// first page
|
||||
screen.getByText("Alice");
|
||||
const aliceAvatar = screen.getByRole("img");
|
||||
expect(screen.queryByRole("button", { name: "common.back" })).toBe(
|
||||
null,
|
||||
);
|
||||
// Bob should be out of the spotlight, and therefore invisible
|
||||
expect(isInaccessible(screen.getByText("Bob"))).toBe(true);
|
||||
// Now navigate to Bob
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
screen.getByText("Bob");
|
||||
expect(screen.getByRole("img")).not.toBe(aliceAvatar);
|
||||
expect(isInaccessible(screen.getByText("Alice"))).toBe(true);
|
||||
// Can toggle whether the tile is expanded
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(toggleExpanded).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const vm2 = createLocalMedia(
|
||||
mockRtcMembership("@bob:example.org", "BBBB"),
|
||||
{
|
||||
rawDisplayName: "Bob",
|
||||
getMxcAvatarUrl: () => "mxc://dlskf",
|
||||
},
|
||||
mockLocalParticipant({}),
|
||||
mockMediaDevices({}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm1, vm2]), constant(false))}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
focusable={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Alice should be in the spotlight, with her name and avatar on the
|
||||
// first page
|
||||
screen.getByText("Alice");
|
||||
const aliceAvatar = screen.getByRole("img");
|
||||
expect(screen.queryByRole("button", { name: "common.back" })).toBe(null);
|
||||
// Bob should be out of the spotlight, and therefore invisible
|
||||
expect(isInaccessible(screen.getByText("Bob"))).toBe(true);
|
||||
// Now navigate to Bob
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
screen.getByText("Bob");
|
||||
expect(screen.getByRole("img")).not.toBe(aliceAvatar);
|
||||
expect(isInaccessible(screen.getByText("Alice"))).toBe(true);
|
||||
// Can toggle whether the tile is expanded
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(toggleExpanded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -6,9 +6,10 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test } from "vitest";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { withTestScheduler } from "./test";
|
||||
import { pauseWhen } from "./observable";
|
||||
import { generateKeyed$, pauseWhen } from "./observable";
|
||||
|
||||
test("pauseWhen", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
@@ -22,3 +23,43 @@ test("pauseWhen", () => {
|
||||
).toBe(outputMarbles);
|
||||
});
|
||||
});
|
||||
|
||||
test("generateKeyed$ has the right output and ends scopes at the right times", () => {
|
||||
const scope1$ = new Subject<string>();
|
||||
const scope2$ = new Subject<string>();
|
||||
const scope3$ = new Subject<string>();
|
||||
const scope4$ = new Subject<string>();
|
||||
const scopeSubjects = [scope1$, scope2$, scope3$, scope4$];
|
||||
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
// Each scope should start when the input number reaches or surpasses their
|
||||
// number and end when the input number drops back below their number.
|
||||
// At the very end, unsubscribing should end all remaining scopes.
|
||||
const inputMarbles = " 123242";
|
||||
const outputMarbles = " abcbdb";
|
||||
const subscriptionMarbles = "^-----!";
|
||||
const scope1Marbles = " y-----n";
|
||||
const scope2Marbles = " -y----n";
|
||||
const scope3Marbles = " --ynyn";
|
||||
const scope4Marbles = " ----yn";
|
||||
|
||||
expectObservable(
|
||||
generateKeyed$(hot<string>(inputMarbles), (input, createOrGet) => {
|
||||
for (let i = 1; i <= +input; i++) {
|
||||
createOrGet(i.toString(), (scope) => {
|
||||
scopeSubjects[i - 1].next("y");
|
||||
scope.onEnd(() => scopeSubjects[i - 1].next("n"));
|
||||
return i.toString();
|
||||
});
|
||||
}
|
||||
return "abcd"[+input - 1];
|
||||
}),
|
||||
subscriptionMarbles,
|
||||
).toBe(outputMarbles);
|
||||
|
||||
expectObservable(scope1$).toBe(scope1Marbles);
|
||||
expectObservable(scope2$).toBe(scope2Marbles);
|
||||
expectObservable(scope3$).toBe(scope3Marbles);
|
||||
expectObservable(scope4$).toBe(scope4Marbles);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
|
||||
const nothing = Symbol("nothing");
|
||||
|
||||
@@ -117,3 +118,71 @@ export function pauseWhen<T>(pause$: Behavior<boolean>) {
|
||||
map(([value]) => value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a changing input value to an output value consisting of items that have
|
||||
* automatically generated ObservableScopes tied to a key. Items will be
|
||||
* automatically created when their key is requested for the first time, reused
|
||||
* when the same key is requested at a later time, and destroyed (have their
|
||||
* scope ended) when the key is no longer requested.
|
||||
*
|
||||
* @param input$ The input value to be mapped.
|
||||
* @param project A function mapping input values to output values. This
|
||||
* function receives an additional callback `createOrGet` which can be used
|
||||
* within the function body to request that an item be generated for a certain
|
||||
* key. The caller provides a factory which will be used to create the item if
|
||||
* it is being requested for the first time. Otherwise, the item previously
|
||||
* existing under that key will be returned.
|
||||
*/
|
||||
export function generateKeyed$<In, Item, Out>(
|
||||
input$: Observable<In>,
|
||||
project: (
|
||||
input: In,
|
||||
createOrGet: (
|
||||
key: string,
|
||||
factory: (scope: ObservableScope) => Item,
|
||||
) => Item,
|
||||
) => Out,
|
||||
): Observable<Out> {
|
||||
return input$.pipe(
|
||||
// Keep track of the existing items over time, so we can reuse them
|
||||
scan<
|
||||
In,
|
||||
{
|
||||
items: Map<string, { item: Item; scope: ObservableScope }>;
|
||||
output: Out;
|
||||
},
|
||||
{ items: Map<string, { item: Item; scope: ObservableScope }> }
|
||||
>(
|
||||
(state, data) => {
|
||||
const nextItems = new Map<
|
||||
string,
|
||||
{ item: Item; scope: ObservableScope }
|
||||
>();
|
||||
|
||||
const output = project(data, (key, factory) => {
|
||||
let item = state.items.get(key);
|
||||
if (item === undefined) {
|
||||
// First time requesting the key; create the item
|
||||
const scope = new ObservableScope();
|
||||
item = { item: factory(scope), scope };
|
||||
}
|
||||
nextItems.set(key, item);
|
||||
return item.item;
|
||||
});
|
||||
|
||||
// Destroy all items that are no longer being requested
|
||||
for (const [key, { scope }] of state.items)
|
||||
if (!nextItems.has(key)) scope.end();
|
||||
|
||||
return { items: nextItems, output };
|
||||
},
|
||||
{ items: new Map() },
|
||||
),
|
||||
finalizeValue((state) => {
|
||||
// Destroy all remaining items when no longer subscribed
|
||||
for (const { scope } of state.items.values()) scope.end();
|
||||
}),
|
||||
map(({ output }) => output),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
mockMediaDevices,
|
||||
mockMuteStates,
|
||||
MockRTCSession,
|
||||
testScope,
|
||||
} from "./test";
|
||||
import { aliceRtcMember, localRtcMember } from "./test-fixtures";
|
||||
import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
||||
@@ -134,6 +135,7 @@ export function getBasicCallViewModelEnvironment(
|
||||
// const remoteParticipants$ = of([aliceParticipant]);
|
||||
|
||||
const vm = new CallViewModel(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
matrixRoom,
|
||||
mockMediaDevices({}),
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
import { map, type Observable, of, type SchedulerLike } from "rxjs";
|
||||
import { type RunHelpers, TestScheduler } from "rxjs/testing";
|
||||
import { expect, type MockedObject, vi, vitest } from "vitest";
|
||||
import { expect, type MockedObject, onTestFinished, vi, vitest } from "vitest";
|
||||
import {
|
||||
type RoomMember,
|
||||
type Room as MatrixRoom,
|
||||
@@ -89,6 +89,15 @@ interface TestRunnerGlobal {
|
||||
rxjsTestScheduler?: SchedulerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ObservableScope which ends when the current test ends.
|
||||
*/
|
||||
export function testScope(): ObservableScope {
|
||||
const scope = new ObservableScope();
|
||||
onTestFinished(() => scope.end());
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Observables with a scheduler that virtualizes time, for testing purposes.
|
||||
*/
|
||||
@@ -259,14 +268,14 @@ export function mockLocalParticipant(
|
||||
} as Partial<LocalParticipant> as LocalParticipant;
|
||||
}
|
||||
|
||||
export async function withLocalMedia(
|
||||
export function createLocalMedia(
|
||||
localRtcMember: CallMembership,
|
||||
roomMember: Partial<RoomMember>,
|
||||
localParticipant: LocalParticipant,
|
||||
mediaDevices: MediaDevices,
|
||||
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const vm = new LocalUserMediaViewModel(
|
||||
): LocalUserMediaViewModel {
|
||||
return new LocalUserMediaViewModel(
|
||||
testScope(),
|
||||
"local",
|
||||
mockMatrixRoomMember(localRtcMember, roomMember),
|
||||
constant(localParticipant),
|
||||
@@ -280,11 +289,6 @@ export async function withLocalMedia(
|
||||
constant(null),
|
||||
constant(null),
|
||||
);
|
||||
try {
|
||||
await continuation(vm);
|
||||
} finally {
|
||||
vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function mockRemoteParticipant(
|
||||
@@ -300,14 +304,14 @@ export function mockRemoteParticipant(
|
||||
} as RemoteParticipant;
|
||||
}
|
||||
|
||||
export async function withRemoteMedia(
|
||||
export function createRemoteMedia(
|
||||
localRtcMember: CallMembership,
|
||||
roomMember: Partial<RoomMember>,
|
||||
participant: Partial<RemoteParticipant>,
|
||||
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
): RemoteUserMediaViewModel {
|
||||
const remoteParticipant = mockRemoteParticipant(participant);
|
||||
const vm = new RemoteUserMediaViewModel(
|
||||
return new RemoteUserMediaViewModel(
|
||||
testScope(),
|
||||
"remote",
|
||||
mockMatrixRoomMember(localRtcMember, roomMember),
|
||||
of(remoteParticipant),
|
||||
@@ -321,11 +325,6 @@ export async function withRemoteMedia(
|
||||
constant(null),
|
||||
constant(null),
|
||||
);
|
||||
try {
|
||||
await continuation(vm);
|
||||
} finally {
|
||||
vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
|
||||
|
||||
Reference in New Issue
Block a user