diff --git a/src/initializer.test.ts b/src/initializer.test.ts index 0e43ed1f..6439c015 100644 --- a/src/initializer.test.ts +++ b/src/initializer.test.ts @@ -18,7 +18,7 @@ import { import { mockConfig } from "./utils/test"; -const sentryInitSpy = vi.fn(); +const sentryInitSpy = vi.hoisted(() => vi.fn()); // Place the mock after the spy is defined vi.mock("@sentry/react", () => ({ diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 93e1d991..cb528f68 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -36,7 +36,6 @@ import { deepCompare } from "matrix-js-sdk/lib/utils"; import { type Layout } from "../layout-types.ts"; import { - mockLocalParticipant, mockMatrixRoomMember, mockRemoteParticipant, withTestScheduler, @@ -61,7 +60,10 @@ import { import { MediaDevices } from "../MediaDevices.ts"; import { getValue } from "../../utils/observable.ts"; import { type Behavior, constant } from "../Behavior.ts"; -import { withCallViewModel as withCallViewModelInMode } from "./CallViewModelTestUtils.ts"; +import { + localParticipant, + withCallViewModel as withCallViewModelInMode, +} from "./CallViewModelTestUtils.ts"; import { MatrixRTCMode } from "../../settings/settings.ts"; import { initializeWidget } from "../../widget.ts"; @@ -104,7 +106,6 @@ const dave = mockMatrixRoomMember(daveRtcMember, { rawDisplayName: "Dave" }); const daveId = `${dave.userId}:${daveRtcMember.deviceId}`; -const localParticipant = mockLocalParticipant({ identity: "" }); const bobParticipant = mockRemoteParticipant({ identity: bobId }); const daveParticipant = mockRemoteParticipant({ identity: daveId }); @@ -269,7 +270,7 @@ describe.each([ }); }); - test("screen sharing activates spotlight layout", () => { + test("remote 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 @@ -350,6 +351,76 @@ describe.each([ }); }); + test("local screen sharing stays in grid layout", () => { + withTestScheduler(({ behavior, expectObservable }) => { + // Local participant shares their screen, then stops sharing + const sharingInputMarbles = " nyn"; + // Layout should show the screen share but stay in type: "grid" + const expectedLayoutMarbles = "aba"; + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant, bobParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + sharingScreen: new Map([ + [localParticipant, behavior(sharingInputMarbles, yesNo)], + ]), + }, + (vm) => { + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + b: { + type: "grid", + spotlight: [`${localId}:0:screen-share`], + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + }, + ); + }, + ); + }); + }); + + test("local screen sharing in one-on-one call activates grid layout", () => { + withTestScheduler(({ behavior, expectObservable }) => { + // Local participant shares their screen, then stops sharing + const sharingInputMarbles = " nyn"; + // Layout should switch to grid layout then back to one-on-one layout + const expectedLayoutMarbles = "aba"; + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember]), + sharingScreen: new Map([ + [localParticipant, behavior(sharingInputMarbles, yesNo)], + ]), + }, + (vm) => { + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "one-on-one", + pip: `${localId}:0`, + spotlight: `${aliceId}:0`, + }, + b: { + type: "grid", + spotlight: [`${localId}:0:screen-share`], + grid: [`${localId}:0`, `${aliceId}:0`], + }, + }, + ); + }, + ); + }); + }); + test("participants stay in the same order unless to appear/disappear", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { const visibilityInputMarbles = "a"; diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index e0c55ee0..d33392e6 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -105,12 +105,16 @@ import { import { createLocalTransport$, JwtEndpointVersion, + type LocalTransport, } from "./localMember/LocalTransport.ts"; import { createMemberships$, membershipsAndTransports$, } from "../SessionBehaviors.ts"; -import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts"; +import { + type ConnectionFactory, + ECConnectionFactory, +} from "./remoteMembers/ConnectionFactory.ts"; import { type ConnectionManagerData, createConnectionManager$, @@ -170,6 +174,10 @@ export interface CallViewModelOptions { connectionState$?: Behavior; /** Optional behavior overriding the computed window size, mainly for testing purposes. */ windowSize$?: Behavior<{ width: number; height: number }>; + /** Optional value overriding the local transport, for testing purposes. */ + localTransport?: LocalTransport; + /** Optional value overriding the connection factory, for testing purposes. */ + connectionFactory?: ConnectionFactory; /** The version & compatibility mode of MatrixRTC that we should use. */ matrixRTCMode$?: Behavior; } @@ -441,6 +449,7 @@ export function createCallViewModel$( // Re-create LocalTransport whenever the mode changes (mode) => ({ keys: [mode], data: undefined }), (scope, _data$, mode) => + options.localTransport ?? createLocalTransport$({ scope: scope, memberships$: memberships$, @@ -467,17 +476,19 @@ export function createCallViewModel$( ), ); - const connectionFactory = new ECConnectionFactory( - client, - matrixRoom.roomId, - mediaDevices, - trackProcessorState$, - livekitKeyProvider, - getUrlParams().controlledAudioDevices, - options.livekitRoomFactory, - getUrlParams().echoCancellation, - getUrlParams().noiseSuppression, - ); + const connectionFactory = + options.connectionFactory ?? + new ECConnectionFactory( + client, + matrixRoom.roomId, + mediaDevices, + trackProcessorState$, + livekitKeyProvider, + getUrlParams().controlledAudioDevices, + options.livekitRoomFactory, + getUrlParams().echoCancellation, + getUrlParams().noiseSuppression, + ); const connectionManager = createConnectionManager$({ scope: scope, diff --git a/src/state/CallViewModel/CallViewModelTestUtils.ts b/src/state/CallViewModel/CallViewModelTestUtils.ts index 431e691d..9685c709 100644 --- a/src/state/CallViewModel/CallViewModelTestUtils.ts +++ b/src/state/CallViewModel/CallViewModelTestUtils.ts @@ -30,7 +30,10 @@ import { type CallViewModelOptions, } from "./CallViewModel"; import { + exampleSfuConfig, + exampleTransport, mockConfig, + MockConnection, mockLivekitRoom, mockLocalParticipant, mockMatrixRoom, @@ -75,7 +78,7 @@ export interface CallViewModelInputs { windowSize$: Behavior<{ width: number; height: number }>; } -const localParticipant = mockLocalParticipant({ identity: "" }); +export const localParticipant = mockLocalParticipant({ identity: "" }); export function withCallViewModel(mode: MatrixRTCMode) { return ( @@ -180,6 +183,13 @@ export function withCallViewModel(mode: MatrixRTCMode) { ); const reactions$ = new BehaviorSubject>({}); + const livekitRoomFactory = (): LivekitRoom => + mockLivekitRoom({ + localParticipant, + disconnect: async () => Promise.resolve(), + setE2EEEnabled: async () => Promise.resolve(), + }); + const vm = createCallViewModel$( testScope(), rtcSession.asMockedSession(), @@ -189,14 +199,38 @@ export function withCallViewModel(mode: MatrixRTCMode) { { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, autoLeaveWhenOthersLeft: false, - livekitRoomFactory: (): LivekitRoom => - mockLivekitRoom({ - localParticipant, - disconnect: async () => Promise.resolve(), - setE2EEEnabled: async () => Promise.resolve(), - }), + livekitRoomFactory, connectionState$, windowSize$, + localTransport: { + active$: constant({ + transport: exampleTransport, + sfuConfig: exampleSfuConfig, + }), + advertised$: constant(exampleTransport), + }, + connectionFactory: { + createConnection( + scope, + transport, + ownMembershipIdentity, + logger, + sfuConfig, + ) { + return new MockConnection( + { + scope, + transport, + ownMembershipIdentity, + existingSFUConfig: sfuConfig, + client: room.client, + roomId: room.roomId, + livekitRoomFactory, + }, + logger, + ); + }, + }, matrixRTCMode$: constant(mode), ...options, }, diff --git a/src/utils/test.ts b/src/utils/test.ts index f8d420cf..af372bfb 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -74,6 +74,8 @@ import { createRemoteScreenShare, type RemoteScreenShareViewModel, } from "../state/media/RemoteScreenShareViewModel"; +import { Connection } from "../state/CallViewModel/remoteMembers/Connection"; +import { type SFUConfig } from "../livekit/openIDSFU"; export function withFakeTimers(continuation: () => void): void { vi.useFakeTimers(); @@ -210,6 +212,13 @@ export const exampleTransport: LivekitTransport = { livekit_alias: "!alias:example.org", }; +export const exampleSfuConfig: SFUConfig = { + jwt: "foo", + livekitAlias: "bar", + livekitIdentity: "baz", + url: "bro", +}; + export function mockRtcMembership( user: string | RoomMember, deviceId: string, @@ -564,3 +573,8 @@ export function mockMuteStates( videoEnabled: false, }); } + +export class MockConnection extends Connection { + public async start(): Promise {} + public async stop(): Promise {} +}