From fdce3ec1aacb221dd87c68be8184a1fbee395342 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 14 Nov 2025 10:44:16 +0100 Subject: [PATCH] Fix a couple of CallViewModel tests. --- src/state/CallViewModel/CallViewModel.test.ts | 2634 +++++++++-------- src/state/CallViewModel/CallViewModel.ts | 95 +- .../localMember/LocalMembership.ts | 21 +- .../remoteMembers/ConnectionManager.ts | 2 +- .../remoteMembers/MatrixLivekitMembers.ts | 2 +- src/state/ObservableScope.ts | 24 +- src/state/SessionBehaviors.ts | 2 +- src/utils/test-fixtures.ts | 4 +- src/utils/test.ts | 20 +- 9 files changed, 1426 insertions(+), 1378 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index ba976094..1cc2ff89 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -93,6 +93,9 @@ import { MatrixRTCTransportMissingError, } from "../../utils/errors.ts"; +mockConfig({ + livekit: { livekit_service_url: "http://my-default-service-url.com" }, +}); vi.mock("rxjs", async (importOriginal) => ({ ...(await importOriginal()), // Disable interval Observables for the following tests since the test @@ -384,1375 +387,1386 @@ export function withCallViewModel( continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState); } -// TODO: Restore this test. It requires makeTransport to not be mocked, unlike -// the rest of the tests in this file… what do we do? -test.skip("test missing RTC config error", async () => { - const rtcMemberships$ = new BehaviorSubject([]); - const emitter = new EventEmitter(); - const client = vi.mocked({ - on: emitter.on.bind(emitter), - off: emitter.off.bind(emitter), - getSyncState: vi.fn().mockReturnValue(SyncState.Syncing), - getUserId: vi.fn().mockReturnValue("@user:localhost"), - getUser: vi.fn().mockReturnValue(null), - getDeviceId: vi.fn().mockReturnValue("DEVICE"), - credentials: { - userId: "@user:localhost", - }, - getCrypto: vi.fn().mockReturnValue(undefined), - getDomain: vi.fn().mockReturnValue("example.org"), - } as unknown as MatrixClient); - - const matrixRoom = mockMatrixRoom({ - roomId: "!myRoomId:example.com", - client, - getMember: vi.fn().mockReturnValue(undefined), - }); - - const fakeRtcSession = new MockRTCSession(matrixRoom).withMemberships( - rtcMemberships$, - ); - - mockConfig({}); - vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({}); - - const callVM = new CallViewModel( - testScope(), - fakeRtcSession.asMockedSession(), - matrixRoom, - mockMediaDevices({}), - mockMuteStates(), - { - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - autoLeaveWhenOthersLeft: false, - livekitRoomFactory: (): LivekitRoom => - mockLivekitRoom({ - localParticipant, - disconnect: async () => Promise.resolve(), - setE2EEEnabled: async () => Promise.resolve(), - }), - }, - new BehaviorSubject({} as Record), - new BehaviorSubject({} as Record), - constant({ processor: undefined, supported: false }), - ); - - const failPromise = Promise.withResolvers(); - callVM.configError$.subscribe((error) => { - if (error) { - failPromise.resolve(error); - } - }); - - const error = await failPromise.promise; - expect(error).toBeInstanceOf(MatrixRTCTransportMissingError); -}); - -test("participants are retained during a focus switch", () => { - withTestScheduler(({ behavior, expectObservable }) => { - // Participants disappear on frame 2 and come back on frame 3 - const participantInputMarbles = "a-ba"; - // Start switching focus on frame 1 and reconnect on frame 3 - const connectionInputMarbles = " cs-c"; - // The visible participants should remain the same throughout the switch - const expectedLayoutMarbles = " a"; - - withCallViewModel( - { - remoteParticipants$: behavior(participantInputMarbles, { - a: [aliceParticipant, bobParticipant], - b: [], - }), - rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), - livekitConnectionState$: behavior(connectionInputMarbles, { - c: ConnectionState.Connected, - s: ConnectionState.Connecting, - }), - }, - (vm) => { - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "grid", - spotlight: undefined, - grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], - }, - }, - ); +describe("CallViewModel", () => { + // TODO: Restore this test. It requires makeTransport to not be mocked, unlike + // the rest of the tests in this file… what do we do? + test.skip("test missing RTC config error", async () => { + const rtcMemberships$ = new BehaviorSubject([]); + const emitter = new EventEmitter(); + const client = vi.mocked({ + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + getSyncState: vi.fn().mockReturnValue(SyncState.Syncing), + getUserId: vi.fn().mockReturnValue("@user:localhost"), + getUser: vi.fn().mockReturnValue(null), + getDeviceId: vi.fn().mockReturnValue("DEVICE"), + credentials: { + userId: "@user:localhost", }, + getCrypto: vi.fn().mockReturnValue(undefined), + getDomain: vi.fn().mockReturnValue("example.org"), + } as unknown as MatrixClient); + + const matrixRoom = mockMatrixRoom({ + roomId: "!myRoomId:example.com", + client, + getMember: vi.fn().mockReturnValue(undefined), + }); + + const fakeRtcSession = new MockRTCSession(matrixRoom).withMemberships( + rtcMemberships$, ); - }); -}); -test("screen sharing activates spotlight layout", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { - // Start with no screen shares, then have Alice and Bob share their screens, - // then return to no screen shares, then have just Alice share for a bit - const participantInputMarbles = " abcda-ba"; - // While there are no screen shares, switch to spotlight manually, and then - // switch back to grid at the end - const modeInputMarbles = " -----s--g"; - // We should automatically enter spotlight for the first round of screen - // sharing, then return to grid, then manually go into spotlight, and - // remain in spotlight until we manually go back to grid - const expectedLayoutMarbles = " abcdaefeg"; - const expectedShowSpeakingMarbles = "y----nyny"; - withCallViewModel( + mockConfig({}); + + vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({}); + + const callVM = new CallViewModel( + testScope(), + fakeRtcSession.asMockedSession(), + matrixRoom, + mockMediaDevices({}), + mockMuteStates(), { - remoteParticipants$: behavior(participantInputMarbles, { - a: [aliceParticipant, bobParticipant], - b: [aliceSharingScreen, bobParticipant], - c: [aliceSharingScreen, bobSharingScreen], - d: [aliceParticipant, bobSharingScreen], - }), - rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), - }, - (vm) => { - schedule(modeInputMarbles, { - s: () => vm.setGridMode("spotlight"), - g: () => vm.setGridMode("grid"), - }); - - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "grid", - spotlight: undefined, - grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], - }, - b: { - type: "spotlight-landscape", - spotlight: [`${aliceId}:0:screen-share`], - grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], - }, - c: { - type: "spotlight-landscape", - spotlight: [ - `${aliceId}:0:screen-share`, - `${bobId}:0:screen-share`, - ], - grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], - }, - d: { - type: "spotlight-landscape", - spotlight: [`${bobId}:0:screen-share`], - grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], - }, - e: { - type: "spotlight-landscape", - spotlight: [`${aliceId}:0`], - grid: [`${localId}:0`, `${bobId}:0`], - }, - f: { - type: "spotlight-landscape", - spotlight: [`${aliceId}:0:screen-share`], - grid: [`${localId}:0`, `${bobId}:0`, `${aliceId}:0`], - }, - g: { - type: "grid", - spotlight: undefined, - grid: [`${localId}:0`, `${bobId}:0`, `${aliceId}:0`], - }, - }, - ); - expectObservable(vm.showSpeakingIndicators$).toBe( - expectedShowSpeakingMarbles, - yesNo, - ); - }, - ); - }); -}); - -test("participants stay in the same order unless to appear/disappear", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { - const visibilityInputMarbles = "a"; - // First Bob speaks, then Dave, then Alice - const aSpeakingInputMarbles = " n- 1998ms - 1999ms y"; - const bSpeakingInputMarbles = " ny 1998ms n 1999ms -"; - const dSpeakingInputMarbles = " n- 1998ms y 1999ms n"; - // Nothing should change when Bob speaks, because Bob is already on screen. - // When Dave speaks he should switch with Alice because she's the one who - // hasn't spoken at all. Then when Alice speaks, she should return to her - // place at the top. - const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a"; - - withCallViewModel( - { - remoteParticipants$: constant([ - aliceParticipant, - bobParticipant, - daveParticipant, - ]), - rtcMembers$: constant([ - localRtcMember, - aliceRtcMember, - bobRtcMember, - daveRtcMember, - ]), - speaking: new Map([ - [aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)], - [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], - [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], - ]), - }, - (vm) => { - schedule(visibilityInputMarbles, { - a: () => { - // We imagine that only three tiles (the first three) will be visible - // on screen at a time - vm.layout$.subscribe((layout) => { - if (layout.type === "grid") layout.setVisibleTiles(3); - }); - }, - }); - - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "grid", - spotlight: undefined, - grid: [ - `${localId}:0`, - `${aliceId}:0`, - `${bobId}:0`, - `${daveId}:0`, - ], - }, - b: { - type: "grid", - spotlight: undefined, - grid: [ - `${localId}:0`, - `${daveId}:0`, - `${bobId}:0`, - `${aliceId}:0`, - ], - }, - c: { - type: "grid", - spotlight: undefined, - grid: [ - `${localId}:0`, - `${aliceId}:0`, - `${daveId}:0`, - `${bobId}:0`, - ], - }, - }, - ); - }, - ); - }); -}); - -test("participants adjust order when space becomes constrained", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { - // Start with all tiles on screen then shrink to 3 - const visibilityInputMarbles = "a-b"; - // Bob and Dave speak - const bSpeakingInputMarbles = " ny"; - const dSpeakingInputMarbles = " ny"; - // Nothing should change when Bob or Dave initially speak, because they are - // on screen. When the screen becomes smaller Alice should move off screen - // to make way for the speakers (specifically, she should swap with Dave). - const expectedLayoutMarbles = " a-b"; - - withCallViewModel( - { - remoteParticipants$: constant([ - aliceParticipant, - bobParticipant, - daveParticipant, - ]), - rtcMembers$: constant([ - localRtcMember, - aliceRtcMember, - bobRtcMember, - daveRtcMember, - ]), - speaking: new Map([ - [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], - [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], - ]), - }, - (vm) => { - let setVisibleTiles: ((value: number) => void) | null = null; - vm.layout$.subscribe((layout) => { - if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles; - }); - schedule(visibilityInputMarbles, { - a: () => setVisibleTiles!(Infinity), - b: () => setVisibleTiles!(3), - }); - - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "grid", - spotlight: undefined, - grid: [ - `${localId}:0`, - `${aliceId}:0`, - `${bobId}:0`, - `${daveId}:0`, - ], - }, - b: { - type: "grid", - spotlight: undefined, - grid: [ - `${localId}:0`, - `${daveId}:0`, - `${bobId}:0`, - `${aliceId}:0`, - ], - }, - }, - ); - }, - ); - }); -}); - -test("spotlight speakers swap places", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { - // Go immediately into spotlight mode for the test - const modeInputMarbles = " s"; - // First Bob speaks, then Dave, then Alice - const aSpeakingInputMarbles = "n--y"; - const bSpeakingInputMarbles = "nyn"; - const dSpeakingInputMarbles = "n-yn"; - // Alice should start in the spotlight, then Bob, then Dave, then Alice - // again. However, the positions of Dave and Bob in the grid should be - // reversed by the end because they've been swapped in and out of the - // spotlight. - const expectedLayoutMarbles = "abcd"; - - withCallViewModel( - { - remoteParticipants$: constant([ - aliceParticipant, - bobParticipant, - daveParticipant, - ]), - rtcMembers$: constant([ - localRtcMember, - aliceRtcMember, - bobRtcMember, - daveRtcMember, - ]), - speaking: new Map([ - [aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)], - [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], - [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], - ]), - }, - (vm) => { - schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") }); - - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "spotlight-landscape", - spotlight: [`${aliceId}:0`], - grid: [`${localId}:0`, `${bobId}:0`, `${daveId}:0`], - }, - b: { - type: "spotlight-landscape", - spotlight: [`${bobId}:0`], - grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`], - }, - c: { - type: "spotlight-landscape", - spotlight: [`${daveId}:0`], - grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], - }, - d: { - type: "spotlight-landscape", - spotlight: [`${aliceId}:0`], - grid: [`${localId}:0`, `${daveId}:0`, `${bobId}:0`], - }, - }, - ); - - // While we expect the media on tiles to change, layout$ itself should - // *never* meaningfully change. That is, we expect there to be no layout - // shifts as the spotlight speaker changes; instead, the same tiles - // should be reused for the whole duration of the test and simply have - // their media swapped out. This is meaningful for keeping the interface - // not too visually distracting during back-and-forth conversations, - // while still animating tiles to express people joining, leaving, etc. - expectObservable( - vm.layout$.pipe( - distinctUntilChanged(deepCompare), - debounceTime(0), - map(() => "x"), - ), - ).toBe("x"); // Expect just one emission - }, - ); - }); -}); - -test("layout enters picture-in-picture mode when requested", () => { - withTestScheduler(({ schedule, expectObservable }) => { - // Enable then disable picture-in-picture - const pipControlInputMarbles = "-ed"; - // Should go into picture-in-picture layout then back to grid - const expectedLayoutMarbles = " aba"; - - withCallViewModel( - { - remoteParticipants$: constant([aliceParticipant, bobParticipant]), - rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), - }, - (vm) => { - schedule(pipControlInputMarbles, { - e: () => window.controls.enablePip(), - d: () => window.controls.disablePip(), - }); - - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "grid", - spotlight: undefined, - grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], - }, - b: { - type: "pip", - spotlight: [`${aliceId}:0`], - }, - }, - ); - }, - ); - }); -}); - -test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { - // Switch to spotlight immediately - const modeInputMarbles = " s"; - // And expand the spotlight immediately - const expandInputMarbles = " a"; - // First Bob speaks, then Dave, then Bob again - const bSpeakingInputMarbles = "n-yn--yn"; - const dSpeakingInputMarbles = "n---yn"; - // Should show Alice (presenter) in the PiP, then Bob, then Dave, then Bob - // again - const expectedLayoutMarbles = "a-b-c-b"; - - withCallViewModel( - { - remoteParticipants$: constant([ - aliceSharingScreen, - bobParticipant, - daveParticipant, - ]), - rtcMembers$: constant([ - localRtcMember, - aliceRtcMember, - bobRtcMember, - daveRtcMember, - ]), - speaking: new Map([ - [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], - [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], - ]), - }, - (vm) => { - schedule(modeInputMarbles, { - s: () => vm.setGridMode("spotlight"), - }); - schedule(expandInputMarbles, { - a: () => vm.toggleSpotlightExpanded$.value!(), - }); - - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "spotlight-expanded", - spotlight: [`${aliceId}:0:screen-share`], - pip: `${aliceId}:0`, - }, - b: { - type: "spotlight-expanded", - spotlight: [`${aliceId}:0:screen-share`], - pip: `${bobId}:0`, - }, - c: { - type: "spotlight-expanded", - spotlight: [`${aliceId}:0:screen-share`], - pip: `${daveId}:0`, - }, - }, - ); - - // While we expect the media on the PiP tile to change, layout$ itself - // should *never* meaningfully change. That is, we expect the same PiP - // tile to exist throughout the test and just have its media swapped out - // when the speaker changes, rather than for tiles to animate in/out. - // This is meaningful for keeping the interface not too visually - // distracting during back-and-forth conversations. - expectObservable( - vm.layout$.pipe( - distinctUntilChanged(deepCompare), - debounceTime(0), - map(() => "x"), - ), - ).toBe("x"); // Expect just one emission - }, - ); - }); -}); - -test("spotlight remembers whether it's expanded", () => { - withTestScheduler(({ schedule, expectObservable }) => { - // Start in spotlight mode, then switch to grid and back to spotlight a - // couple times - const modeInputMarbles = " s-gs-gs"; - // Expand and collapse the spotlight - const expandInputMarbles = " -a--a"; - // Spotlight should stay expanded during the first mode switch, and stay - // collapsed during the second mode switch - const expectedLayoutMarbles = "abcbada"; - - withCallViewModel( - { - remoteParticipants$: constant([aliceParticipant, bobParticipant]), - rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), - }, - (vm) => { - schedule(modeInputMarbles, { - s: () => vm.setGridMode("spotlight"), - g: () => vm.setGridMode("grid"), - }); - schedule(expandInputMarbles, { - a: () => vm.toggleSpotlightExpanded$.value!(), - }); - - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "spotlight-landscape", - spotlight: [`${aliceId}:0`], - grid: [`${localId}:0`, `${bobId}:0`], - }, - b: { - type: "spotlight-expanded", - spotlight: [`${aliceId}:0`], - pip: `${localId}:0`, - }, - c: { - type: "grid", - spotlight: undefined, - grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], - }, - d: { - type: "grid", - spotlight: undefined, - grid: [`${localId}:0`, `${bobId}:0`, `${aliceId}:0`], - }, - }, - ); - }, - ); - }); -}); - -test("participants must have a MatrixRTCSession to be visible", () => { - withTestScheduler(({ behavior, expectObservable }) => { - // iterate through a number of combinations of participants and MatrixRTC memberships - // Bob never has an MatrixRTC membership - const scenarioInputMarbles = " abcdec"; - // Bob should never be visible - const expectedLayoutMarbles = "a-bc-b"; - - withCallViewModel( - { - remoteParticipants$: behavior(scenarioInputMarbles, { - a: [], - b: [bobParticipant], - c: [aliceParticipant, bobParticipant], - d: [aliceParticipant, daveParticipant, bobParticipant], - e: [aliceParticipant, daveParticipant, bobSharingScreen], - }), - rtcMembers$: behavior(scenarioInputMarbles, { - a: [localRtcMember], - b: [localRtcMember], - c: [localRtcMember, aliceRtcMember], - d: [localRtcMember, aliceRtcMember, daveRtcMember], - e: [localRtcMember, aliceRtcMember, daveRtcMember], - }), - }, - (vm) => { - vm.setGridMode("grid"); - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "grid", - spotlight: undefined, - grid: [`${localId}:0`], - }, - b: { - type: "one-on-one", - local: `${localId}:0`, - remote: `${aliceId}:0`, - }, - c: { - type: "grid", - spotlight: undefined, - grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`], - }, - }, - ); - }, - ); - }); -}); - -it("should show at least one tile per MatrixRTCSession", () => { - withTestScheduler(({ behavior, expectObservable }) => { - // iterate through some combinations of MatrixRTC memberships - const scenarioInputMarbles = " abcd"; - // There should always be one tile for each MatrixRTCSession - const expectedLayoutMarbles = "abcd"; - - withCallViewModel( - { - rtcMembers$: behavior(scenarioInputMarbles, { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - c: [localRtcMember, aliceRtcMember, daveRtcMember], - d: [localRtcMember, daveRtcMember], - }), - }, - (vm) => { - vm.setGridMode("grid"); - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "grid", - spotlight: undefined, - grid: [`${localId}:0`], - }, - b: { - type: "one-on-one", - local: `${localId}:0`, - remote: `${aliceId}:0`, - }, - c: { - type: "grid", - spotlight: undefined, - grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`], - }, - d: { - type: "one-on-one", - local: `${localId}:0`, - remote: `${daveId}:0`, - }, - }, - ); - }, - ); - }); -}); - -it("should rank raised hands above video feeds and below speakers and presenters", () => { - withTestScheduler(({ schedule, expectObservable }) => { - // There should always be one tile for each MatrixRTCSession - const expectedLayoutMarbles = "ab"; - - withCallViewModel( - { - remoteParticipants$: constant([aliceParticipant, bobParticipant]), - rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), - }, - (vm, _rtcSession, { raisedHands$ }) => { - schedule("ab", { - a: () => { - // We imagine that only two tiles (the first two) will be visible on screen at a time - vm.layout$.subscribe((layout) => { - if (layout.type === "grid") { - layout.setVisibleTiles(2); - } - }); - }, - b: () => { - raisedHands$.next({ - [`${bobRtcMember.userId}:${bobRtcMember.deviceId}`]: { - time: new Date(), - reactionEventId: "", - membershipEventId: "", - }, - }); - }, - }); - expectObservable(summarizeLayout$(vm.layout$)).toBe( - expectedLayoutMarbles, - { - a: { - type: "grid", - spotlight: undefined, - grid: [ - `${localId}:0`, - "@alice:example.org:AAAA:0", - "@bob:example.org:BBBB:0", - ], - }, - b: { - type: "grid", - spotlight: undefined, - grid: [ - `${localId}:0`, - // Bob shifts up! - "@bob:example.org:BBBB:0", - "@alice:example.org:AAAA:0", - ], - }, - }, - ); - }, - ); - }); -}); - -function nooneEverThere$( - behavior: (marbles: string, values: Record) => Behavior, -): Behavior { - return behavior("a-b-c-d", { - a: [], // Start empty - b: [], // Alice joins - c: [], // Alice still there - d: [], // Alice leaves - }); -} - -function participantJoinLeave$( - behavior: ( - marbles: string, - values: Record, - ) => Behavior, -): Behavior { - return behavior("a-b-c-d", { - a: [], // Start empty - b: [aliceParticipant], // Alice joins - c: [aliceParticipant], // Alice still there - d: [], // Alice leaves - }); -} - -function rtcMemberJoinLeave$( - behavior: ( - marbles: string, - values: Record, - ) => Behavior, -): Behavior { - return behavior("a-b-c-d", { - a: [localRtcMember], // Start empty - b: [localRtcMember, aliceRtcMember], // Alice joins - c: [localRtcMember, aliceRtcMember], // Alice still there - d: [localRtcMember], // Alice leaves - }); -} - -test("autoLeave$ emits only when autoLeaveWhenOthersLeft option is enabled", () => { - withTestScheduler(({ behavior, expectObservable }) => { - withCallViewModel( - { - remoteParticipants$: participantJoinLeave$(behavior), - rtcMembers$: rtcMemberJoinLeave$(behavior), - }, - (vm) => { - expectObservable(vm.autoLeave$).toBe("------a", { - a: "allOthersLeft", - }); - }, - { - autoLeaveWhenOthersLeft: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); -}); - -test("autoLeave$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => { - withTestScheduler(({ behavior, expectObservable }) => { - withCallViewModel( - { - remoteParticipants$: nooneEverThere$(behavior), - rtcMembers$: nooneEverThere$(behavior), - }, - (vm) => { - expectObservable(vm.autoLeave$).toBe("-"); - }, - { - autoLeaveWhenOthersLeft: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); -}); - -test("autoLeave$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => { - withTestScheduler(({ behavior, expectObservable }) => { - withCallViewModel( - { - remoteParticipants$: participantJoinLeave$(behavior), - rtcMembers$: rtcMemberJoinLeave$(behavior), - }, - (vm) => { - expectObservable(vm.autoLeave$).toBe("-"); - }, - { autoLeaveWhenOthersLeft: false, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + livekitRoomFactory: (): LivekitRoom => + mockLivekitRoom({ + localParticipant, + disconnect: async () => Promise.resolve(), + setE2EEEnabled: async () => Promise.resolve(), + }), }, + new BehaviorSubject({} as Record), + new BehaviorSubject({} as Record), + constant({ processor: undefined, supported: false }), ); - }); -}); -test("autoLeave$ emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { - withTestScheduler(({ behavior, expectObservable }) => { - withCallViewModel( - { - remoteParticipants$: behavior("a-b-c-d", { - a: [], // Alone - b: [aliceParticipant], // Alice joins - c: [aliceParticipant], - d: [], // Local joins with a second device - }), - rtcMembers$: behavior("a-b-c-d", { - a: [localRtcMember], // Start empty - b: [localRtcMember, aliceRtcMember], // Alice joins - c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there - d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves - }), - }, - (vm) => { - expectObservable(vm.autoLeave$).toBe("------a", { - a: "allOthersLeft", - }); - }, - { - autoLeaveWhenOthersLeft: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); -}); - -describe("waitForCallPickup$", () => { - test("unknown -> ringing -> timeout when notified and nobody joins", () => { - withTestScheduler(({ schedule, expectObservable }) => { - // No one ever joins (only local user) - withCallViewModel( - { remoteParticipants$: constant([]) }, - (vm, rtcSession) => { - // Fire a call notification at 10ms with lifetime 30ms - schedule(" 10ms r", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif1", 30), - mockLegacyRingEvent, - ); - }, - }); - - expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms c", { - a: "unknown", - b: "ringing", - c: "timeout", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); + const failPromise = Promise.withResolvers(); + callVM.configError$.subscribe((error) => { + if (error) { + failPromise.resolve(error); + } }); + + const error = await failPromise.promise; + expect(error).toBeInstanceOf(MatrixRTCTransportMissingError); }); - test("regression test: does stop ringing in case livekitConnectionState$ emits after didSendCallNotification$ has already emitted", () => { - withTestScheduler(({ schedule, expectObservable, behavior }) => { + test("participants are retained during a focus switch", () => { + withTestScheduler(({ behavior, expectObservable }) => { + // Participants disappear on frame 2 and come back on frame 3 + const participantInputMarbles = "a-ba"; + // Start switching focus on frame 1 and reconnect on frame 3 + const connectionInputMarbles = " cs-c"; + // The visible participants should remain the same throughout the switch + const expectedLayoutMarbles = " a"; + withCallViewModel( { - livekitConnectionState$: behavior("d 9ms c", { - d: ConnectionState.Disconnected, + remoteParticipants$: behavior(participantInputMarbles, { + a: [aliceParticipant, bobParticipant], + b: [], + }), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + livekitConnectionState$: behavior(connectionInputMarbles, { c: ConnectionState.Connected, + s: ConnectionState.Connecting, }), }, - (vm, rtcSession) => { - // Fire a call notification IMMEDIATELY (its important for this test, that this happens before the livekitConnectionState$ emits) - schedule("n", { - n: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif1", 30), - mockLegacyRingEvent, - ); + (vm) => { + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, }, - }); - - expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", { - a: "unknown", - b: "ringing", - c: "timeout", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + ); }, ); }); }); - test("ringing -> success if someone joins before timeout", () => { + test("screen sharing activates spotlight layout", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { - // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) + // Start with no screen shares, then have Alice and Bob share their screens, + // then return to no screen shares, then have just Alice share for a bit + const participantInputMarbles = " abcda-ba"; + // While there are no screen shares, switch to spotlight manually, and then + // switch back to grid at the end + const modeInputMarbles = " -----s--g"; + // We should automatically enter spotlight for the first round of screen + // sharing, then return to grid, then manually go into spotlight, and + // remain in spotlight until we manually go back to grid + const expectedLayoutMarbles = " abcdaefeg"; + const expectedShowSpeakingMarbles = "y----nyny"; withCallViewModel( { - remoteParticipants$: behavior("a 19ms b", { - a: [], - b: [aliceParticipant], + remoteParticipants$: behavior(participantInputMarbles, { + a: [aliceParticipant, bobParticipant], + b: [aliceSharingScreen, bobParticipant], + c: [aliceSharingScreen, bobSharingScreen], + d: [aliceParticipant, bobSharingScreen], }), - rtcMembers$: behavior("a 19ms b", { + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + }, + (vm) => { + schedule(modeInputMarbles, { + s: () => vm.setGridMode("spotlight"), + g: () => vm.setGridMode("grid"), + }); + + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + b: { + type: "spotlight-landscape", + spotlight: [`${aliceId}:0:screen-share`], + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + c: { + type: "spotlight-landscape", + spotlight: [ + `${aliceId}:0:screen-share`, + `${bobId}:0:screen-share`, + ], + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + d: { + type: "spotlight-landscape", + spotlight: [`${bobId}:0:screen-share`], + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + e: { + type: "spotlight-landscape", + spotlight: [`${aliceId}:0`], + grid: [`${localId}:0`, `${bobId}:0`], + }, + f: { + type: "spotlight-landscape", + spotlight: [`${aliceId}:0:screen-share`], + grid: [`${localId}:0`, `${bobId}:0`, `${aliceId}:0`], + }, + g: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${bobId}:0`, `${aliceId}:0`], + }, + }, + ); + expectObservable(vm.showSpeakingIndicators$).toBe( + expectedShowSpeakingMarbles, + yesNo, + ); + }, + ); + }); + }); + + test("participants stay in the same order unless to appear/disappear", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + const visibilityInputMarbles = "a"; + // First Bob speaks, then Dave, then Alice + const aSpeakingInputMarbles = " n- 1998ms - 1999ms y"; + const bSpeakingInputMarbles = " ny 1998ms n 1999ms -"; + const dSpeakingInputMarbles = " n- 1998ms y 1999ms n"; + // Nothing should change when Bob speaks, because Bob is already on screen. + // When Dave speaks he should switch with Alice because she's the one who + // hasn't spoken at all. Then when Alice speaks, she should return to her + // place at the top. + const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a"; + + withCallViewModel( + { + remoteParticipants$: constant([ + aliceParticipant, + bobParticipant, + daveParticipant, + ]), + rtcMembers$: constant([ + localRtcMember, + aliceRtcMember, + bobRtcMember, + daveRtcMember, + ]), + speaking: new Map([ + [aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)], + [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], + [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], + ]), + }, + (vm) => { + schedule(visibilityInputMarbles, { + a: () => { + // We imagine that only three tiles (the first three) will be visible + // on screen at a time + vm.layout$.subscribe((layout) => { + if (layout.type === "grid") layout.setVisibleTiles(3); + }); + }, + }); + + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [ + `${localId}:0`, + `${aliceId}:0`, + `${bobId}:0`, + `${daveId}:0`, + ], + }, + b: { + type: "grid", + spotlight: undefined, + grid: [ + `${localId}:0`, + `${daveId}:0`, + `${bobId}:0`, + `${aliceId}:0`, + ], + }, + c: { + type: "grid", + spotlight: undefined, + grid: [ + `${localId}:0`, + `${aliceId}:0`, + `${daveId}:0`, + `${bobId}:0`, + ], + }, + }, + ); + }, + ); + }); + }); + + test("participants adjust order when space becomes constrained", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + // Start with all tiles on screen then shrink to 3 + const visibilityInputMarbles = "a-b"; + // Bob and Dave speak + const bSpeakingInputMarbles = " ny"; + const dSpeakingInputMarbles = " ny"; + // Nothing should change when Bob or Dave initially speak, because they are + // on screen. When the screen becomes smaller Alice should move off screen + // to make way for the speakers (specifically, she should swap with Dave). + const expectedLayoutMarbles = " a-b"; + + withCallViewModel( + { + remoteParticipants$: constant([ + aliceParticipant, + bobParticipant, + daveParticipant, + ]), + rtcMembers$: constant([ + localRtcMember, + aliceRtcMember, + bobRtcMember, + daveRtcMember, + ]), + speaking: new Map([ + [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], + [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], + ]), + }, + (vm) => { + let setVisibleTiles: ((value: number) => void) | null = null; + vm.layout$.subscribe((layout) => { + if (layout.type === "grid") + setVisibleTiles = layout.setVisibleTiles; + }); + schedule(visibilityInputMarbles, { + a: () => setVisibleTiles!(Infinity), + b: () => setVisibleTiles!(3), + }); + + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [ + `${localId}:0`, + `${aliceId}:0`, + `${bobId}:0`, + `${daveId}:0`, + ], + }, + b: { + type: "grid", + spotlight: undefined, + grid: [ + `${localId}:0`, + `${daveId}:0`, + `${bobId}:0`, + `${aliceId}:0`, + ], + }, + }, + ); + }, + ); + }); + }); + + test("spotlight speakers swap places", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + // Go immediately into spotlight mode for the test + const modeInputMarbles = " s"; + // First Bob speaks, then Dave, then Alice + const aSpeakingInputMarbles = "n--y"; + const bSpeakingInputMarbles = "nyn"; + const dSpeakingInputMarbles = "n-yn"; + // Alice should start in the spotlight, then Bob, then Dave, then Alice + // again. However, the positions of Dave and Bob in the grid should be + // reversed by the end because they've been swapped in and out of the + // spotlight. + const expectedLayoutMarbles = "abcd"; + + withCallViewModel( + { + remoteParticipants$: constant([ + aliceParticipant, + bobParticipant, + daveParticipant, + ]), + rtcMembers$: constant([ + localRtcMember, + aliceRtcMember, + bobRtcMember, + daveRtcMember, + ]), + speaking: new Map([ + [aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)], + [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], + [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], + ]), + }, + (vm) => { + schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") }); + + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "spotlight-landscape", + spotlight: [`${aliceId}:0`], + grid: [`${localId}:0`, `${bobId}:0`, `${daveId}:0`], + }, + b: { + type: "spotlight-landscape", + spotlight: [`${bobId}:0`], + grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`], + }, + c: { + type: "spotlight-landscape", + spotlight: [`${daveId}:0`], + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + d: { + type: "spotlight-landscape", + spotlight: [`${aliceId}:0`], + grid: [`${localId}:0`, `${daveId}:0`, `${bobId}:0`], + }, + }, + ); + + // While we expect the media on tiles to change, layout$ itself should + // *never* meaningfully change. That is, we expect there to be no layout + // shifts as the spotlight speaker changes; instead, the same tiles + // should be reused for the whole duration of the test and simply have + // their media swapped out. This is meaningful for keeping the interface + // not too visually distracting during back-and-forth conversations, + // while still animating tiles to express people joining, leaving, etc. + expectObservable( + vm.layout$.pipe( + distinctUntilChanged(deepCompare), + debounceTime(0), + map(() => "x"), + ), + ).toBe("x"); // Expect just one emission + }, + ); + }); + }); + + test("layout enters picture-in-picture mode when requested", () => { + withTestScheduler(({ schedule, expectObservable }) => { + // Enable then disable picture-in-picture + const pipControlInputMarbles = "-ed"; + // Should go into picture-in-picture layout then back to grid + const expectedLayoutMarbles = " aba"; + + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant, bobParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + }, + (vm) => { + schedule(pipControlInputMarbles, { + e: () => window.controls.enablePip(), + d: () => window.controls.disablePip(), + }); + + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + b: { + type: "pip", + spotlight: [`${aliceId}:0`], + }, + }, + ); + }, + ); + }); + }); + + test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + // Switch to spotlight immediately + const modeInputMarbles = " s"; + // And expand the spotlight immediately + const expandInputMarbles = " a"; + // First Bob speaks, then Dave, then Bob again + const bSpeakingInputMarbles = "n-yn--yn"; + const dSpeakingInputMarbles = "n---yn"; + // Should show Alice (presenter) in the PiP, then Bob, then Dave, then Bob + // again + const expectedLayoutMarbles = "a-b-c-b"; + + withCallViewModel( + { + remoteParticipants$: constant([ + aliceSharingScreen, + bobParticipant, + daveParticipant, + ]), + rtcMembers$: constant([ + localRtcMember, + aliceRtcMember, + bobRtcMember, + daveRtcMember, + ]), + speaking: new Map([ + [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], + [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], + ]), + }, + (vm) => { + schedule(modeInputMarbles, { + s: () => vm.setGridMode("spotlight"), + }); + schedule(expandInputMarbles, { + a: () => vm.toggleSpotlightExpanded$.value!(), + }); + + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "spotlight-expanded", + spotlight: [`${aliceId}:0:screen-share`], + pip: `${aliceId}:0`, + }, + b: { + type: "spotlight-expanded", + spotlight: [`${aliceId}:0:screen-share`], + pip: `${bobId}:0`, + }, + c: { + type: "spotlight-expanded", + spotlight: [`${aliceId}:0:screen-share`], + pip: `${daveId}:0`, + }, + }, + ); + + // While we expect the media on the PiP tile to change, layout$ itself + // should *never* meaningfully change. That is, we expect the same PiP + // tile to exist throughout the test and just have its media swapped out + // when the speaker changes, rather than for tiles to animate in/out. + // This is meaningful for keeping the interface not too visually + // distracting during back-and-forth conversations. + expectObservable( + vm.layout$.pipe( + distinctUntilChanged(deepCompare), + debounceTime(0), + map(() => "x"), + ), + ).toBe("x"); // Expect just one emission + }, + ); + }); + }); + + test("spotlight remembers whether it's expanded", () => { + withTestScheduler(({ schedule, expectObservable }) => { + // Start in spotlight mode, then switch to grid and back to spotlight a + // couple times + const modeInputMarbles = " s-gs-gs"; + // Expand and collapse the spotlight + const expandInputMarbles = " -a--a"; + // Spotlight should stay expanded during the first mode switch, and stay + // collapsed during the second mode switch + const expectedLayoutMarbles = "abcbada"; + + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant, bobParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + }, + (vm) => { + schedule(modeInputMarbles, { + s: () => vm.setGridMode("spotlight"), + g: () => vm.setGridMode("grid"), + }); + schedule(expandInputMarbles, { + a: () => vm.toggleSpotlightExpanded$.value!(), + }); + + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "spotlight-landscape", + spotlight: [`${aliceId}:0`], + grid: [`${localId}:0`, `${bobId}:0`], + }, + b: { + type: "spotlight-expanded", + spotlight: [`${aliceId}:0`], + pip: `${localId}:0`, + }, + c: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + d: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${bobId}:0`, `${aliceId}:0`], + }, + }, + ); + }, + ); + }); + }); + + test("participants must have a MatrixRTCSession to be visible", () => { + withTestScheduler(({ behavior, expectObservable }) => { + // iterate through a number of combinations of participants and MatrixRTC memberships + // Bob never has an MatrixRTC membership + const scenarioInputMarbles = " abcdec"; + // Bob should never be visible + const expectedLayoutMarbles = "a-bc-b"; + + withCallViewModel( + { + remoteParticipants$: behavior(scenarioInputMarbles, { + a: [], + b: [bobParticipant], + c: [aliceParticipant, bobParticipant], + d: [aliceParticipant, daveParticipant, bobParticipant], + e: [aliceParticipant, daveParticipant, bobSharingScreen], + }), + rtcMembers$: behavior(scenarioInputMarbles, { + a: [localRtcMember], + b: [localRtcMember], + c: [localRtcMember, aliceRtcMember], + d: [localRtcMember, aliceRtcMember, daveRtcMember], + e: [localRtcMember, aliceRtcMember, daveRtcMember], + }), + }, + (vm) => { + vm.setGridMode("grid"); + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`], + }, + b: { + type: "one-on-one", + local: `${localId}:0`, + remote: `${aliceId}:0`, + }, + c: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`], + }, + }, + ); + }, + ); + }); + }); + + it("should show at least one tile per MatrixRTCSession", () => { + withTestScheduler(({ behavior, expectObservable }) => { + // iterate through some combinations of MatrixRTC memberships + const scenarioInputMarbles = " abcd"; + // There should always be one tile for each MatrixRTCSession + const expectedLayoutMarbles = "abcd"; + + withCallViewModel( + { + rtcMembers$: behavior(scenarioInputMarbles, { a: [localRtcMember], b: [localRtcMember, aliceRtcMember], + c: [localRtcMember, aliceRtcMember, daveRtcMember], + d: [localRtcMember, daveRtcMember], }), }, - (vm, rtcSession) => { - // Notify at 5ms so we enter ringing, then success at 20ms - schedule(" 5ms r", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif2", 100), - mockLegacyRingEvent, - ); + (vm) => { + vm.setGridMode("grid"); + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`], + }, + b: { + type: "one-on-one", + local: `${localId}:0`, + remote: `${aliceId}:0`, + }, + c: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`], + }, + d: { + type: "one-on-one", + local: `${localId}:0`, + remote: `${daveId}:0`, + }, }, - }); - - expectObservable(vm.callPickupState$).toBe("a 4ms b 14ms c", { - a: "unknown", - b: "ringing", - c: "success", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + ); }, ); }); }); - test("ringing -> unknown if we get disconnected", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { - const connectionState$ = new BehaviorSubject(ConnectionState.Connected); - // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) - withCallViewModel( - { - remoteParticipants$: behavior("a 19ms b", { - a: [], - b: [aliceParticipant], - }), - rtcMembers$: behavior("a 19ms b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - livekitConnectionState$: connectionState$, - }, - (vm, rtcSession) => { - // Notify at 5ms so we enter ringing, then get disconnected 5ms later - schedule(" 5ms r 5ms d", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif2", 100), - mockLegacyRingEvent, - ); - }, - d: () => { - connectionState$.next(ConnectionState.Disconnected); - }, - }); - - expectObservable(vm.callPickupState$).toBe("a 4ms b 5ms c", { - a: "unknown", - b: "ringing", - c: "unknown", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); - }); - - test("success when someone joins before we notify", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { - // Join at 10ms, notify later at 20ms (state should stay success) - withCallViewModel( - { - remoteParticipants$: behavior("a 9ms b", { - a: [], - b: [aliceParticipant], - }), - rtcMembers$: behavior("a 9ms b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - }, - (vm, rtcSession) => { - schedule(" 20ms r", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif3", 50), - mockLegacyRingEvent, - ); - }, - }); - expectObservable(vm.callPickupState$).toBe("a 9ms b", { - a: "unknown", - b: "success", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); - }); - - test("notify without lifetime -> immediate timeout", () => { + it("should rank raised hands above video feeds and below speakers and presenters", () => { withTestScheduler(({ schedule, expectObservable }) => { + // There should always be one tile for each MatrixRTCSession + const expectedLayoutMarbles = "ab"; + withCallViewModel( - {}, - (vm, rtcSession) => { - schedule(" 10ms r", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif4", undefined), - mockLegacyRingEvent, - ); + { + remoteParticipants$: constant([aliceParticipant, bobParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + }, + (vm, _rtcSession, { raisedHands$ }) => { + schedule("ab", { + a: () => { + // We imagine that only two tiles (the first two) will be visible on screen at a time + vm.layout$.subscribe((layout) => { + if (layout.type === "grid") { + layout.setVisibleTiles(2); + } + }); + }, + b: () => { + raisedHands$.next({ + [`${bobRtcMember.userId}:${bobRtcMember.deviceId}`]: { + time: new Date(), + reactionEventId: "", + membershipEventId: "", + }, + }); }, }); - expectObservable(vm.callPickupState$).toBe("a 9ms b", { - a: "unknown", - b: "timeout", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [ + `${localId}:0`, + "@alice:example.org:AAAA:0", + "@bob:example.org:BBBB:0", + ], + }, + b: { + type: "grid", + spotlight: undefined, + grid: [ + `${localId}:0`, + // Bob shifts up! + "@bob:example.org:BBBB:0", + "@alice:example.org:AAAA:0", + ], + }, + }, + ); }, ); }); }); - test("stays null when waitForCallPickup=false", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { - withCallViewModel( - { - remoteParticipants$: behavior("a--b", { - a: [], - b: [aliceParticipant], - }), - rtcMembers$: behavior("a--b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - }, - (vm, rtcSession) => { - schedule(" 5ms r", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif5", 30), - mockLegacyRingEvent, - ); - }, - }); - expectObservable(vm.callPickupState$).toBe("(n)", { - n: null, - }); - }, - { - waitForCallPickup: false, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); - }); - - test("decline before timeout window ends -> decline", () => { - withTestScheduler(({ schedule, expectObservable }) => { - withCallViewModel( - {}, - (vm, rtcSession) => { - // Notify at 10ms with 50ms lifetime, decline at 40ms with matching id - schedule(" 10ms r 29ms d", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$decl1", 50), - mockLegacyRingEvent, - ); - }, - d: () => { - // Emit decline timeline event with id matching the notification - rtcSession.room.emit( - MatrixRoomEvent.Timeline, - new MatrixEvent({ - type: EventType.RTCDecline, - content: { - "m.relates_to": { - rel_type: "m.reference", - event_id: "$decl1", - }, - }, - }), - rtcSession.room, - undefined, - false, - {} as IRoomTimelineData, - ); - }, - }); - expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms e", { - a: "unknown", - b: "ringing", - e: "decline", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); - }); - - test("decline after timeout window ends -> stays timeout", () => { - withTestScheduler(({ schedule, expectObservable }) => { - withCallViewModel( - {}, - (vm, rtcSession) => { - // Notify at 10ms with 20ms lifetime (timeout at 30ms), decline at 40ms - schedule(" 10ms r 20ms t 10ms d", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$decl2", 20), - mockLegacyRingEvent, - ); - }, - t: () => {}, - d: () => { - rtcSession.room.emit( - MatrixRoomEvent.Timeline, - new MatrixEvent({ event_id: "$decl2", type: "m.rtc.decline" }), - rtcSession.room, - undefined, - false, - {} as IRoomTimelineData, - ); - }, - }); - expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", { - a: "unknown", - b: "ringing", - c: "timeout", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); - }); - - function testStaysRinging(declineEvent: Partial): void { - withTestScheduler(({ schedule, expectObservable }) => { - withCallViewModel( - {}, - (vm, rtcSession) => { - // Notify at 10ms with id A, decline arrives at 20ms with id B - schedule(" 10ms r 10ms d", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$right", 50), - mockLegacyRingEvent, - ); - }, - d: () => { - rtcSession.room.emit( - MatrixRoomEvent.Timeline, - new MatrixEvent(declineEvent), - rtcSession.room, - undefined, - false, - {} as IRoomTimelineData, - ); - }, - }); - // We assert up to 21ms to see the ringing at 10ms and no change at 20ms - expectObservable(vm.callPickupState$, "21ms !").toBe("a 9ms b", { - a: "unknown", - b: "ringing", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); + function nooneEverThere$( + behavior: (marbles: string, values: Record) => Behavior, + ): Behavior { + return behavior("a-b-c-d", { + a: [], // Start empty + b: [], // Alice joins + c: [], // Alice still there + d: [], // Alice leaves }); } - test("decline with wrong id is ignored (stays ringing)", () => { - testStaysRinging({ - event_id: "$wrong", - type: "m.rtc.decline", - sender: local.userId, + function participantJoinLeave$( + behavior: ( + marbles: string, + values: Record, + ) => Behavior, + ): Behavior { + return behavior("a-b-c-d", { + a: [], // Start empty + b: [aliceParticipant], // Alice joins + c: [aliceParticipant], // Alice still there + d: [], // Alice leaves }); - }); + } - test("decline with sender being the local user is ignored (stays ringing)", () => { - testStaysRinging({ - event_id: "$right", - type: "m.rtc.decline", - sender: alice.userId, + function rtcMemberJoinLeave$( + behavior: ( + marbles: string, + values: Record, + ) => Behavior, + ): Behavior { + return behavior("a-b-c-d", { + a: [localRtcMember], // Start empty + b: [localRtcMember, aliceRtcMember], // Alice joins + c: [localRtcMember, aliceRtcMember], // Alice still there + d: [localRtcMember], // Alice leaves }); - }); -}); + } -test("audio output changes when toggling earpiece mode", () => { - withTestScheduler(({ schedule, expectObservable }) => { - getUrlParams.mockReturnValue({ controlledAudioDevices: true }); - vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(of([])); - - const devices = new MediaDevices(testScope()); - - window.controls.setAvailableAudioDevices([ - { id: "speaker", name: "Speaker", isSpeaker: true }, - { id: "earpiece", name: "Handset", isEarpiece: true }, - { id: "headphones", name: "Headphones" }, - ]); - window.controls.setAudioDevice("headphones"); - - const toggleInputMarbles = " -aaa"; - const expectedEarpieceModeMarbles = "n-yn"; - const expectedTargetStateMarbles = " sese"; - - withCallViewModel({ mediaDevices: devices }, (vm) => { - schedule(toggleInputMarbles, { - a: () => getValue(vm.audioOutputSwitcher$)?.switch(), - }); - expectObservable(vm.earpieceMode$).toBe( - expectedEarpieceModeMarbles, - yesNo, - ); - expectObservable( - vm.audioOutputSwitcher$.pipe(map((switcher) => switcher?.targetOutput)), - ).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" }); - }); - }); -}); - -test("media tracks are paused while reconnecting to MatrixRTC", () => { - withTestScheduler(({ schedule, expectObservable }) => { - const trackRunning$ = new BehaviorSubject(true); - const originalPublications = localParticipant.trackPublications; - localParticipant.trackPublications = new Map([ - [ - "video", + test("autoLeave$ emits only when autoLeaveWhenOthersLeft option is enabled", () => { + withTestScheduler(({ behavior, expectObservable }) => { + withCallViewModel( { - track: new (class { - public get isUpstreamPaused(): boolean { - return !trackRunning$.value; - } - public async pauseUpstream(): Promise { - trackRunning$.next(false); - return Promise.resolve(); - } - public async resumeUpstream(): Promise { - trackRunning$.next(true); - return Promise.resolve(); - } - })(), - } as unknown as LocalTrackPublication, - ], - ]); - onTestFinished(() => { - localParticipant.trackPublications = originalPublications; + remoteParticipants$: participantJoinLeave$(behavior), + rtcMembers$: rtcMemberJoinLeave$(behavior), + }, + (vm) => { + expectObservable(vm.autoLeave$).toBe("------a", { + a: "allOthersLeft", + }); + }, + { + autoLeaveWhenOthersLeft: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("autoLeave$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => { + withTestScheduler(({ behavior, expectObservable }) => { + withCallViewModel( + { + remoteParticipants$: nooneEverThere$(behavior), + rtcMembers$: nooneEverThere$(behavior), + }, + (vm) => { + expectObservable(vm.autoLeave$).toBe("-"); + }, + { + autoLeaveWhenOthersLeft: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("autoLeave$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => { + withTestScheduler(({ behavior, expectObservable }) => { + withCallViewModel( + { + remoteParticipants$: participantJoinLeave$(behavior), + rtcMembers$: rtcMemberJoinLeave$(behavior), + }, + (vm) => { + expectObservable(vm.autoLeave$).toBe("-"); + }, + { + autoLeaveWhenOthersLeft: false, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("autoLeave$ emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { + withTestScheduler(({ behavior, expectObservable }) => { + withCallViewModel( + { + remoteParticipants$: behavior("a-b-c-d", { + a: [], // Alone + b: [aliceParticipant], // Alice joins + c: [aliceParticipant], + d: [], // Local joins with a second device + }), + rtcMembers$: behavior("a-b-c-d", { + a: [localRtcMember], // Start empty + b: [localRtcMember, aliceRtcMember], // Alice joins + c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there + d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves + }), + }, + (vm) => { + expectObservable(vm.autoLeave$).toBe("------a", { + a: "allOthersLeft", + }); + }, + { + autoLeaveWhenOthersLeft: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + describe("waitForCallPickup$", () => { + test("unknown -> ringing -> timeout when notified and nobody joins", () => { + withTestScheduler(({ schedule, expectObservable }) => { + // No one ever joins (only local user) + withCallViewModel( + { remoteParticipants$: constant([]) }, + (vm, rtcSession) => { + // Fire a call notification at 10ms with lifetime 30ms + schedule(" 10ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif1", 30), + mockLegacyRingEvent, + ); + }, + }); + + expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms c", { + a: "unknown", + b: "ringing", + c: "timeout", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); }); - // There are three indicators that the client might be disconnected from - // MatrixRTC: whether the sync loop is connected, whether the membership is - // present in local room state, and whether the membership manager thinks - // we've hit the timeout for the delayed leave event. Let's test all - // combinations of these conditions. - const syncingMarbles = " nyny----n--y"; - const membershipStatusMarbles = " y---ny-n-yn-y"; - const probablyLeftMarbles = " n-----y-ny---n"; - const expectedReconnectingMarbles = "n-ynyny------n"; - const expectedTrackRunningMarbles = "nynynyn------y"; + test("regression test: does stop ringing in case livekitConnectionState$ emits after didSendCallNotification$ has already emitted", () => { + withTestScheduler(({ schedule, expectObservable, behavior }) => { + withCallViewModel( + { + livekitConnectionState$: behavior("d 9ms c", { + d: ConnectionState.Disconnected, + c: ConnectionState.Connected, + }), + }, + (vm, rtcSession) => { + // Fire a call notification IMMEDIATELY (its important for this test, that this happens before the livekitConnectionState$ emits) + schedule("n", { + n: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif1", 30), + mockLegacyRingEvent, + ); + }, + }); - withCallViewModel( - { initialSyncState: SyncState.Reconnecting }, - (vm, rtcSession, _subjects, setSyncState) => { - schedule(syncingMarbles, { - y: () => setSyncState(SyncState.Syncing), - n: () => setSyncState(SyncState.Reconnecting), + expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", { + a: "unknown", + b: "ringing", + c: "timeout", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("ringing -> success if someone joins before timeout", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) + withCallViewModel( + { + remoteParticipants$: behavior("a 19ms b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a 19ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + }, + (vm, rtcSession) => { + // Notify at 5ms so we enter ringing, then success at 20ms + schedule(" 5ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif2", 100), + mockLegacyRingEvent, + ); + }, + }); + + expectObservable(vm.callPickupState$).toBe("a 4ms b 14ms c", { + a: "unknown", + b: "ringing", + c: "success", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("ringing -> unknown if we get disconnected", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + const connectionState$ = new BehaviorSubject(ConnectionState.Connected); + // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) + withCallViewModel( + { + remoteParticipants$: behavior("a 19ms b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a 19ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + livekitConnectionState$: connectionState$, + }, + (vm, rtcSession) => { + // Notify at 5ms so we enter ringing, then get disconnected 5ms later + schedule(" 5ms r 5ms d", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif2", 100), + mockLegacyRingEvent, + ); + }, + d: () => { + connectionState$.next(ConnectionState.Disconnected); + }, + }); + + expectObservable(vm.callPickupState$).toBe("a 4ms b 5ms c", { + a: "unknown", + b: "ringing", + c: "unknown", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("success when someone joins before we notify", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + // Join at 10ms, notify later at 20ms (state should stay success) + withCallViewModel( + { + remoteParticipants$: behavior("a 9ms b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a 9ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + }, + (vm, rtcSession) => { + schedule(" 20ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif3", 50), + mockLegacyRingEvent, + ); + }, + }); + expectObservable(vm.callPickupState$).toBe("a 9ms b", { + a: "unknown", + b: "success", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("notify without lifetime -> immediate timeout", () => { + withTestScheduler(({ schedule, expectObservable }) => { + withCallViewModel( + {}, + (vm, rtcSession) => { + schedule(" 10ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif4", undefined), + mockLegacyRingEvent, + ); + }, + }); + expectObservable(vm.callPickupState$).toBe("a 9ms b", { + a: "unknown", + b: "timeout", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("stays null when waitForCallPickup=false", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + withCallViewModel( + { + remoteParticipants$: behavior("a--b", { + a: [], + b: [aliceParticipant], + }), + rtcMembers$: behavior("a--b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + }, + (vm, rtcSession) => { + schedule(" 5ms r", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif5", 30), + mockLegacyRingEvent, + ); + }, + }); + expectObservable(vm.callPickupState$).toBe("(n)", { + n: null, + }); + }, + { + waitForCallPickup: false, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("decline before timeout window ends -> decline", () => { + withTestScheduler(({ schedule, expectObservable }) => { + withCallViewModel( + {}, + (vm, rtcSession) => { + // Notify at 10ms with 50ms lifetime, decline at 40ms with matching id + schedule(" 10ms r 29ms d", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$decl1", 50), + mockLegacyRingEvent, + ); + }, + d: () => { + // Emit decline timeline event with id matching the notification + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + type: EventType.RTCDecline, + content: { + "m.relates_to": { + rel_type: "m.reference", + event_id: "$decl1", + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms e", { + a: "unknown", + b: "ringing", + e: "decline", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("decline after timeout window ends -> stays timeout", () => { + withTestScheduler(({ schedule, expectObservable }) => { + withCallViewModel( + {}, + (vm, rtcSession) => { + // Notify at 10ms with 20ms lifetime (timeout at 30ms), decline at 40ms + schedule(" 10ms r 20ms t 10ms d", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$decl2", 20), + mockLegacyRingEvent, + ); + }, + t: () => {}, + d: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + event_id: "$decl2", + type: "m.rtc.decline", + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", { + a: "unknown", + b: "ringing", + c: "timeout", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + function testStaysRinging(declineEvent: Partial): void { + withTestScheduler(({ schedule, expectObservable }) => { + withCallViewModel( + {}, + (vm, rtcSession) => { + // Notify at 10ms with id A, decline arrives at 20ms with id B + schedule(" 10ms r 10ms d", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$right", 50), + mockLegacyRingEvent, + ); + }, + d: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent(declineEvent), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + // We assert up to 21ms to see the ringing at 10ms and no change at 20ms + expectObservable(vm.callPickupState$, "21ms !").toBe("a 9ms b", { + a: "unknown", + b: "ringing", + }); + }, + { + waitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + } + + test("decline with wrong id is ignored (stays ringing)", () => { + testStaysRinging({ + event_id: "$wrong", + type: "m.rtc.decline", + sender: local.userId, + }); + }); + + test("decline with sender being the local user is ignored (stays ringing)", () => { + testStaysRinging({ + event_id: "$right", + type: "m.rtc.decline", + sender: alice.userId, + }); + }); + }); + + test("audio output changes when toggling earpiece mode", () => { + withTestScheduler(({ schedule, expectObservable }) => { + getUrlParams.mockReturnValue({ controlledAudioDevices: true }); + vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( + of([]), + ); + + const devices = new MediaDevices(testScope()); + + window.controls.setAvailableAudioDevices([ + { id: "speaker", name: "Speaker", isSpeaker: true }, + { id: "earpiece", name: "Handset", isEarpiece: true }, + { id: "headphones", name: "Headphones" }, + ]); + window.controls.setAudioDevice("headphones"); + + const toggleInputMarbles = " -aaa"; + const expectedEarpieceModeMarbles = "n-yn"; + const expectedTargetStateMarbles = " sese"; + + withCallViewModel({ mediaDevices: devices }, (vm) => { + schedule(toggleInputMarbles, { + a: () => getValue(vm.audioOutputSwitcher$)?.switch(), }); - schedule(membershipStatusMarbles, { - y: () => { - rtcSession.membershipStatus = Status.Connected; - }, - n: () => { - rtcSession.membershipStatus = Status.Reconnecting; - }, - }); - schedule(probablyLeftMarbles, { - y: () => { - rtcSession.probablyLeft = true; - }, - n: () => { - rtcSession.probablyLeft = false; - }, - }); - expectObservable(vm.reconnecting$).toBe( - expectedReconnectingMarbles, + expectObservable(vm.earpieceMode$).toBe( + expectedEarpieceModeMarbles, yesNo, ); - expectObservable(trackRunning$).toBe( - expectedTrackRunningMarbles, - yesNo, - ); - }, - ); + expectObservable( + vm.audioOutputSwitcher$.pipe( + map((switcher) => switcher?.targetOutput), + ), + ).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" }); + }); + }); + }); + + test("media tracks are paused while reconnecting to MatrixRTC", () => { + withTestScheduler(({ schedule, expectObservable }) => { + const trackRunning$ = new BehaviorSubject(true); + const originalPublications = localParticipant.trackPublications; + localParticipant.trackPublications = new Map([ + [ + "video", + { + track: new (class { + public get isUpstreamPaused(): boolean { + return !trackRunning$.value; + } + public async pauseUpstream(): Promise { + trackRunning$.next(false); + return Promise.resolve(); + } + public async resumeUpstream(): Promise { + trackRunning$.next(true); + return Promise.resolve(); + } + })(), + } as unknown as LocalTrackPublication, + ], + ]); + onTestFinished(() => { + localParticipant.trackPublications = originalPublications; + }); + + // There are three indicators that the client might be disconnected from + // MatrixRTC: whether the sync loop is connected, whether the membership is + // present in local room state, and whether the membership manager thinks + // we've hit the timeout for the delayed leave event. Let's test all + // combinations of these conditions. + const syncingMarbles = " nyny----n--y"; + const membershipStatusMarbles = " y---ny-n-yn-y"; + const probablyLeftMarbles = " n-----y-ny---n"; + const expectedReconnectingMarbles = "n-ynyny------n"; + const expectedTrackRunningMarbles = "nynynyn------y"; + + withCallViewModel( + { initialSyncState: SyncState.Reconnecting }, + (vm, rtcSession, _subjects, setSyncState) => { + schedule(syncingMarbles, { + y: () => setSyncState(SyncState.Syncing), + n: () => setSyncState(SyncState.Reconnecting), + }); + schedule(membershipStatusMarbles, { + y: () => { + rtcSession.membershipStatus = Status.Connected; + }, + n: () => { + rtcSession.membershipStatus = Status.Reconnecting; + }, + }); + schedule(probablyLeftMarbles, { + y: () => { + rtcSession.probablyLeft = true; + }, + n: () => { + rtcSession.probablyLeft = false; + }, + }); + expectObservable(vm.reconnecting$).toBe( + expectedReconnectingMarbles, + yesNo, + ); + expectObservable(trackRunning$).toBe( + expectedTrackRunningMarbles, + yesNo, + ); + }, + ); + }); }); }); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index aa7f32be..669820ab 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -182,6 +182,21 @@ export class CallViewModel { this.matrixRTCSession, ); + // Each hbar seperates a block of input variables required for the CallViewModel to function. + // The outputs of this block is written under the hbar. + // + // For mocking purposes it is recommended to only mock the functions creating those outputs. + // All other fields are just temp computations for the mentioned output. + // The class does not need anything except the values underneath the bar. + // The creation of the values under the bar are all tested independently and testing the callViewModel Should + // not test their cretation. Call view model only needs: + // - memberships$ via createMemberships$ + // - localMembership via createLocalMembership$ + // - callLifecycle via createCallNotificationLifecycle$ + // - matrixMemberMetadataStore via createMatrixMemberMetadata$ + + // ------------------------------------------------------------------------ + // memberships$ private memberships$ = createMemberships$(this.scope, this.matrixRTCSession); private membershipsAndTransports = membershipsAndTransports$( @@ -189,6 +204,9 @@ export class CallViewModel { this.memberships$, ); + // ------------------------------------------------------------------------ + // matrixLivekitMembers$ AND localMembership + private localTransport$ = createLocalTransport$({ scope: this.scope, memberships$: this.memberships$, @@ -199,38 +217,32 @@ export class CallViewModel { ), }); - // ------------------------------------------------------------------------ - private connectionFactory = new ECConnectionFactory( this.matrixRoom.client, this.mediaDevices, this.trackProcessorState$, this.livekitKeyProvider, getUrlParams().controlledAudioDevices, - ); - - // Can contain duplicates. The connection manager will take care of this. - private allTransports$ = this.scope.behavior( - combineLatest( - [this.localTransport$, this.membershipsAndTransports.transports$], - (localTransport, transports) => { - const localTransportAsArray = localTransport ? [localTransport] : []; - return transports.mapInner((transports) => [ - ...localTransportAsArray, - ...transports, - ]); - }, - ), + this.options.livekitRoomFactory, ); private connectionManager = createConnectionManager$({ scope: this.scope, connectionFactory: this.connectionFactory, - inputTransports$: this.allTransports$, + inputTransports$: this.scope.behavior( + combineLatest( + [this.localTransport$, this.membershipsAndTransports.transports$], + (localTransport, transports) => { + const localTransportAsArray = localTransport ? [localTransport] : []; + return transports.mapInner((transports) => [ + ...localTransportAsArray, + ...transports, + ]); + }, + ), + ), }); - // ------------------------------------------------------------------------ - private matrixLivekitMembers$ = createMatrixLivekitMembers$({ scope: this.scope, membershipsWithTransport$: @@ -273,6 +285,7 @@ export class CallViewModel { ), ), ); + private localMatrixLivekitMemberUninitialized = { membership$: this.localRtcMembership$, participant$: this.localMembership.participant$, @@ -294,6 +307,7 @@ export class CallViewModel { ); // ------------------------------------------------------------------------ + // callLifecycle private callLifecycle = createCallNotificationLifecycle$({ scope: this.scope, @@ -309,6 +323,13 @@ export class CallViewModel { public autoLeave$ = this.callLifecycle.autoLeave$; // ------------------------------------------------------------------------ + // matrixMemberMetadataStore + + private matrixMemberMetadataStore = createMatrixMemberMetadata$( + this.scope, + this.scope.behavior(this.memberships$.pipe(map((mems) => mems.value))), + createRoomMembers$(this.scope, this.matrixRoom), + ); /** * If there is a configuration error with the call (e.g. misconfigured E2EE). @@ -402,12 +423,6 @@ export class CallViewModel { ), ); - private matrixMemberMetadataStore = createMatrixMemberMetadata$( - this.scope, - this.scope.behavior(this.memberships$.pipe(map((mems) => mems.value))), - createRoomMembers$(this.scope, this.matrixRoom), - ); - /** * List of user media (camera feeds) that we want tiles for. */ @@ -426,20 +441,23 @@ export class CallViewModel { { value: matrixLivekitMembers }, duplicateTiles, ]) { + let localParticipantId = undefined; // add local member if available if (localMatrixLivekitMember) { - const { - userId, - participant$, - connection$, - // membership$, - } = localMatrixLivekitMember; - const participantId = participant$.value?.identity; // should be membership$.value.membershipID which is not optional + const { userId, participant$, connection$, membership$ } = + localMatrixLivekitMember; + localParticipantId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional // const participantId = membership$.value.membershipID; - if (participantId) { + if (localParticipantId) { for (let dup = 0; dup < 1 + duplicateTiles; dup++) { yield { - keys: [dup, participantId, userId, participant$, connection$], + keys: [ + dup, + localParticipantId, + userId, + participant$, + connection$, + ], data: undefined, }; } @@ -450,11 +468,11 @@ export class CallViewModel { userId, participant$, connection$, - // membership$ + membership$, } of matrixLivekitMembers) { - const participantId = participant$.value?.identity; + const participantId = `${userId}:${membership$.value.deviceId}`; + if (participantId === localParticipantId) continue; // const participantId = membership$.value?.identity; - if (!participantId) continue; for (let dup = 0; dup < 1 + duplicateTiles; dup++) { yield { keys: [dup, participantId, userId, participant$, connection$], @@ -550,9 +568,8 @@ export class CallViewModel { * - There can be multiple participants for one Matrix user if they join from * multiple devices. */ - // TODO KEEP THIS!! and adapt it to what our membershipManger returns public readonly participantCount$ = this.scope.behavior( - this.memberships$.pipe(map((ms) => ms.value.length)), + this.matrixLivekitMembers$.pipe(map((ms) => ms.value.length)), ); // only public to expose to the view. diff --git a/src/state/CallViewModel/localMember/LocalMembership.ts b/src/state/CallViewModel/localMember/LocalMembership.ts index 1d517643..5e60562a 100644 --- a/src/state/CallViewModel/localMember/LocalMembership.ts +++ b/src/state/CallViewModel/localMember/LocalMembership.ts @@ -30,7 +30,7 @@ import { startWith, switchMap, } from "rxjs"; -import { logger } from "matrix-js-sdk/lib/logger"; +import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { type Behavior } from "../../Behavior"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager"; @@ -52,7 +52,7 @@ import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { MatrixRTCMode } from "../../../settings/settings.ts"; import { Config } from "../../../config/Config.ts"; import { type Connection } from "../remoteMembers/Connection.ts"; - +const logger = rootLogger.getChild("[LocalMembership]"); export enum LivekitState { Uninitialized = "uninitialized", Connecting = "connecting", @@ -323,10 +323,19 @@ export const createLocalMembership$ = ({ !connectRequested || state.matrix$.value.state !== MatrixState.Disconnected ) { - logger.info("Waiting for transport to enter rtc session"); + logger.info( + "Not yet connecting because: ", + "transport === null:", + transport === null, + "!connectRequested:", + !connectRequested, + "state.matrix$.value.state !== MatrixState.Disconnected:", + state.matrix$.value.state !== MatrixState.Disconnected, + ); return; } state.matrix$.next({ state: MatrixState.Connecting }); + logger.info("Matrix State connecting"); enterRTCSession(matrixRTCSession, transport, options.value).catch( (error) => { logger.error(error); @@ -376,7 +385,9 @@ export const createLocalMembership$ = ({ for (const p of publications) { if (p.track?.isUpstreamPaused === true) { const kind = p.track.kind; - logger.log(`Resuming ${kind} track (MatrixRTC connection present)`); + logger.info( + `Resuming ${kind} track (MatrixRTC connection present)`, + ); p.track .resumeUpstream() .catch((e) => @@ -391,7 +402,7 @@ export const createLocalMembership$ = ({ for (const p of publications) { if (p.track?.isUpstreamPaused === false) { const kind = p.track.kind; - logger.log( + logger.info( `Pausing ${kind} track (uncertain MatrixRTC connection)`, ); p.track diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts index 32d42d75..b28e4f75 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts @@ -117,7 +117,7 @@ export function createConnectionManager$({ connectionFactory, inputTransports$, }: Props): IConnectionManager { - const logger = rootLogger.getChild("ConnectionManager"); + const logger = rootLogger.getChild("[ConnectionManager]"); const running$ = new BehaviorSubject(true); scope.onEnd(() => running$.next(false)); diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index 3b31cd33..2f152630 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -22,7 +22,7 @@ import { Epoch, type ObservableScope } from "../../ObservableScope"; import { type Connection } from "./Connection"; import { generateItemsWithEpoch } from "../../../utils/observable"; -const logger = rootLogger.getChild("MatrixLivekitMembers"); +const logger = rootLogger.getChild("[MatrixLivekitMembers]"); /** * Represents a Matrix call member and their associated LiveKit participation. diff --git a/src/state/ObservableScope.ts b/src/state/ObservableScope.ts index 5372246c..282eb77f 100644 --- a/src/state/ObservableScope.ts +++ b/src/state/ObservableScope.ts @@ -74,22 +74,14 @@ export class ObservableScope { // they will no longer re-emit their current value upon subscription. We want // to support Observables that complete (for example `of({})`), so we have to // take care to not propagate the completion event. - setValue$ - .pipe( - this.bind(), - distinctUntilChanged((a, b) => { - logger.log("distinctUntilChanged", a, b); - return a === b; - }), - ) - .subscribe({ - next(value) { - subject$.next(value); - }, - error(err: unknown) { - subject$.error(err); - }, - }); + setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({ + next(value) { + subject$.next(value); + }, + error(err: unknown) { + subject$.error(err); + }, + }); if (subject$.value === nothing) throw new Error("Behavior failed to synchronously emit an initial value"); return subject$ as Behavior; diff --git a/src/state/SessionBehaviors.ts b/src/state/SessionBehaviors.ts index 7d38ac3d..153ad164 100644 --- a/src/state/SessionBehaviors.ts +++ b/src/state/SessionBehaviors.ts @@ -76,6 +76,6 @@ export const createMemberships$ = ( MatrixRTCSessionEvent.MembershipsChanged, (_, memberships: CallMembership[]) => memberships, ).pipe(trackEpoch()), - new Epoch([]), + new Epoch(matrixRTCSession.memberships), ); }; diff --git a/src/utils/test-fixtures.ts b/src/utils/test-fixtures.ts index e3824ac9..4cf330b7 100644 --- a/src/utils/test-fixtures.ts +++ b/src/utils/test-fixtures.ts @@ -11,9 +11,9 @@ import { mockRemoteParticipant, } from "./test"; -export const localRtcMember = mockRtcMembership("@carol:example.org", "1111"); +export const localRtcMember = mockRtcMembership("@local:example.org", "1111"); export const localRtcMemberDevice2 = mockRtcMembership( - "@carol:example.org", + "@local:example.org", "2222", ); export const local = mockMatrixRoomMember(localRtcMember); diff --git a/src/utils/test.ts b/src/utils/test.ts index d0f09576..4fec433c 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -6,7 +6,14 @@ 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, onTestFinished, vi, vitest } from "vitest"; +import { + expect, + type MockedObject, + type MockInstance, + onTestFinished, + vi, + vitest, +} from "vitest"; import { MatrixEvent, type Room as MatrixRoom, @@ -269,6 +276,7 @@ export function mockLivekitRoom( }: { remoteParticipants$?: Observable } = {}, ): LivekitRoom { const livekitRoom = { + options: {}, ...mockEmitter(), ...room, } as Partial as LivekitRoom; @@ -291,6 +299,7 @@ export function mockLocalParticipant( return { isLocal: true, trackPublications: new Map(), + unpublishTracks: async () => Promise.resolve(), getTrackPublication: () => ({}) as Partial as LocalTrackPublication, ...mockEmitter(), @@ -331,6 +340,8 @@ export function mockRemoteParticipant( setVolume() {}, getTrackPublication: () => ({}) as Partial as RemoteTrackPublication, + // this will only get used for `getTrackPublications().length` + getTrackPublications: () => [0], ...mockEmitter(), ...participant, } as RemoteParticipant; @@ -363,13 +374,16 @@ export function createRemoteMedia( ); } -export function mockConfig(config: Partial = {}): void { - vi.spyOn(Config, "get").mockReturnValue({ +export function mockConfig( + config: Partial = {}, +): MockInstance<() => ResolvedConfigOptions> { + const spy = vi.spyOn(Config, "get").mockReturnValue({ ...DEFAULT_CONFIG, ...config, }); // simulate loading the config vi.spyOn(Config, "init").mockResolvedValue(void 0); + return spy; } export class MockRTCSession extends TypedEventEmitter<