diff --git a/src/models/Call.ts b/src/models/Call.ts index 60ff2b2ffa6..c8e6f031468 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -23,17 +23,14 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue"; import { type IWidgetApiRequest, type ClientWidgetApi, type IWidgetData } from "matrix-widget-api"; import { - MatrixRTCSession, + type MatrixRTCSession, MatrixRTCSessionEvent, - type CallMembership, MatrixRTCSessionManagerEvents, - type ICallNotifyContent, } from "matrix-js-sdk/src/matrixrtc"; import type EventEmitter from "events"; import type { IApp } from "../stores/WidgetStore"; import SettingsStore from "../settings/SettingsStore"; -import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler"; import { timeout } from "../utils/promise"; import WidgetUtils from "../utils/WidgetUtils"; import { WidgetType } from "../widgets/WidgetType"; @@ -44,7 +41,6 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidge import { getCurrentLanguage } from "../languageHandler"; import { Anonymity, PosthogAnalytics } from "../PosthogAnalytics"; import { UPDATE_EVENT } from "../stores/AsyncStore"; -import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers"; import { isVideoRoom } from "../utils/video-rooms"; import { FontWatcher } from "../settings/watchers/FontWatcher"; import { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-types"; @@ -85,15 +81,9 @@ export enum ConnectionState { export const isConnected = (state: ConnectionState): boolean => state === ConnectionState.Connected || state === ConnectionState.Disconnecting; -export enum Layout { - Tile = "tile", - Spotlight = "spotlight", -} - export enum CallEvent { ConnectionState = "connection_state", Participants = "participants", - Layout = "layout", Close = "close", Destroy = "destroy", } @@ -104,7 +94,6 @@ interface CallEventHandlerMap { participants: Map>, prevParticipants: Map>, ) => void; - [CallEvent.Layout]: (layout: Layout) => void; [CallEvent.Close]: () => void; [CallEvent.Destroy]: () => void; } @@ -202,18 +191,6 @@ export abstract class Call extends TypedEventEmitter; - /** - * Contacts the widget to connect to the call or prompt the user to connect to the call. - * @param {MediaDeviceInfo | null} audioInput The audio input to use, or - * null to start muted. - * @param {MediaDeviceInfo | null} audioInput The video input to use, or - * null to start muted. - */ - protected abstract performConnection( - audioInput: MediaDeviceInfo | null, - videoInput: MediaDeviceInfo | null, - ): Promise; - /** * Contacts the widget to disconnect from the call. */ @@ -221,28 +198,10 @@ export abstract class Call extends TypedEventEmitter { - const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } = - (await MediaDeviceHandler.getDevices())!; - - let audioInput: MediaDeviceInfo | null = null; - if (!MediaDeviceHandler.startWithAudioMuted) { - const deviceId = MediaDeviceHandler.getAudioInput(); - audioInput = audioInputs.find((d) => d.deviceId === deviceId) ?? audioInputs[0] ?? null; - } - let videoInput: MediaDeviceInfo | null = null; - if (!MediaDeviceHandler.startWithVideoMuted) { - const deviceId = MediaDeviceHandler.getVideoInput(); - videoInput = videoInputs.find((d) => d.deviceId === deviceId) ?? videoInputs[0] ?? null; - } - const messagingStore = WidgetMessagingStore.instance; this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null; if (!this.messaging) { @@ -263,13 +222,23 @@ export abstract class Call extends TypedEventEmitter { - // Ensure that the messaging doesn't get stopped while we're waiting for responses - const dontStopMessaging = new Promise((resolve, reject) => { - const messagingStore = WidgetMessagingStore.instance; - - const listener = (uid: string): void => { - if (uid === this.widgetUid) { - cleanup(); - reject(new Error("Messaging stopped")); - } - }; - const done = (): void => { - cleanup(); - resolve(); - }; - const cleanup = (): void => { - messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener); - this.off(CallEvent.ConnectionState, done); - }; - - messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener); - this.on(CallEvent.ConnectionState, done); - }); - - // Empirically, it's possible for Jitsi Meet to crash instantly at startup, - // sending a hangup event that races with the rest of this method, so we need - // to add the hangup listener now rather than later + public async start(): Promise { + await super.start(); + this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - - // Actually perform the join - const response = waitForEvent( - this.messaging!, - `action:${ElementWidgetActions.JoinCall}`, - (ev: CustomEvent) => { - ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack - return true; - }, - ); - const request = this.messaging!.transport.send(ElementWidgetActions.JoinCall, { - audioInput: audioInput?.label ?? null, - videoInput: videoInput?.label ?? null, - }); - try { - await Promise.race([Promise.all([request, response]), dontStopMessaging]); - } catch (e) { - // If it timed out, clean up our advance preparations - this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - - if (this.messaging!.transport.ready) { - // The messaging still exists, which means Jitsi might still be going in the background - this.messaging!.transport.send(ElementWidgetActions.HangupCall, { force: true }); - } - - throw new Error(`Failed to join call in room ${this.roomId}: ${e}`); - } - ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock); } @@ -558,18 +462,17 @@ export class JitsiCall extends Call { } } - public setDisconnected(): void { - // During tests this.messaging can be undefined - this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + public close(): void { + this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); + this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock); - - super.setDisconnected(); + super.close(); } public destroy(): void { this.room.off(RoomStateEvent.Update, this.onRoomState); - this.on(CallEvent.ConnectionState, this.onConnectionState); + this.off(CallEvent.ConnectionState, this.onConnectionState); if (this.participantsExpirationTimer !== null) { clearTimeout(this.participantsExpirationTimer); this.participantsExpirationTimer = null; @@ -621,27 +524,21 @@ export class JitsiCall extends Call { await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {}); }; + private readonly onJoin = (ev: CustomEvent): void => { + ev.preventDefault(); + this.messaging!.transport.reply(ev.detail, {}); // ack + this.setConnected(); + }; + private readonly onHangup = async (ev: CustomEvent): Promise => { // If we're already in the middle of a client-initiated disconnection, // ignore the event if (this.connectionState === ConnectionState.Disconnecting) return; ev.preventDefault(); - - // In case this hangup is caused by Jitsi Meet crashing at startup, - // wait for the connection event in order to avoid racing - if (this.connectionState === ConnectionState.Disconnected) { - await waitForEvent(this, CallEvent.ConnectionState); - } - this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); - this.close(); - // In video rooms we immediately want to restart the call after hangup - // The lobby will be shown again and it connects to all signals from Jitsi. - if (isVideoRoom(this.room)) { - this.start(); - } + if (!isVideoRoom(this.room)) this.close(); }; } @@ -658,14 +555,6 @@ export class ElementCall extends Call { private settingsStoreCallEncryptionWatcher?: string; private terminationTimer?: number; - private _layout = Layout.Tile; - public get layout(): Layout { - return this._layout; - } - protected set layout(value: Layout) { - this._layout = value; - this.emit(CallEvent.Layout, value); - } public get presented(): boolean { return super.presented; @@ -686,7 +575,6 @@ export class ElementCall extends Call { const params = new URLSearchParams({ embed: "true", // We're embedding EC within another application // Template variables are used, so that this can be configured using the widget data. - preload: "$preload", // We want it to load in the background. skipLobby: "$skipLobby", // Skip the lobby in case we show a lobby component of our own. returnToLobby: "$returnToLobby", // Returns to the lobby (instead of blank screen) when the call ends. (For video rooms) perParticipantE2EE: "$perParticipantE2EE", @@ -756,17 +644,13 @@ export class ElementCall extends Call { } // Creates a new widget if there isn't any widget of typ Call in this room. - // Defaults for creating a new widget are: skipLobby = false, preload = false + // Defaults for creating a new widget are: skipLobby = false // When there is already a widget the current widget configuration will be used or can be overwritten - // by passing the according parameters (skipLobby, preload). - // - // `preload` is deprecated. We used it for optimizing EC by using a custom EW call lobby and preloading the iframe. - // now it should always be false. + // by passing the according parameters (skipLobby). private static createOrGetCallWidget( roomId: string, client: MatrixClient, skipLobby: boolean | undefined, - preload: boolean | undefined, returnToLobby: boolean | undefined, ): IApp { const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type)); @@ -777,9 +661,6 @@ export class ElementCall extends Call { if (skipLobby !== undefined) { overwrites.skipLobby = skipLobby; } - if (preload !== undefined) { - overwrites.preload = preload; - } if (returnToLobby !== undefined) { overwrites.returnToLobby = returnToLobby; } @@ -804,7 +685,6 @@ export class ElementCall extends Call { {}, { skipLobby: skipLobby ?? false, - preload: preload ?? false, returnToLobby: returnToLobby ?? false, }, ), @@ -870,7 +750,6 @@ export class ElementCall extends Call { room.roomId, room.client, undefined, - undefined, isVideoRoom(room), ); return new ElementCall(session, availableOrCreatedWidget, room.client); @@ -880,99 +759,41 @@ export class ElementCall extends Call { } public static create(room: Room, skipLobby = false): void { - ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false, isVideoRoom(room)); + ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room)); } - protected async sendCallNotify(): Promise { - const room = this.room; - const existingOtherRoomCallMembers = MatrixRTCSession.callMembershipsForRoom(room).filter( - // filter all memberships where the application is m.call and the call_id is "" - (m) => { - const isRoomCallMember = m.application === "m.call" && m.callId === ""; - const isThisDevice = m.deviceId === this.client.deviceId; - return isRoomCallMember && !isThisDevice; - }, - ); - - const memberCount = getJoinedNonFunctionalMembers(room).length; - if (!isVideoRoom(room) && existingOtherRoomCallMembers.length === 0) { - // send ringing event - const content: ICallNotifyContent = { - "application": "m.call", - "m.mentions": { user_ids: [], room: true }, - "notify_type": memberCount == 2 ? "ring" : "notify", - "call_id": "", - }; - - await room.client.sendEvent(room.roomId, EventType.CallNotify, content); - } - } - - protected async performConnection( - audioInput: MediaDeviceInfo | null, - videoInput: MediaDeviceInfo | null, - ): Promise { - // The JoinCall action is only send if the widget is waiting for it. - if (this.widget.data?.preload) { - try { - await this.messaging!.transport.send(ElementWidgetActions.JoinCall, { - audioInput: audioInput?.label ?? null, - videoInput: videoInput?.label ?? null, - }); - } catch (e) { - throw new Error(`Failed to join call in room ${this.roomId}: ${e}`); - } - } - this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); - this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); + public async start(): Promise { + await super.start(); + this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.messaging!.once(`action:${ElementWidgetActions.Close}`, this.onClose); + this.messaging!.on(`action:${ElementWidgetActions.Close}`, this.onClose); this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); - - // TODO: if the widget informs us when the join button is clicked (widget action), so we can - // - set state to connecting - // - send call notify - const session = this.client.matrixRTC.getActiveRoomSession(this.room); - if (session) { - await waitForEvent( - session, - MatrixRTCSessionEvent.MembershipsChanged, - (_, newMemberships: CallMembership[]) => - newMemberships.some((m) => m.sender === this.client.getUserId()), - false, // allow user to wait as long as they want (no timeout) - ); - } else { - await waitForEvent( - this.client.matrixRTC, - MatrixRTCSessionManagerEvents.SessionStarted, - (roomId: string, session: MatrixRTCSession) => - this.session.callId === session.callId && roomId === this.roomId, - false, // allow user to wait as long as they want (no timeout) - ); - } - this.sendCallNotify(); } protected async performDisconnection(): Promise { + const response = waitForEvent( + this.messaging!, + `action:${ElementWidgetActions.HangupCall}`, + (ev: CustomEvent) => { + ev.preventDefault(); + this.messaging!.transport.reply(ev.detail, {}); // ack + return true; + }, + ); + const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); try { - await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); - await waitForEvent( - this.session, - MatrixRTCSessionEvent.MembershipsChanged, - (_, newMemberships: CallMembership[]) => - !newMemberships.some((m) => m.sender === this.client.getUserId()), - ); + await Promise.all([request, response]); } catch (e) { throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`); } } - public setDisconnected(): void { - this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); - this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); + public close(): void { + this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.messaging!.off(`action:${ElementWidgetActions.Close}`, this.onClose); this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); - super.setDisconnected(); + super.close(); } public destroy(): void { @@ -994,15 +815,6 @@ export class ElementCall extends Call { if (this.session.memberships.length === 0 && !this.presented && !this.room.isCallRoom()) this.destroy(); }; - /** - * Sets the call's layout. - * @param layout The layout to switch to. - */ - public async setLayout(layout: Layout): Promise { - const action = layout === Layout.Tile ? ElementWidgetActions.TileLayout : ElementWidgetActions.SpotlightLayout; - await this.messaging!.transport.send(action, {}); - } - private readonly onMembershipChanged = (): void => this.updateParticipants(); private updateParticipants(): void { @@ -1028,34 +840,27 @@ export class ElementCall extends Call { this.messaging!.transport.reply(ev.detail, {}); // ack }; - private readonly onHangup = async (ev: CustomEvent): Promise => { + private readonly onJoin = (ev: CustomEvent): void => { ev.preventDefault(); this.messaging!.transport.reply(ev.detail, {}); // ack - this.setDisconnected(); - // In video rooms we immediately want to reconnect after hangup - // This starts the lobby again and connects to all signals from EC. - if (isVideoRoom(this.room)) { - this.start(); - } + this.setConnected(); }; - private readonly onClose = async (ev: CustomEvent): Promise => { - ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack - // User is done with the call; tell the UI to close it - this.close(); - }; + private readonly onHangup = async (ev: CustomEvent): Promise => { + // If we're already in the middle of a client-initiated disconnection, + // ignore the event + if (this.connectionState === ConnectionState.Disconnecting) return; - private readonly onTileLayout = async (ev: CustomEvent): Promise => { ev.preventDefault(); - this.layout = Layout.Tile; this.messaging!.transport.reply(ev.detail, {}); // ack + this.setDisconnected(); }; - private readonly onSpotlightLayout = async (ev: CustomEvent): Promise => { + private readonly onClose = async (ev: CustomEvent): Promise => { ev.preventDefault(); - this.layout = Layout.Spotlight; this.messaging!.transport.reply(ev.detail, {}); // ack + // User is done with the call; tell the UI to close it + this.close(); }; public clean(): Promise { diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index a0a1c84536a..36fc2b505f9 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -79,7 +79,6 @@ export class MockedCall extends Call { // No action needed for any of the following methods since this is just a mock public async clean(): Promise {} // Public to allow spying - public async performConnection(): Promise {} public async performDisconnection(): Promise {} public destroy() { diff --git a/test/unit-tests/components/views/messages/CallEvent-test.tsx b/test/unit-tests/components/views/messages/CallEvent-test.tsx index cc8e5a0a8ee..688c9b190f1 100644 --- a/test/unit-tests/components/views/messages/CallEvent-test.tsx +++ b/test/unit-tests/components/views/messages/CallEvent-test.tsx @@ -151,7 +151,7 @@ describe("CallEvent", () => { }), ); defaultDispatcher.unregister(dispatcherRef); - await act(() => call.start()); + act(() => call.setConnectionState(ConnectionState.Connected)); // Test that the leave button works fireEvent.click(screen.getByRole("button", { name: "Leave" })); diff --git a/test/unit-tests/components/views/rooms/RoomTile-test.tsx b/test/unit-tests/components/views/rooms/RoomTile-test.tsx index 68b47a037a5..a770b00bd41 100644 --- a/test/unit-tests/components/views/rooms/RoomTile-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomTile-test.tsx @@ -46,6 +46,7 @@ import { UIComponent } from "../../../../../src/settings/UIFeature"; import { MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import SettingsStore from "../../../../../src/settings/SettingsStore"; +import { ConnectionState } from "../../../../../src/models/Call"; jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), @@ -215,7 +216,7 @@ describe("RoomTile", () => { it("tracks connection state", async () => { renderRoomTile(); screen.getByText("Video"); - await act(() => call.start()); + act(() => call.setConnectionState(ConnectionState.Connected)); screen.getByText("Joined"); await act(() => call.disconnect()); screen.getByText("Video"); diff --git a/test/unit-tests/models/Call-test.ts b/test/unit-tests/models/Call-test.ts index c8f9d50334d..3437fc0a6e0 100644 --- a/test/unit-tests/models/Call-test.ts +++ b/test/unit-tests/models/Call-test.ts @@ -34,7 +34,6 @@ import type { Mocked } from "jest-mock"; import type { ClientWidgetApi } from "matrix-widget-api"; import { type JitsiCallMemberContent, - Layout, Call, CallEvent, ConnectionState, @@ -42,7 +41,6 @@ import { ElementCall, } from "../../../src/models/Call"; import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../../test-utils"; -import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import WidgetStore from "../../../src/stores/WidgetStore"; import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore"; @@ -53,18 +51,6 @@ import { Anonymity, PosthogAnalytics } from "../../../src/PosthogAnalytics"; import { type SettingKey } from "../../../src/settings/Settings.tsx"; import SdkConfig from "../../../src/SdkConfig.ts"; -jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ - [MediaDeviceKindEnum.AudioInput]: [ - { deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => {} }, - ], - [MediaDeviceKindEnum.VideoInput]: [ - { deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => {} }, - ], - [MediaDeviceKindEnum.AudioOutput]: [], -}); -jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1"); -jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2"); - const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]); jest.spyOn(SettingsStore, "getValue").mockImplementation( (settingName): any => enabledSettings.has(settingName) || undefined, @@ -137,14 +123,7 @@ const cleanUpClientRoomAndStores = (client: MatrixClient, room: Room) => { client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); }; -const setUpWidget = ( - call: Call, -): { - widget: Widget; - messaging: Mocked; - audioMutedSpy: jest.SpyInstance; - videoMutedSpy: jest.SpyInstance; -} => { +const setUpWidget = (call: Call): { widget: Widget; messaging: Mocked } => { call.widget.data = { ...call.widget, skipLobby: true }; const widget = new Widget(call.widget); @@ -162,23 +141,45 @@ const setUpWidget = ( } as unknown as Mocked; WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging); - const audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get"); - const videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get"); - - return { widget, messaging, audioMutedSpy, videoMutedSpy }; + return { widget, messaging }; }; -const cleanUpCallAndWidget = ( - call: Call, - widget: Widget, - audioMutedSpy: jest.SpyInstance, - videoMutedSpy: jest.SpyInstance, -) => { +async function connect(call: Call, messaging: Mocked, startWidget = true): Promise { + async function sessionConnect() { + await new Promise((r) => { + setTimeout(() => r(), 400); + }); + messaging.emit(`action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", {})); + } + async function runTimers() { + jest.advanceTimersByTime(500); + jest.advanceTimersByTime(500); + } + sessionConnect(); + await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]); +} + +async function disconnect(call: Call, messaging: Mocked): Promise { + async function sessionDisconnect() { + await new Promise((r) => { + setTimeout(() => r(), 400); + }); + messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); + } + async function runTimers() { + jest.advanceTimersByTime(500); + jest.advanceTimersByTime(500); + } + sessionDisconnect(); + const promise = call.disconnect(); + runTimers(); + await promise; +} + +const cleanUpCallAndWidget = (call: Call, widget: Widget) => { call.destroy(); jest.clearAllMocks(); WidgetMessagingStore.instance.stopMessaging(widget, call.roomId); - audioMutedSpy.mockRestore(); - videoMutedSpy.mockRestore(); }; describe("JitsiCall", () => { @@ -222,8 +223,6 @@ describe("JitsiCall", () => { let call: JitsiCall; let widget: Widget; let messaging: Mocked; - let audioMutedSpy: jest.SpyInstance; - let videoMutedSpy: jest.SpyInstance; beforeEach(async () => { jest.useFakeTimers(); @@ -234,7 +233,7 @@ describe("JitsiCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call)); + ({ widget, messaging } = setUpWidget(call)); mocked(messaging.transport).send.mockImplementation(async (action, data): Promise => { if (action === ElementWidgetActions.JoinCall) { @@ -252,102 +251,37 @@ describe("JitsiCall", () => { }); }); - afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); + afterEach(() => cleanUpCallAndWidget(call, widget)); - it("connects muted", async () => { + it("connects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - audioMutedSpy.mockReturnValue(true); - videoMutedSpy.mockReturnValue(true); - - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { - audioInput: null, - videoInput: null, - }); }); - it("connects unmuted", async () => { - expect(call.connectionState).toBe(ConnectionState.Disconnected); - audioMutedSpy.mockReturnValue(false); - videoMutedSpy.mockReturnValue(false); - - await call.start(); - expect(call.connectionState).toBe(ConnectionState.Connected); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, { - audioInput: "Headphones", - videoInput: "Built-in webcam", - }); - }); - - it("waits for messaging when connecting", async () => { + it("waits for messaging when starting", async () => { // Temporarily remove the messaging to simulate connecting while the // widget is still initializing WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const connect = call.start(); + const startup = call.start(); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); - await connect; + await startup; + await connect(call, messaging, false); expect(call.connectionState).toBe(ConnectionState.Connected); }); - it("doesn't stop messaging when connecting", async () => { - // Temporarily remove the messaging to simulate connecting while the - // widget is still initializing - jest.useFakeTimers(); - const oldSendMock = messaging.transport.send; - mocked(messaging.transport).send.mockImplementation(async (action: string): Promise => { - if (action === ElementWidgetActions.JoinCall) { - await new Promise((resolve) => setTimeout(resolve, 100)); - messaging.emit( - `action:${ElementWidgetActions.JoinCall}`, - new CustomEvent("widgetapirequest", { detail: {} }), - ); - } - }); - expect(call.connectionState).toBe(ConnectionState.Disconnected); - - const connect = call.start(); - async function runTimers() { - jest.advanceTimersByTime(500); - jest.advanceTimersByTime(1000); - } - async function runStopMessaging() { - await new Promise((resolve) => setTimeout(resolve, 1000)); - WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); - } - runStopMessaging(); - runTimers(); - let connectError; - try { - await connect; - } catch (e) { - console.log(e); - connectError = e; - } - expect(connectError).toBeDefined(); - // const connect2 = await connect; - // expect(connect2).toThrow(); - messaging.transport.send = oldSendMock; - jest.useRealTimers(); - }); - - it("fails to connect if the widget returns an error", async () => { - mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); - await expect(call.start()).rejects.toBeDefined(); - }); - it("fails to disconnect if the widget returns an error", async () => { - await call.start(); - mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); + await connect(call, messaging); + mocked(messaging.transport).send.mockRejectedValue(new Error("never!")); await expect(call.disconnect()).rejects.toBeDefined(); }); it("handles remote disconnection", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); const callback = jest.fn(); @@ -355,7 +289,6 @@ describe("JitsiCall", () => { call.on(CallEvent.ConnectionState, callback); messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); - messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {})); await waitFor(() => { expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected); }); @@ -365,14 +298,14 @@ describe("JitsiCall", () => { it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); await call.disconnect(); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave); expect(call.connectionState).toBe(ConnectionState.Disconnected); @@ -380,14 +313,14 @@ describe("JitsiCall", () => { it("reconnects after disconnect in video rooms", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); await call.disconnect(); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("remains connected if we stay in the room", async () => { - await call.start(); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Join); expect(call.connectionState).toBe(ConnectionState.Connected); @@ -413,7 +346,7 @@ describe("JitsiCall", () => { // Now, stub out client.sendStateEvent so we can test our local echo client.sendStateEvent.mockReset(); - await call.start(); + await connect(call, messaging); expect(call.participants).toEqual( new Map([ [alice, new Set(["alices_device"])], @@ -426,8 +359,8 @@ describe("JitsiCall", () => { }); it("updates room state when connecting and disconnecting", async () => { + await connect(call, messaging); const now1 = Date.now(); - await call.start(); await waitFor( () => expect( @@ -454,7 +387,7 @@ describe("JitsiCall", () => { }); it("repeatedly updates room state while connected", async () => { - await call.start(); + await connect(call, messaging); await waitFor( () => expect(client.sendStateEvent).toHaveBeenLastCalledWith( @@ -484,7 +417,7 @@ describe("JitsiCall", () => { const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await call.start(); + await connect(call, messaging); await call.disconnect(); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.Connected, ConnectionState.Disconnected], @@ -499,7 +432,7 @@ describe("JitsiCall", () => { const onParticipants = jest.fn(); call.on(CallEvent.Participants, onParticipants); - await call.start(); + await connect(call, messaging); await call.disconnect(); expect(onParticipants.mock.calls).toEqual([ [new Map([[alice, new Set(["alices_device"])]]), new Map()], @@ -512,7 +445,7 @@ describe("JitsiCall", () => { }); it("switches to spotlight layout when the widget becomes a PiP", async () => { - await call.start(); + await connect(call, messaging); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock); expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock); @@ -556,7 +489,7 @@ describe("JitsiCall", () => { }); it("doesn't clean up valid devices", async () => { - await call.start(); + await connect(call, messaging); await client.sendStateEvent( room.roomId, JitsiCall.MEMBER_EVENT_TYPE, @@ -621,47 +554,6 @@ describe("ElementCall", () => { jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember)); } - const callConnectProcedure = async (call: ElementCall, startWidget = true): Promise => { - async function sessionConnect() { - await new Promise((r) => { - setTimeout(() => r(), 400); - }); - client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, { - sessionId: undefined, - } as unknown as MatrixRTCSession); - call.session?.emit( - MatrixRTCSessionEvent.MembershipsChanged, - [], - [{ sender: client.getUserId() } as CallMembership], - ); - } - async function runTimers() { - jest.advanceTimersByTime(500); - jest.advanceTimersByTime(500); - } - sessionConnect(); - await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]); - }; - const callDisconnectionProcedure: (call: ElementCall) => Promise = async (call) => { - async function sessionDisconnect() { - await new Promise((r) => { - setTimeout(() => r(), 400); - }); - client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, { - sessionId: undefined, - } as unknown as MatrixRTCSession); - call.session?.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []); - } - async function runTimers() { - jest.advanceTimersByTime(500); - jest.advanceTimersByTime(500); - } - sessionDisconnect(); - const promise = call.disconnect(); - runTimers(); - await promise; - }; - beforeEach(() => { jest.useFakeTimers(); ({ client, room, alice } = setUpClientRoomAndStores()); @@ -864,8 +756,6 @@ describe("ElementCall", () => { let call: ElementCall; let widget: Widget; let messaging: Mocked; - let audioMutedSpy: jest.SpyInstance; - let videoMutedSpy: jest.SpyInstance; beforeEach(async () => { jest.useFakeTimers(); @@ -876,34 +766,28 @@ describe("ElementCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call)); + ({ widget, messaging } = setUpWidget(call)); }); - afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); + afterEach(() => cleanUpCallAndWidget(call, widget)); // TODO refactor initial device configuration to use the EW settings. // Add tests for passing EW device configuration to the widget. - it("waits for messaging when connecting", async () => { + it("waits for messaging when starting", async () => { // Temporarily remove the messaging to simulate connecting while the // widget is still initializing WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); - const connect = callConnectProcedure(call); + const startup = call.start(); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); - await connect; + await startup; + await connect(call, messaging, false); expect(call.connectionState).toBe(ConnectionState.Connected); }); - it("fails to connect if the widget returns an error", async () => { - // we only send a JoinCall action if the widget is preloading - call.widget.data = { ...call.widget, preload: true }; - mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); - await expect(call.start()).rejects.toBeDefined(); - }); - it("fails to disconnect if the widget returns an error", async () => { - await callConnectProcedure(call); + await connect(call, messaging); mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); await expect(call.disconnect()).rejects.toBeDefined(); }); @@ -911,7 +795,7 @@ describe("ElementCall", () => { it("handles remote disconnection", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); @@ -921,62 +805,35 @@ describe("ElementCall", () => { it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); - await callDisconnectionProcedure(call); + await disconnect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("remains connected if we stay in the room", async () => { - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Join); expect(call.connectionState).toBe(ConnectionState.Connected); }); it("disconnects if the widget dies", async () => { - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); - it("tracks layout", async () => { - await callConnectProcedure(call); - expect(call.layout).toBe(Layout.Tile); - - messaging.emit( - `action:${ElementWidgetActions.SpotlightLayout}`, - new CustomEvent("widgetapirequest", { detail: {} }), - ); - expect(call.layout).toBe(Layout.Spotlight); - - messaging.emit( - `action:${ElementWidgetActions.TileLayout}`, - new CustomEvent("widgetapirequest", { detail: {} }), - ); - expect(call.layout).toBe(Layout.Tile); - }); - - it("sets layout", async () => { - await callConnectProcedure(call); - - await call.setLayout(Layout.Spotlight); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); - - await call.setLayout(Layout.Tile); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {}); - }); - it("acknowledges mute_device widget action", async () => { - await callConnectProcedure(call); + await connect(call, messaging); const preventDefault = jest.fn(); const mockEv = { preventDefault, @@ -992,8 +849,8 @@ describe("ElementCall", () => { const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await callConnectProcedure(call); - await callDisconnectionProcedure(call); + await connect(call, messaging); + await disconnect(call, messaging); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.Connected, ConnectionState.Disconnected], [ConnectionState.Disconnecting, ConnectionState.Connected], @@ -1014,29 +871,11 @@ describe("ElementCall", () => { call.off(CallEvent.Participants, onParticipants); }); - it("emits events when layout changes", async () => { - await callConnectProcedure(call); - const onLayout = jest.fn(); - call.on(CallEvent.Layout, onLayout); - - messaging.emit( - `action:${ElementWidgetActions.SpotlightLayout}`, - new CustomEvent("widgetapirequest", { detail: {} }), - ); - messaging.emit( - `action:${ElementWidgetActions.TileLayout}`, - new CustomEvent("widgetapirequest", { detail: {} }), - ); - expect(onLayout.mock.calls).toEqual([[Layout.Spotlight], [Layout.Tile]]); - - call.off(CallEvent.Layout, onLayout); - }); - it("ends the call immediately if the session ended", async () => { - await callConnectProcedure(call); + await connect(call, messaging); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await callDisconnectionProcedure(call); + await disconnect(call, messaging); // this will be called automatically // disconnect -> widget sends state event -> session manager notices no-one left client.matrixRTC.emit( @@ -1072,39 +911,12 @@ describe("ElementCall", () => { roomSpy.mockRestore(); addWidgetSpy.mockRestore(); }); - - it("sends notify event on connect in a room with more than two members", async () => { - const sendEventSpy = jest.spyOn(room.client, "sendEvent"); - ElementCall.create(room); - await callConnectProcedure(Call.get(room) as ElementCall); - expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", { - "application": "m.call", - "call_id": "", - "m.mentions": { room: true, user_ids: [] }, - "notify_type": "notify", - }); - }); - it("sends ring on create in a DM (two participants) room", async () => { - setRoomMembers(["@user:example.com", "@user2:example.com"]); - - const sendEventSpy = jest.spyOn(room.client, "sendEvent"); - ElementCall.create(room); - await callConnectProcedure(Call.get(room) as ElementCall); - expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", { - "application": "m.call", - "call_id": "", - "m.mentions": { room: true, user_ids: [] }, - "notify_type": "ring", - }); - }); }); describe("instance in a video room", () => { let call: ElementCall; let widget: Widget; let messaging: Mocked; - let audioMutedSpy: jest.SpyInstance; - let videoMutedSpy: jest.SpyInstance; beforeEach(async () => { jest.useFakeTimers(); @@ -1117,64 +929,29 @@ describe("ElementCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call)); + ({ widget, messaging } = setUpWidget(call)); }); - afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); + afterEach(() => cleanUpCallAndWidget(call, widget)); it("doesn't end the call when the last participant leaves", async () => { - await callConnectProcedure(call); + await connect(call, messaging); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await callDisconnectionProcedure(call); + await disconnect(call, messaging); expect(onDestroy).not.toHaveBeenCalled(); call.off(CallEvent.Destroy, onDestroy); }); - it("connect to call with ongoing session", async () => { - // Mock membership getter used by `roomSessionForRoom`. - // This makes sure the roomSession will not be empty. - jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockImplementation(() => [ - { fakeVal: "fake membership", getMsUntilExpiry: () => 1000 } as unknown as CallMembership, - ]); - // Create ongoing session - const roomSession = MatrixRTCSession.roomSessionForRoom(client, room); - const roomSessionEmitSpy = jest.spyOn(roomSession, "emit"); - - // Make sure the created session ends up in the call. - // `getActiveRoomSession` will be used during `call.connect` - // `getRoomSession` will be used during `Call.get` - client.matrixRTC.getActiveRoomSession.mockImplementation(() => { - return roomSession; - }); - client.matrixRTC.getRoomSession.mockImplementation(() => { - return roomSession; - }); - - ElementCall.create(room); - const call = Call.get(room); - if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); - expect(call.session).toBe(roomSession); - await callConnectProcedure(call); - expect(roomSessionEmitSpy).toHaveBeenCalledWith( - "memberships_changed", - [], - [{ sender: "@alice:example.org" }], - ); - expect(call.connectionState).toBe(ConnectionState.Connected); - call.destroy(); - }); - it("handles remote disconnection and reconnect right after", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await callConnectProcedure(call); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); - messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {})); // We should now be able to reconnect without manually starting the widget expect(call.connectionState).toBe(ConnectionState.Disconnected); - await callConnectProcedure(call, false); + await connect(call, messaging, false); await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected), { interval: 5 }); }); }); diff --git a/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts b/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts index 4294f82af2e..a8b8afe7e70 100644 --- a/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts +++ b/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts @@ -28,6 +28,7 @@ import "../../../../../src/stores/room-list/RoomListStore"; // must be imported import { Algorithm } from "../../../../../src/stores/room-list/algorithms/Algorithm"; import { CallStore } from "../../../../../src/stores/CallStore"; import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; +import { ConnectionState } from "../../../../../src/models/Call"; describe("Algorithm", () => { useMockedCalls(); @@ -83,7 +84,7 @@ describe("Algorithm", () => { MockedCall.create(roomWithCall, "1"); const call = CallStore.instance.getCall(roomWithCall.roomId); - if (call === null) throw new Error("Failed to create call"); + if (!(call instanceof MockedCall)) throw new Error("Failed to create call"); const widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, { @@ -93,7 +94,7 @@ describe("Algorithm", () => { // End of setup expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]); - await call.start(); + call.setConnectionState(ConnectionState.Connected); expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]); await call.disconnect(); expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);