diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 87bf84d8c4a..8067cc065ca 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -21,12 +21,11 @@ interface JoinCallViewProps { room: Room; resizing: boolean; call: Call; - skipLobby?: boolean; role?: AriaRole; onClose: () => void; } -const JoinCallView: FC = ({ room, resizing, call, skipLobby, role, onClose }) => { +const JoinCallView: FC = ({ room, resizing, call, role, onClose }) => { const cli = useContext(MatrixClientContext); useTypedEventEmitter(call, CallEvent.Close, onClose); @@ -35,12 +34,6 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, call.clean(); }, [call]); - useEffect(() => { - // Always update the widget data so that we don't ignore "skipLobby" accidentally. - call.widget.data ??= {}; - call.widget.data.skipLobby = skipLobby; - }, [call.widget, skipLobby]); - const disconnectAllOtherCalls: () => Promise = useCallback(async () => { // The stickyPromise has to resolve before the widget actually becomes sticky. // We only let the widget become sticky after disconnecting all other active calls. @@ -69,7 +62,6 @@ const JoinCallView: FC = ({ room, resizing, call, skipLobby, interface CallViewProps { room: Room; resizing: boolean; - skipLobby?: boolean; role?: AriaRole; /** * Callback for when the user closes the call. @@ -77,19 +69,8 @@ interface CallViewProps { onClose: () => void; } -export const CallView: FC = ({ room, resizing, skipLobby, role, onClose }) => { +export const CallView: FC = ({ room, resizing, role, onClose }) => { const call = useCall(room.roomId); - return ( - call && ( - - ) - ); + return call && ; }; diff --git a/src/models/Call.ts b/src/models/Call.ts index 81970da6b1a..13f0fa59f2d 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -97,6 +97,14 @@ interface CallEventHandlerMap { [CallEvent.Destroy]: () => void; } +/** + * Parameters to be passed during widget creation. + * These parameters are hints only, and may not be accepted by the implementation. + */ +export interface WidgetGenerationParameters { + skipLobby?: boolean; +} + /** * A group call accessed through a widget. */ @@ -180,8 +188,8 @@ export abstract class Call extends TypedEventEmitter WidgetType.CALL.matches(app.type)); @@ -689,9 +700,6 @@ export class ElementCall extends Call { // Always update the widget data because even if the widget is already created, // we might have settings changes that update the widget. const overwrites: IWidgetData = {}; - if (skipLobby !== undefined) { - overwrites.skipLobby = skipLobby; - } if (returnToLobby !== undefined) { overwrites.returnToLobby = returnToLobby; } @@ -701,7 +709,7 @@ export class ElementCall extends Call { // To use Element Call without touching room state, we create a virtual // widget (one that doesn't have a corresponding state event) - const url = ElementCall.generateWidgetUrl(client, roomId); + const url = ElementCall.generateWidgetUrl(client, roomId, params); const createdWidget = WidgetStore.instance.addVirtualWidget( { id: secureRandomString(24), // So that it's globally unique @@ -715,7 +723,6 @@ export class ElementCall extends Call { roomId, {}, { - skipLobby: skipLobby ?? false, returnToLobby: returnToLobby ?? false, }, ), @@ -766,7 +773,7 @@ export class ElementCall extends Call { this.updateParticipants(); } - public static get(room: Room): ElementCall | null { + public static get(room: Room, params: WidgetGenerationParameters): ElementCall | null { const apps = WidgetStore.instance.getApps(room.roomId); const hasEcWidget = apps.some((app) => WidgetType.CALL.matches(app.type)); const session = room.client.matrixRTC.getRoomSession(room); @@ -780,7 +787,7 @@ export class ElementCall extends Call { const availableOrCreatedWidget = ElementCall.createOrGetCallWidget( room.roomId, room.client, - undefined, + params, isVideoRoom(room), ); return new ElementCall(session, availableOrCreatedWidget, room.client); @@ -789,8 +796,8 @@ export class ElementCall extends Call { return null; } - public static create(room: Room, skipLobby = false): void { - ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room)); + public static create(room: Room, params: WidgetGenerationParameters = {}): void { + ElementCall.createOrGetCallWidget(room.roomId, room.client, params, isVideoRoom(room)); } public async start(): Promise { diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 6347cc898ed..0f13a22cbad 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -8,8 +8,6 @@ Please see LICENSE files in the repository root for full details. import { logger } from "matrix-js-sdk/src/logger"; import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler"; -import { type MatrixRTCSession, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc"; - import type { EmptyObject, GroupCall, Room } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; import { UPDATE_EVENT } from "./AsyncStore"; @@ -55,7 +53,6 @@ export class CallStore extends AsyncStoreWithClient { } this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); - this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets); // If the room ID of a previously connected call is still in settings at @@ -89,7 +86,6 @@ export class CallStore extends AsyncStoreWithClient { this.matrixClient.off(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); this.matrixClient.off(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); this.matrixClient.off(GroupCallEventHandlerEvent.Ended, this.onGroupCall); - this.matrixClient.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart); } WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets); } @@ -187,7 +183,4 @@ export class CallStore extends AsyncStoreWithClient { }; private onGroupCall = (groupCall: GroupCall): void => this.updateRoom(groupCall.room); - private onRTCSessionStart = (roomId: string, session: MatrixRTCSession): void => { - this.updateRoom(session.room); - }; } diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 50ca69eec37..7be74655fd7 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -359,7 +359,7 @@ export class RoomViewStore extends EventEmitter { let call = CallStore.instance.getCall(payload.room_id); // Start a call if not already there if (call === null) { - ElementCall.create(room, false); + ElementCall.create(room, { skipLobby: payload.skipLobby }); call = CallStore.instance.getCall(payload.room_id)!; } call.presented = true; From fdd6f6caf61034219bd0c00f7525d1fe924022d3 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 10:24:43 +0100 Subject: [PATCH 02/27] adjust text --- test/unit-tests/components/views/voip/CallView-test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/unit-tests/components/views/voip/CallView-test.tsx b/test/unit-tests/components/views/voip/CallView-test.tsx index ca6a50b4184..9c35e79a6ae 100644 --- a/test/unit-tests/components/views/voip/CallView-test.tsx +++ b/test/unit-tests/components/views/voip/CallView-test.tsx @@ -82,13 +82,13 @@ describe("CallView", () => { client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); }); - const renderView = async (skipLobby = false, role: string | undefined = undefined): Promise => { - render( {}} />); + const renderView = async (role: string | undefined = undefined): Promise => { + render( {}} />); await act(() => Promise.resolve()); // Let effects settle }; it("accepts an accessibility role", async () => { - await renderView(undefined, "main"); + await renderView("main"); screen.getByRole("main"); }); @@ -99,7 +99,7 @@ describe("CallView", () => { }); it("updates the call's skipLobby parameter", async () => { - await renderView(true); + await renderView(); expect(call.widget.data?.skipLobby).toBe(true); }); }); From c8b0fb6cfb5c3facd03aae7718754205f97c579e Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 12:49:27 +0100 Subject: [PATCH 03/27] Add tests for Element Call --- playwright/e2e/voip/element-call.spec.ts | 147 +++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 playwright/e2e/voip/element-call.spec.ts diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts new file mode 100644 index 00000000000..eb0cc3d3145 --- /dev/null +++ b/playwright/e2e/voip/element-call.spec.ts @@ -0,0 +1,147 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { EventType, Preset } from "matrix-js-sdk/src/matrix"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { test, expect } from "../../element-web-test"; +import type { Credentials } from "../../plugins/homeserver"; + +function assertCommonCallParameters( + url: URLSearchParams, + hash: URLSearchParams, + user: Credentials, + room: { roomId: string }, +): void { + expect(url.has("widgetId")).toEqual(true); + expect(url.has("parentUrl")).toEqual(true); + + expect(hash.get("confineToRoom")).toEqual("true"); + expect(hash.get("returnToLobby")).toEqual("false"); + expect(hash.get("perParticipantE2EE")).toEqual("false"); + expect(hash.get("header")).toEqual("none"); + expect(hash.get("userId")).toEqual(user.userId); + expect(hash.get("deviceId")).toEqual(user.deviceId); + expect(hash.get("roomId")).toEqual(room.roomId); + expect(hash.get("preload")).toEqual("false"); + expect(hash.has("rageshakeSubmitUrl")).toEqual(true); +} + +test.describe("Element Call", () => { + test.use({ + config: { + element_call: { + use_exclusively: true, + }, + }, + botCreateOpts: { + autoAcceptInvites: true, + displayName: "Bob", + }, + }); + + test.beforeEach(async ({ page, user, app }) => { + // Mock a widget page. It doesn't need to actually be Element Call. + await page.route("/widget.html", async (route) => { + await route.fulfill({ + status: 200, + body: "

Hello world

", + }); + }); + await app.settings.setValue( + "Developer.elementCallUrl", + null, + SettingLevel.DEVICE, + new URL("/widget.html#", page.url()).toString(), + ); + }); + + test.describe("Group Chat", () => { + test.use({ + room: async ({ page, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name: "TestRoom" }); + await use({ roomId }); + }, + }); + test("should be able to start a video call", async ({ page, user, room, app }) => { + await app.viewRoomById(room.roomId); + await page.getByRole("button", { name: "Video call" }).click(); + await page.getByRole("menuitem", { name: "Element Call" }).click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + + // Ensure we set the correct parameters for ECall. + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + expect(hash.get("sendNotificationType")).toEqual("notification"); + expect(hash.get("intent")).toEqual("start_call"); + expect(hash.get("skipLobby")).toEqual("false"); + }); + + test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + await page.getByRole("button", { name: "Video call" }).click(); + await page.keyboard.down("Shift"); + await page.getByRole("menuitem", { name: "Element Call" }).click(); + await page.keyboard.up("Shift"); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + expect(hash.get("sendNotificationType")).toEqual("notification"); + expect(hash.get("intent")).toEqual("start_call"); + expect(hash.get("skipLobby")).toEqual("true"); + }); + }); + + test.describe("DMs", () => { + test.use({ + room: async ({ page, app, user, bot }, use) => { + const roomId = await app.client.createRoom({ + name: "TestRoom", + preset: Preset.TrustedPrivateChat, + invite: [bot.credentials.userId], + }); + await app.client.setAccountData(EventType.Direct, { + [bot.credentials.userId]: [roomId], + }); + await use({ roomId }); + }, + }); + + test("should be able to start a video call", async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + await page.getByRole("button", { name: "Video call" }).click(); + await page.getByRole("menuitem", { name: "Element Call" }).click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + expect(hash.get("sendNotificationType")).toEqual("ring"); + expect(hash.get("intent")).toEqual("start_call_dm"); + expect(hash.get("skipLobby")).toEqual("false"); + }); + + test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); + await page.getByRole("button", { name: "Video call" }).click(); + await page.keyboard.down("Shift"); + await page.getByRole("menuitem", { name: "Element Call" }).click(); + await page.keyboard.up("Shift"); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); + const url = new URL(frameUrlStr); + const hash = new URLSearchParams(url.hash.slice(1)); + assertCommonCallParameters(url.searchParams, hash, user, room); + expect(hash.get("sendNotificationType")).toEqual("ring"); + expect(hash.get("intent")).toEqual("start_call_dm"); + expect(hash.get("skipLobby")).toEqual("true"); + }); + }); +}); From 519403842947de76950674b2faeffd334e48b78f Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 13:04:40 +0100 Subject: [PATCH 04/27] Refactor usages of skipLobby --- playwright/e2e/voip/element-call.spec.ts | 8 ++++---- src/components/views/beacon/RoomCallBanner.tsx | 2 +- src/hooks/room/useRoomCall.tsx | 4 ++-- src/models/Call.ts | 3 --- src/stores/RoomViewStore.tsx | 4 ---- src/utils/room/placeCall.ts | 2 +- 6 files changed, 8 insertions(+), 15 deletions(-) diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index eb0cc3d3145..19f7fecc50c 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -79,7 +79,7 @@ test.describe("Element Call", () => { assertCommonCallParameters(url.searchParams, hash, user, room); expect(hash.get("sendNotificationType")).toEqual("notification"); expect(hash.get("intent")).toEqual("start_call"); - expect(hash.get("skipLobby")).toEqual("false"); + expect(hash.get("skipLobby")).toEqual(null); }); test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => { @@ -114,7 +114,7 @@ test.describe("Element Call", () => { }, }); - test("should be able to start a video call", async ({ page, user, bot, room, app }) => { + test("should be able to start a video call", async ({ page, user, room, app }) => { await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.getByRole("menuitem", { name: "Element Call" }).click(); @@ -125,10 +125,10 @@ test.describe("Element Call", () => { assertCommonCallParameters(url.searchParams, hash, user, room); expect(hash.get("sendNotificationType")).toEqual("ring"); expect(hash.get("intent")).toEqual("start_call_dm"); - expect(hash.get("skipLobby")).toEqual("false"); + expect(hash.get("skipLobby")).toEqual(null); }); - test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => { + test("should be able to skip lobby by holding down shift", async ({ page, user, room, app }) => { await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.keyboard.down("Shift"); diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index e4f0dfa6086..0d405f82084 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -35,7 +35,7 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) => action: Action.ViewRoom, room_id: roomId, view_call: true, - skipLobby: "shiftKey" in ev ? ev.shiftKey : false, + skipLobby: "shiftKey" in ev ? ev.shiftKey : undefined, metricsTrigger: undefined, }); }, diff --git a/src/hooks/room/useRoomCall.tsx b/src/hooks/room/useRoomCall.tsx index 5e7b48ee543..b01c194605c 100644 --- a/src/hooks/room/useRoomCall.tsx +++ b/src/hooks/room/useRoomCall.tsx @@ -229,7 +229,7 @@ export const useRoomCall = ( if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { - placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey ?? false); + placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined); } }, [promptPinWidget, room, widget], @@ -240,7 +240,7 @@ export const useRoomCall = ( if (widget && promptPinWidget) { WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top); } else { - placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey ?? false); + placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined); } }, [widget, promptPinWidget, room], diff --git a/src/models/Call.ts b/src/models/Call.ts index 13f0fa59f2d..a21d1fb98bd 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -686,9 +686,6 @@ 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 - // When there is already a widget the current widget configuration will be used or can be overwritten - // by passing the according parameters (skipLobby). private static createOrGetCallWidget( roomId: string, client: MatrixClient, diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 7be74655fd7..7483eeb680e 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -739,10 +739,6 @@ export class RoomViewStore extends EventEmitter { return this.state.viewingCall; } - public skipCallLobby(): boolean | undefined { - return this.state.skipLobby; - } - /** * Gets the current state of the 'promptForAskToJoin' property. * diff --git a/src/utils/room/placeCall.ts b/src/utils/room/placeCall.ts index 1f0d67c1e6f..3c7658f1d99 100644 --- a/src/utils/room/placeCall.ts +++ b/src/utils/room/placeCall.ts @@ -26,7 +26,7 @@ export const placeCall = async ( room: Room, callType: CallType, platformCallType: PlatformCallType, - skipLobby: boolean, + skipLobby: boolean | undefined, ): Promise => { const { analyticsName } = getPlatformCallTypeProps(platformCallType); PosthogTrackers.trackInteraction(analyticsName); From 25ec93e8e0fa75f1d500f133562749818634a6d2 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 13:09:32 +0100 Subject: [PATCH 05/27] remove unrequired test --- test/unit-tests/components/views/voip/CallView-test.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/unit-tests/components/views/voip/CallView-test.tsx b/test/unit-tests/components/views/voip/CallView-test.tsx index 9c35e79a6ae..2b971a02528 100644 --- a/test/unit-tests/components/views/voip/CallView-test.tsx +++ b/test/unit-tests/components/views/voip/CallView-test.tsx @@ -97,9 +97,4 @@ describe("CallView", () => { await renderView(); expect(cleanSpy).toHaveBeenCalled(); }); - - it("updates the call's skipLobby parameter", async () => { - await renderView(); - expect(call.widget.data?.skipLobby).toBe(true); - }); }); From 6c9255bed46a92b133ff00fdc65fe14506f9732e Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 13:25:13 +0100 Subject: [PATCH 06/27] lint --- playwright/e2e/voip/element-call.spec.ts | 6 +++--- src/stores/CallStore.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index 19f7fecc50c..9936415d8d6 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { EventType, Preset } from "matrix-js-sdk/src/matrix"; +import type { EventType, Preset } from "matrix-js-sdk/src/matrix"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { test, expect } from "../../element-web-test"; import type { Credentials } from "../../plugins/homeserver"; @@ -104,10 +104,10 @@ test.describe("Element Call", () => { room: async ({ page, app, user, bot }, use) => { const roomId = await app.client.createRoom({ name: "TestRoom", - preset: Preset.TrustedPrivateChat, + preset: "trusted_private_chat" as Preset.TrustedPrivateChat, invite: [bot.credentials.userId], }); - await app.client.setAccountData(EventType.Direct, { + await app.client.setAccountData("m.direct" as EventType.Direct, { [bot.credentials.userId]: [roomId], }); await use({ roomId }); diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 0f13a22cbad..9ec89899a51 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import { logger } from "matrix-js-sdk/src/logger"; import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler"; + import type { EmptyObject, GroupCall, Room } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; import { UPDATE_EVENT } from "./AsyncStore"; From bc50fd1cc7ed1284089442c05151aac9b10bd2b6 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 13:33:40 +0100 Subject: [PATCH 07/27] update test --- src/models/Call.ts | 2 +- test/unit-tests/models/Call-test.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index a21d1fb98bd..7a3edc07742 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -770,7 +770,7 @@ export class ElementCall extends Call { this.updateParticipants(); } - public static get(room: Room, params: WidgetGenerationParameters): ElementCall | null { + public static get(room: Room, params: WidgetGenerationParameters = {}): ElementCall | null { const apps = WidgetStore.instance.getApps(room.roomId); const hasEcWidget = apps.some((app) => WidgetType.CALL.matches(app.type)); const session = room.client.matrixRTC.getRoomSession(room); diff --git a/test/unit-tests/models/Call-test.ts b/test/unit-tests/models/Call-test.ts index a91f951be33..ea33cfbcd14 100644 --- a/test/unit-tests/models/Call-test.ts +++ b/test/unit-tests/models/Call-test.ts @@ -817,6 +817,15 @@ describe("ElementCall", () => { const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); expect(urlParams.get("sendNotificationType")).toBe("notification"); }); + + it("requests to skip lobby in params", async () => { + ElementCall.create(room, { skipLobby: true }); + const call = Call.get(room); + if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); + + const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); + expect(urlParams.get("skipLobby")).toBe("true"); + }); }); describe("instance in a non-video room", () => { @@ -828,7 +837,7 @@ describe("ElementCall", () => { jest.useFakeTimers(); jest.setSystemTime(0); - ElementCall.create(room, true); + ElementCall.create(room, { skipLobby: true }); const maybeCall = ElementCall.get(room); if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; From 511a1801fea2fb069771025fa6219bc5c1cb9089 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 14:04:07 +0100 Subject: [PATCH 08/27] Add a test --- .../views/beacon/RoomCallBanner-test.tsx | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx index 38cb77eb03f..439260fd7d5 100644 --- a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx @@ -13,6 +13,8 @@ import { type MatrixClient, type RoomMember, RoomStateEvent, + Beacon, + BeaconIdentifier, } from "matrix-js-sdk/src/matrix"; import { type ClientWidgetApi, Widget } from "matrix-widget-api"; import { act, cleanup, render, screen } from "jest-matrix-react"; @@ -31,6 +33,26 @@ import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMe import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { ConnectionState } from "../../../../../src/models/Call"; import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; +import { OwnBeaconStore } from "../../../../../src/stores/OwnBeaconStore"; + +jest.mock("../../../../../src/stores/OwnBeaconStore", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const EventEmitter = require("events"); + class MockOwnBeaconStore extends EventEmitter { + public getLiveBeaconIdsWithLocationPublishError = jest.fn().mockReturnValue([]); + public getBeaconById = jest.fn(); + public getLiveBeaconIds = jest.fn().mockReturnValue([{}]); + public readonly beaconUpdateErrors = new Map(); + public readonly beacons = new Map(); + } + return { + // @ts-ignore + ...jest.requireActual("../../../../../src/stores/OwnBeaconStore"), + OwnBeaconStore: { + instance: new MockOwnBeaconStore() as unknown as OwnBeaconStore, + }, + }; +}); describe("", () => { let client: Mocked; @@ -65,6 +87,10 @@ describe("", () => { client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); }); + afterAll(() => { + jest.restoreAllMocks(); + }); + const renderBanner = async (props = {}): Promise => { render(); await act(() => Promise.resolve()); // Let effects settle @@ -116,6 +142,15 @@ describe("", () => { expect(banner).toBeFalsy(); }); + it("doesn't show banner if live location is ongoing", async () => { + // @ts-ignore writing to readonly variable + mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = true; + call.setConnectionState(ConnectionState.Disconnected); + await renderBanner(); + const banner = await screen.queryByText("Video call"); + expect(banner).toBeFalsy(); + }); + it("doesn't show banner if the call is shown", async () => { jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall"); mocked(SdkContextClass.instance.roomViewStore.isViewingCall).mockReturnValue(true); @@ -126,5 +161,4 @@ describe("", () => { }); // TODO: test clicking buttons - // TODO: add live location share warning test (should not render if there is an active live location share) }); From 4a2cd6d736a449e9c74467cd83dac53aae8381ef Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 14:04:11 +0100 Subject: [PATCH 09/27] Reduce complexity of functiojnm --- src/models/Call.ts | 127 ++++++++++++++++++++++++--------------------- 1 file changed, 68 insertions(+), 59 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 7a3edc07742..d5a54dcc517 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -574,12 +574,72 @@ export class ElementCall extends Call { this.checkDestroy(); } - private static generateWidgetUrl(client: MatrixClient, roomId: string, opts: WidgetGenerationParameters = {}): URL { - const baseUrl = window.location.href; - let url = new URL("./widgets/element-call/index.html#", baseUrl); // this strips hash fragment from baseUrl + private static appendCallNotifIntent(params: URLSearchParams, client: MatrixClient, roomId: string): void { + const room = client.getRoom(roomId); + if (!room || isVideoRoom(room)) { + // If the room isn't known, or the room is a video room then skip setting an intent. + return; + } + const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); + const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership(); + const hasCallStarted = !!oldestCallMember && oldestCallMember.sender !== client.getSafeUserId(); + // XXX: @element-hq/element-call-embedded <= 0.15.0 sets the wrong parameter for + // preload by default so we override here. This can be removed when that package + // is released and upgraded. + if (isDM) { + params.append("sendNotificationType", "ring"); + if (hasCallStarted) { + params.append("intent", ElementCallIntent.JoinExistingDM); + params.append("preload", "false"); + } else { + params.append("intent", ElementCallIntent.StartCallDM); + params.append("preload", "false"); + } + } else { + params.append("sendNotificationType", "notification"); + if (hasCallStarted) { + params.append("intent", ElementCallIntent.JoinExisting); + params.append("preload", "false"); + } else { + params.append("intent", ElementCallIntent.StartCall); + params.append("preload", "false"); + } + } + } + + private static appendPosthogParams(params: URLSearchParams, client: MatrixClient) { + const posthogConfig = SdkConfig.get("posthog"); + if (!posthogConfig || PosthogAnalytics.instance.getAnonymity() === Anonymity.Disabled) { + return; + } + + const accountAnalyticsData = client.getAccountData(PosthogAnalytics.ANALYTICS_EVENT_TYPE)?.getContent(); + // The analyticsID is passed directly to element call (EC) since this codepath is only for EC and no other widget. + // We really don't want the same analyticID's for the EC and EW posthog instances (Data on posthog should be limited/anonymized as much as possible). + // This is prohibited in EC where a hashed version of the analyticsID is used for the actual posthog identification. + // We can pass the raw EW analyticsID here since we need to trust EC with not sending sensitive data to posthog (EC has access to more sensible data than the analyticsID e.g. the username) + const analyticsID: string = accountAnalyticsData?.pseudonymousAnalyticsOptIn ? accountAnalyticsData?.id : ""; + + params.append("analyticsID", analyticsID); // Legacy, deprecated in favour of posthogUserId + params.append("posthogUserId", analyticsID); + params.append("posthogApiHost", posthogConfig.api_host); + params.append("posthogApiKey", posthogConfig.project_api_key); + + // We gate passing sentry behind analytics consent as EC shares data automatically without user-consent, + // unlike EW where data is shared upon an intentional user action (rageshake). + const sentryConfig = SdkConfig.get("sentry"); + if (sentryConfig) { + params.append("sentryDsn", sentryConfig.dsn); + params.append("sentryEnvironment", sentryConfig.environment ?? ""); + } + } - const elementCallUrl = SettingsStore.getValue("Developer.elementCallUrl"); - if (elementCallUrl) url = new URL(elementCallUrl); + private static generateWidgetUrl(client: MatrixClient, roomId: string, opts: WidgetGenerationParameters = {}): URL { + const elementCallUrlOverride = SettingsStore.getValue("Developer.elementCallUrl"); + const url = elementCallUrlOverride + ? new URL(elementCallUrlOverride) + : // this strips hash fragment from baseUrl + new URL("./widgets/element-call/index.html#", window.location.href); // Splice together the Element Call URL for this call const params = new URLSearchParams({ @@ -601,68 +661,15 @@ export class ElementCall extends Call { params.set("skipLobby", opts.skipLobby.toString()); } - const room = client.getRoom(roomId); - if (room !== null && !isVideoRoom(room)) { - const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); - const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership(); - const hasCallStarted = !!oldestCallMember && oldestCallMember.sender !== client.getSafeUserId(); - // XXX: @element-hq/element-call-embedded <= 0.15.0 sets the wrong parameter for - // preload by default so we override here. This can be removed when that package - // is released and upgraded. - if (isDM) { - params.append("sendNotificationType", "ring"); - if (hasCallStarted) { - params.append("intent", ElementCallIntent.JoinExistingDM); - params.append("preload", "false"); - } else { - params.append("intent", ElementCallIntent.StartCallDM); - params.append("preload", "false"); - } - } else { - params.append("sendNotificationType", "notification"); - if (hasCallStarted) { - params.append("intent", ElementCallIntent.JoinExisting); - params.append("preload", "false"); - } else { - params.append("intent", ElementCallIntent.StartCall); - params.append("preload", "false"); - } - } - } - const rageshakeSubmitUrl = SdkConfig.get("bug_report_endpoint_url"); if (rageshakeSubmitUrl) { params.append("rageshakeSubmitUrl", rageshakeSubmitUrl); } - const posthogConfig = SdkConfig.get("posthog"); - if (posthogConfig && PosthogAnalytics.instance.getAnonymity() !== Anonymity.Disabled) { - const accountAnalyticsData = client.getAccountData(PosthogAnalytics.ANALYTICS_EVENT_TYPE)?.getContent(); - // The analyticsID is passed directly to element call (EC) since this codepath is only for EC and no other widget. - // We really don't want the same analyticID's for the EC and EW posthog instances (Data on posthog should be limited/anonymized as much as possible). - // This is prohibited in EC where a hashed version of the analyticsID is used for the actual posthog identification. - // We can pass the raw EW analyticsID here since we need to trust EC with not sending sensitive data to posthog (EC has access to more sensible data than the analyticsID e.g. the username) - const analyticsID: string = accountAnalyticsData?.pseudonymousAnalyticsOptIn - ? accountAnalyticsData?.id - : ""; - - params.append("analyticsID", analyticsID); // Legacy, deprecated in favour of posthogUserId - params.append("posthogUserId", analyticsID); - params.append("posthogApiHost", posthogConfig.api_host); - params.append("posthogApiKey", posthogConfig.project_api_key); - - // We gate passing sentry behind analytics consent as EC shares data automatically without user-consent, - // unlike EW where data is shared upon an intentional user action (rageshake). - const sentryConfig = SdkConfig.get("sentry"); - if (sentryConfig) { - params.append("sentryDsn", sentryConfig.dsn); - params.append("sentryEnvironment", sentryConfig.environment ?? ""); - } - } - if (SettingsStore.getValue("fallbackICEServerAllowed")) { params.append("allowIceFallback", "true"); } + if (SettingsStore.getValue("feature_allow_screen_share_only_mode")) { params.append("allowVoipWithNoMedia", "true"); } @@ -679,6 +686,8 @@ export class ElementCall extends Call { }) .forEach((font) => params.append("font", font)); } + this.appendPosthogParams(params, client); + this.appendCallNotifIntent(params, client, roomId); const replacedUrl = params.toString().replace(/%24/g, "$"); url.hash = `#?${replacedUrl}`; From 873f8b7c6366ce63c342458662327144e3a40842 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 14:31:28 +0100 Subject: [PATCH 10/27] Document functions --- src/models/Call.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index d5a54dcc517..fb97a40a478 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -574,6 +574,14 @@ export class ElementCall extends Call { this.checkDestroy(); } + /** + * Calculate the correct intent (and associated parameters) for an Element Call room. Paarameters + * will be applied to the `params` instance. + * + * @param params Existing URL parameters + * @param client The current client. + * @param roomId The room ID for the call. + */ private static appendCallNotifIntent(params: URLSearchParams, client: MatrixClient, roomId: string): void { const room = client.getRoom(roomId); if (!room || isVideoRoom(room)) { @@ -607,7 +615,14 @@ export class ElementCall extends Call { } } - private static appendPosthogParams(params: URLSearchParams, client: MatrixClient) { + /** + * Calculate the correct analytics parameters for an Element Call room. Paarameters + * will be applied to the `params` instance. + * + * @param params Existing URL parameters + * @param client The current client. + */ + private static appendAnalyticsParams(params: URLSearchParams, client: MatrixClient): void { const posthogConfig = SdkConfig.get("posthog"); if (!posthogConfig || PosthogAnalytics.instance.getAnonymity() === Anonymity.Disabled) { return; @@ -634,6 +649,15 @@ export class ElementCall extends Call { } } + /** + * Generate the correct Element Call widget URL for creating or joining a call in this room. + * Unless `Developer.elementCallUrl` is set, the widget will use the embedded Element Call package. + * + * @param client + * @param roomId + * @param opts + * @returns + */ private static generateWidgetUrl(client: MatrixClient, roomId: string, opts: WidgetGenerationParameters = {}): URL { const elementCallUrlOverride = SettingsStore.getValue("Developer.elementCallUrl"); const url = elementCallUrlOverride @@ -686,7 +710,7 @@ export class ElementCall extends Call { }) .forEach((font) => params.append("font", font)); } - this.appendPosthogParams(params, client); + this.appendAnalyticsParams(params, client); this.appendCallNotifIntent(params, client, roomId); const replacedUrl = params.toString().replace(/%24/g, "$"); From 26e51a74cb87aef3e77de442bd160a34002425bb Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 14:46:50 +0100 Subject: [PATCH 11/27] Remove parameters now part of intent --- playwright/e2e/voip/element-call.spec.ts | 2 -- src/models/Call.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index 9936415d8d6..e66267f9633 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -19,10 +19,8 @@ function assertCommonCallParameters( expect(url.has("widgetId")).toEqual(true); expect(url.has("parentUrl")).toEqual(true); - expect(hash.get("confineToRoom")).toEqual("true"); expect(hash.get("returnToLobby")).toEqual("false"); expect(hash.get("perParticipantE2EE")).toEqual("false"); - expect(hash.get("header")).toEqual("none"); expect(hash.get("userId")).toEqual(user.userId); expect(hash.get("deviceId")).toEqual(user.deviceId); expect(hash.get("roomId")).toEqual(room.roomId); diff --git a/src/models/Call.ts b/src/models/Call.ts index fb97a40a478..80976620468 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -667,11 +667,9 @@ export class ElementCall extends Call { // Splice together the Element Call URL for this call const params = new URLSearchParams({ - confineToRoom: "true", // Only show the call interface for the configured room // Template variables are used, so that this can be configured using the widget data. returnToLobby: "$returnToLobby", // Returns to the lobby (instead of blank screen) when the call ends. (For video rooms) perParticipantE2EE: "$perParticipantE2EE", - header: "none", // Hide the header since our room header is enough userId: client.getUserId()!, deviceId: client.getDeviceId()!, roomId: roomId, From 236120c089d99448cb5bc509b2cde30be1697acf Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 14:47:52 +0100 Subject: [PATCH 12/27] document --- src/stores/CallStore.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 9ec89899a51..325cbda8b61 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -46,14 +46,10 @@ export class CallStore extends AsyncStoreWithClient { protected async onReady(): Promise { if (!this.matrixClient) return; - // We assume that the calls present in a room are a function of room - // widgets and group calls, so we initialize the room map here and then - // update it whenever those change - for (const room of this.matrixClient.getRooms()) { - this.updateRoom(room); - } this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); + + // Whenever a widget gets updated, we want to recheck the list of calls. WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets); // If the room ID of a previously connected call is still in settings at From 59713426a333b03deb1ece266d5b24441329b88e Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 14:50:08 +0100 Subject: [PATCH 13/27] Update matrixClient usage --- src/stores/CallStore.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 325cbda8b61..58467c088b2 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -45,9 +45,9 @@ export class CallStore extends AsyncStoreWithClient { } protected async onReady(): Promise { - if (!this.matrixClient) return; - this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); - this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); + // Legacy calls only + this.matrixClient?.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); + this.matrixClient?.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); // Whenever a widget gets updated, we want to recheck the list of calls. WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets); @@ -79,11 +79,9 @@ export class CallStore extends AsyncStoreWithClient { this.calls.clear(); this._connectedCalls.clear(); - if (this.matrixClient) { - this.matrixClient.off(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); - this.matrixClient.off(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); - this.matrixClient.off(GroupCallEventHandlerEvent.Ended, this.onGroupCall); - } + this.matrixClient?.off(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); + this.matrixClient?.off(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); + this.matrixClient?.off(GroupCallEventHandlerEvent.Ended, this.onGroupCall); WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets); } From 85713e55cf8602c2658a567aaa2c35f48a76a03b Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 14:52:01 +0100 Subject: [PATCH 14/27] update logic --- src/components/views/beacon/RoomCallBanner.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index 0d405f82084..35f3d6973d9 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -35,7 +35,8 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) => action: Action.ViewRoom, room_id: roomId, view_call: true, - skipLobby: "shiftKey" in ev ? ev.shiftKey : undefined, + // If shift is held down, always skip lobby. Else, use defaults. + skipLobby: ("shiftKey" in ev && ev.shiftKey) || undefined, metricsTrigger: undefined, }); }, From 683daa49d94391cb133fa434a3e54f61c0a8a7c1 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 14:52:46 +0100 Subject: [PATCH 15/27] rename --- src/models/Call.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 80976620468..96e665a176e 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -582,7 +582,7 @@ export class ElementCall extends Call { * @param client The current client. * @param roomId The room ID for the call. */ - private static appendCallNotifIntent(params: URLSearchParams, client: MatrixClient, roomId: string): void { + private static appendRoomParams(params: URLSearchParams, client: MatrixClient, roomId: string): void { const room = client.getRoom(roomId); if (!room || isVideoRoom(room)) { // If the room isn't known, or the room is a video room then skip setting an intent. @@ -709,7 +709,7 @@ export class ElementCall extends Call { .forEach((font) => params.append("font", font)); } this.appendAnalyticsParams(params, client); - this.appendCallNotifIntent(params, client, roomId); + this.appendRoomParams(params, client, roomId); const replacedUrl = params.toString().replace(/%24/g, "$"); url.hash = `#?${replacedUrl}`; From c8195293af42bad78987e92e6a249460b0f05e75 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 14:58:53 +0100 Subject: [PATCH 16/27] refactor returnToLobby --- src/models/Call.ts | 46 +++++++++++++--------------------------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 96e665a176e..acc51c4dc4d 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -102,6 +102,9 @@ interface CallEventHandlerMap { * These parameters are hints only, and may not be accepted by the implementation. */ export interface WidgetGenerationParameters { + /** + * Skip showing the lobby screen of a call. + */ skipLobby?: boolean; } @@ -584,9 +587,12 @@ export class ElementCall extends Call { */ private static appendRoomParams(params: URLSearchParams, client: MatrixClient, roomId: string): void { const room = client.getRoom(roomId); - if (!room || isVideoRoom(room)) { + if (!room) { // If the room isn't known, or the room is a video room then skip setting an intent. return; + } else if (isVideoRoom(room)) { + // Video call rooms always return to the lobby. + params.append("returnToLobby", "true"); } const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership(); @@ -668,7 +674,6 @@ export class ElementCall extends Call { // Splice together the Element Call URL for this call const params = new URLSearchParams({ // Template variables are used, so that this can be configured using the widget data. - returnToLobby: "$returnToLobby", // Returns to the lobby (instead of blank screen) when the call ends. (For video rooms) perParticipantE2EE: "$perParticipantE2EE", userId: client.getUserId()!, deviceId: client.getDeviceId()!, @@ -721,17 +726,10 @@ export class ElementCall extends Call { roomId: string, client: MatrixClient, params: WidgetGenerationParameters = {}, - returnToLobby: boolean | undefined, ): IApp { const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type)); if (ecWidget) { - // Always update the widget data because even if the widget is already created, - // we might have settings changes that update the widget. - const overwrites: IWidgetData = {}; - if (returnToLobby !== undefined) { - overwrites.returnToLobby = returnToLobby; - } - ecWidget.data = ElementCall.getWidgetData(client, roomId, ecWidget?.data ?? {}, overwrites); + ecWidget.data = ElementCall.getWidgetData(client, roomId, ecWidget?.data ?? {}); return ecWidget; } @@ -746,14 +744,7 @@ export class ElementCall extends Call { type: WidgetType.CALL.preferred, url: url.toString(), waitForIframeLoad: false, - data: ElementCall.getWidgetData( - client, - roomId, - {}, - { - returnToLobby: returnToLobby ?? false, - }, - ), + data: ElementCall.getWidgetData(client, roomId, {}), }, roomId, ); @@ -761,12 +752,7 @@ export class ElementCall extends Call { return createdWidget; } - private static getWidgetData( - client: MatrixClient, - roomId: string, - currentData: IWidgetData, - overwriteData: IWidgetData, - ): IWidgetData { + private static getWidgetData(client: MatrixClient, roomId: string, currentData: IWidgetData): IWidgetData { let perParticipantE2EE = false; if ( client.getRoom(roomId)?.hasEncryptionStateEvent() && @@ -775,13 +761,12 @@ export class ElementCall extends Call { perParticipantE2EE = true; return { ...currentData, - ...overwriteData, perParticipantE2EE, }; } private onCallEncryptionSettingsChange(): void { - this.widget.data = ElementCall.getWidgetData(this.client, this.roomId, this.widget.data ?? {}, {}); + this.widget.data = ElementCall.getWidgetData(this.client, this.roomId, this.widget.data ?? {}); } private constructor( @@ -812,12 +797,7 @@ export class ElementCall extends Call { // - or this is a call room. Then we also always want to show a call. if (hasEcWidget || session.memberships.length !== 0 || room.isCallRoom()) { // create a widget for the case we are joining a running call and don't have on yet. - const availableOrCreatedWidget = ElementCall.createOrGetCallWidget( - room.roomId, - room.client, - params, - isVideoRoom(room), - ); + const availableOrCreatedWidget = ElementCall.createOrGetCallWidget(room.roomId, room.client, params); return new ElementCall(session, availableOrCreatedWidget, room.client); } @@ -825,7 +805,7 @@ export class ElementCall extends Call { } public static create(room: Room, params: WidgetGenerationParameters = {}): void { - ElementCall.createOrGetCallWidget(room.roomId, room.client, params, isVideoRoom(room)); + ElementCall.createOrGetCallWidget(room.roomId, room.client, params); } public async start(): Promise { From 2c591ce36147a5b1909dcfdc285b05c131971741 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 14:59:17 +0100 Subject: [PATCH 17/27] fixup --- playwright/e2e/voip/element-call.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index e66267f9633..0173a365c1c 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -19,13 +19,14 @@ function assertCommonCallParameters( expect(url.has("widgetId")).toEqual(true); expect(url.has("parentUrl")).toEqual(true); - expect(hash.get("returnToLobby")).toEqual("false"); expect(hash.get("perParticipantE2EE")).toEqual("false"); expect(hash.get("userId")).toEqual(user.userId); expect(hash.get("deviceId")).toEqual(user.deviceId); expect(hash.get("roomId")).toEqual(room.roomId); expect(hash.get("preload")).toEqual("false"); + expect(hash.has("rageshakeSubmitUrl")).toEqual(true); + expect(hash.has("returnToLobby")).toEqual(false); } test.describe("Element Call", () => { From 4853966898c89237a8311772b848e1e5843c4218 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 15:03:18 +0100 Subject: [PATCH 18/27] fix lint --- .../components/views/beacon/RoomCallBanner-test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx index 439260fd7d5..cc533eb9a1d 100644 --- a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx @@ -13,8 +13,8 @@ import { type MatrixClient, type RoomMember, RoomStateEvent, - Beacon, - BeaconIdentifier, + type Beacon, + type BeaconIdentifier, } from "matrix-js-sdk/src/matrix"; import { type ClientWidgetApi, Widget } from "matrix-widget-api"; import { act, cleanup, render, screen } from "jest-matrix-react"; From 844d5467d7773f5beee361a991be0533ce7f3933 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 15:26:29 +0100 Subject: [PATCH 19/27] Fix tests skipping condition --- .../views/beacon/RoomCallBanner-test.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx index cc533eb9a1d..4eaf70bc6b8 100644 --- a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx @@ -13,12 +13,10 @@ import { type MatrixClient, type RoomMember, RoomStateEvent, - type Beacon, - type BeaconIdentifier, } from "matrix-js-sdk/src/matrix"; import { type ClientWidgetApi, Widget } from "matrix-widget-api"; import { act, cleanup, render, screen } from "jest-matrix-react"; -import { mocked, type Mocked } from "jest-mock"; +import { mocked, type MockedObject, type Mocked } from "jest-mock"; import { mkRoomMember, @@ -39,11 +37,8 @@ jest.mock("../../../../../src/stores/OwnBeaconStore", () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const EventEmitter = require("events"); class MockOwnBeaconStore extends EventEmitter { - public getLiveBeaconIdsWithLocationPublishError = jest.fn().mockReturnValue([]); - public getBeaconById = jest.fn(); public getLiveBeaconIds = jest.fn().mockReturnValue([{}]); - public readonly beaconUpdateErrors = new Map(); - public readonly beacons = new Map(); + public isMonitoringLiveLocation = false; } return { // @ts-ignore @@ -105,6 +100,7 @@ describe("", () => { describe("call started", () => { let call: MockedCall; let widget: Widget; + let beaconStore: MockedObject; beforeEach(() => { MockedCall.create(room, "1"); @@ -118,6 +114,10 @@ describe("", () => { WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, } as unknown as ClientWidgetApi); + beaconStore = mocked(OwnBeaconStore.instance); + beaconStore.getLiveBeaconIds.mockReturnValue([]); + // @ts-ignore writing to mock + beaconStore.isMonitoringLiveLocation = false; }); afterEach(() => { cleanup(); // Unmount before we do any cleanup that might update the component @@ -143,8 +143,9 @@ describe("", () => { }); it("doesn't show banner if live location is ongoing", async () => { - // @ts-ignore writing to readonly variable - mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = true; + beaconStore.getLiveBeaconIds.mockReturnValue(["abcdef"]); + // @ts-ignore Writing to readonly value + beaconStore.isMonitoringLiveLocation = true; call.setConnectionState(ConnectionState.Disconnected); await renderBanner(); const banner = await screen.queryByText("Video call"); From b800c514382c1af4d1b7327da2e0d624e6414afe Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 16:05:05 +0100 Subject: [PATCH 20/27] Add more tests to make the angery cloud happy --- .../views/beacon/RoomCallBanner.tsx | 3 +- .../views/beacon/RoomCallBanner-test.tsx | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index 35f3d6973d9..73eb80702ba 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -12,7 +12,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; -import defaultDispatcher from "../../../dispatcher/dispatcher"; +import defaultDispatcher, { type MatrixDispatcher } from "../../../dispatcher/dispatcher"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; import { ConnectionState, type ElementCall } from "../../../models/Call"; @@ -80,6 +80,7 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) => interface Props { roomId: Room["roomId"]; + dispatcher?: MatrixDispatcher; } const RoomCallBanner: React.FC = ({ roomId }) => { diff --git a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx index 4eaf70bc6b8..9cc80a018ef 100644 --- a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx @@ -32,6 +32,9 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { ConnectionState } from "../../../../../src/models/Call"; import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; import { OwnBeaconStore } from "../../../../../src/stores/OwnBeaconStore"; +import userEvent from "@testing-library/user-event"; +import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../../src/dispatcher/actions"; jest.mock("../../../../../src/stores/OwnBeaconStore", () => { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -49,6 +52,8 @@ jest.mock("../../../../../src/stores/OwnBeaconStore", () => { }; }); +jest.mock("../../../../../src/dispatcher/dispatcher"); + describe("", () => { let client: Mocked; let room: Room; @@ -135,6 +140,41 @@ describe("", () => { await screen.findByText("Join"); }); + it("joins the call when Join is clicked", async () => { + const user = userEvent.setup(); + const dispatcherSpy = jest.fn(); + defaultDispatcher.dispatch = dispatcherSpy; + await renderBanner(); + const button = await screen.findByText("Join"); + await user.click(button); + + expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + skipLobby: undefined, + metricsTrigger: undefined, + }); + }); + + it("joins the call and skips lobby while shift is held", async () => { + const user = userEvent.setup(); + const dispatcherSpy = jest.fn(); + defaultDispatcher.dispatch = dispatcherSpy; + await renderBanner(); + const button = await screen.findByText("Join"); + await user.keyboard("[ShiftLeft>]"); // Press Shift (without releasing it) + await user.click(button); + + expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + skipLobby: true, + metricsTrigger: undefined, + }); + }); + it("doesn't show banner if the call is connected", async () => { call.setConnectionState(ConnectionState.Connected); await renderBanner(); From 5ab9c04bd9da3b41b3aa1b4cc8893baab1bdc5cb Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 16:57:13 +0100 Subject: [PATCH 21/27] lint --- test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx index 9cc80a018ef..24d9631930f 100644 --- a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx @@ -17,6 +17,7 @@ import { import { type ClientWidgetApi, Widget } from "matrix-widget-api"; import { act, cleanup, render, screen } from "jest-matrix-react"; import { mocked, type MockedObject, type Mocked } from "jest-mock"; +import userEvent from "@testing-library/user-event"; import { mkRoomMember, @@ -32,7 +33,6 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { ConnectionState } from "../../../../../src/models/Call"; import { SdkContextClass } from "../../../../../src/contexts/SDKContext"; import { OwnBeaconStore } from "../../../../../src/stores/OwnBeaconStore"; -import userEvent from "@testing-library/user-event"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; From 086007ca29e655ef976c9e56416300684e6bc653 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 17:00:58 +0100 Subject: [PATCH 22/27] Wait for Bob to join --- playwright/e2e/voip/element-call.spec.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index 0173a365c1c..1e1c45a543e 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -61,17 +61,19 @@ test.describe("Element Call", () => { test.describe("Group Chat", () => { test.use({ room: async ({ page, app, user, bot }, use) => { - const roomId = await app.client.createRoom({ name: "TestRoom" }); + const roomId = await app.client.createRoom({ name: "TestRoom", invite: [user.userId] }); await use({ roomId }); }, }); test("should be able to start a video call", async ({ page, user, room, app }) => { + await expect(page.getByText("Bob joined the room")).toBeVisible(); + await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.getByRole("menuitem", { name: "Element Call" }).click(); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); await expect(frameUrlStr).toBeDefined(); - // Ensure we set the correct parameters for ECall. const url = new URL(frameUrlStr); const hash = new URLSearchParams(url.hash.slice(1)); @@ -82,11 +84,14 @@ test.describe("Element Call", () => { }); test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => { + await expect(page.getByText("Bob joined the room")).toBeVisible(); + await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.keyboard.down("Shift"); await page.getByRole("menuitem", { name: "Element Call" }).click(); await page.keyboard.up("Shift"); + const frameUrlStr = await page.locator("iframe").getAttribute("src"); await expect(frameUrlStr).toBeDefined(); const url = new URL(frameUrlStr); @@ -114,10 +119,13 @@ test.describe("Element Call", () => { }); test("should be able to start a video call", async ({ page, user, room, app }) => { + await expect(page.getByText("Bob joined the room")).toBeVisible(); + await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.getByRole("menuitem", { name: "Element Call" }).click(); const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); const url = new URL(frameUrlStr); const hash = new URLSearchParams(url.hash.slice(1)); @@ -128,12 +136,15 @@ test.describe("Element Call", () => { }); test("should be able to skip lobby by holding down shift", async ({ page, user, room, app }) => { + await expect(page.getByText("Bob joined the room")).toBeVisible(); + await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.keyboard.down("Shift"); await page.getByRole("menuitem", { name: "Element Call" }).click(); await page.keyboard.up("Shift"); const frameUrlStr = await page.locator("iframe").getAttribute("src"); + await expect(frameUrlStr).toBeDefined(); const url = new URL(frameUrlStr); const hash = new URLSearchParams(url.hash.slice(1)); From ee6696eb0f114c28a65ac61552f1061f577187c9 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 17:01:05 +0100 Subject: [PATCH 23/27] lint --- playwright/e2e/voip/element-call.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index 1e1c45a543e..1f9233bb3c4 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -71,7 +71,7 @@ test.describe("Element Call", () => { await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.getByRole("menuitem", { name: "Element Call" }).click(); - + const frameUrlStr = await page.locator("iframe").getAttribute("src"); await expect(frameUrlStr).toBeDefined(); // Ensure we set the correct parameters for ECall. From 2eaf20ea303e6f8f6278e6dc475886eaa749e591 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 19:28:18 +0100 Subject: [PATCH 24/27] fix test ordering --- playwright/e2e/voip/element-call.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index 1f9233bb3c4..773445cdbac 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -61,14 +61,14 @@ test.describe("Element Call", () => { test.describe("Group Chat", () => { test.use({ room: async ({ page, app, user, bot }, use) => { - const roomId = await app.client.createRoom({ name: "TestRoom", invite: [user.userId] }); + const roomId = await app.client.createRoom({ name: "TestRoom", invite: [bot.credentials.userId] }); await use({ roomId }); }, }); test("should be able to start a video call", async ({ page, user, room, app }) => { + await app.viewRoomById(room.roomId); await expect(page.getByText("Bob joined the room")).toBeVisible(); - await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.getByRole("menuitem", { name: "Element Call" }).click(); @@ -84,9 +84,9 @@ test.describe("Element Call", () => { }); test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => { + await app.viewRoomById(room.roomId); await expect(page.getByText("Bob joined the room")).toBeVisible(); - await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.keyboard.down("Shift"); await page.getByRole("menuitem", { name: "Element Call" }).click(); @@ -119,9 +119,9 @@ test.describe("Element Call", () => { }); test("should be able to start a video call", async ({ page, user, room, app }) => { + await app.viewRoomById(room.roomId); await expect(page.getByText("Bob joined the room")).toBeVisible(); - await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.getByRole("menuitem", { name: "Element Call" }).click(); const frameUrlStr = await page.locator("iframe").getAttribute("src"); @@ -136,9 +136,9 @@ test.describe("Element Call", () => { }); test("should be able to skip lobby by holding down shift", async ({ page, user, room, app }) => { + await app.viewRoomById(room.roomId); await expect(page.getByText("Bob joined the room")).toBeVisible(); - await app.viewRoomById(room.roomId); await page.getByRole("button", { name: "Video call" }).click(); await page.keyboard.down("Shift"); await page.getByRole("menuitem", { name: "Element Call" }).click(); From e1bbbac6c413db645961e09bef3cd40b7e7ff544 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 19:28:22 +0100 Subject: [PATCH 25/27] return on video room --- src/models/Call.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/models/Call.ts b/src/models/Call.ts index acc51c4dc4d..e6daa317401 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -593,6 +593,7 @@ export class ElementCall extends Call { } else if (isVideoRoom(room)) { // Video call rooms always return to the lobby. params.append("returnToLobby", "true"); + return; } const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership(); From 532f36182ebcf3028e574f5f3b1a9e0b90098151 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 19:38:36 +0100 Subject: [PATCH 26/27] review changes --- src/models/Call.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index e6daa317401..1d9a79be509 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -593,6 +593,8 @@ export class ElementCall extends Call { } else if (isVideoRoom(room)) { // Video call rooms always return to the lobby. params.append("returnToLobby", "true"); + // Video call rooms already exist, so just treat as if we're joining a group call. + params.append("intent", ElementCallIntent.JoinExisting); return; } const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId); @@ -602,7 +604,6 @@ export class ElementCall extends Call { // preload by default so we override here. This can be removed when that package // is released and upgraded. if (isDM) { - params.append("sendNotificationType", "ring"); if (hasCallStarted) { params.append("intent", ElementCallIntent.JoinExistingDM); params.append("preload", "false"); @@ -611,7 +612,6 @@ export class ElementCall extends Call { params.append("preload", "false"); } } else { - params.append("sendNotificationType", "notification"); if (hasCallStarted) { params.append("intent", ElementCallIntent.JoinExisting); params.append("preload", "false"); From 7fe448ba6c0ad97138daba394324dda2de73e74c Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 22 Sep 2025 19:57:52 +0100 Subject: [PATCH 27/27] Refactor CallStore --- src/stores/CallStore.ts | 69 ++++++++++++++++------------- src/stores/WidgetStore.ts | 2 + test/unit-tests/models/Call-test.ts | 9 ---- 3 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 58467c088b2..8185be7de87 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -8,8 +8,8 @@ Please see LICENSE files in the repository root for full details. import { logger } from "matrix-js-sdk/src/logger"; import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler"; +import { type EmptyObject, type GroupCall, type Room } from "matrix-js-sdk/src/matrix"; -import type { EmptyObject, GroupCall, Room } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; import { UPDATE_EVENT } from "./AsyncStore"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; @@ -109,38 +109,43 @@ export class CallStore extends AsyncStoreWithClient { private callListeners = new Map unknown>>(); private updateRoom(room: Room): void { - if (!this.calls.has(room.roomId)) { - const call = Call.get(room); - - if (call) { - const onConnectionState = (state: ConnectionState): void => { - if (state === ConnectionState.Connected) { - this.connectedCalls = new Set([...this.connectedCalls, call]); - } else if (state === ConnectionState.Disconnected) { - this.connectedCalls = new Set([...this.connectedCalls].filter((c) => c !== call)); - } - }; - const onDestroy = (): void => { - this.calls.delete(room.roomId); - for (const [event, listener] of this.callListeners.get(call)!) call.off(event, listener); - this.updateRoom(room); - }; - - call.on(CallEvent.ConnectionState, onConnectionState); - call.on(CallEvent.Destroy, onDestroy); - - this.calls.set(room.roomId, call); - this.callListeners.set( - call, - new Map unknown>([ - [CallEvent.ConnectionState, onConnectionState], - [CallEvent.Destroy, onDestroy], - ]), - ); - } - - this.emit(CallStoreEvent.Call, call, room.roomId); + if (this.calls.has(room.roomId)) { + // Room has a call, nothing to do here. + return; + } + const call = Call.get(room); + + if (call) { + logger.debug(`updateRooms(${room.roomId}) called, binding management hooks`); + const onConnectionState = (state: ConnectionState): void => { + if (state === ConnectionState.Connected) { + this.connectedCalls = new Set([...this.connectedCalls, call]); + } else if (state === ConnectionState.Disconnected) { + this.connectedCalls = new Set([...this.connectedCalls].filter((c) => c !== call)); + } + }; + const onDestroy = (): void => { + this.calls.delete(room.roomId); + for (const [event, listener] of this.callListeners.get(call)!) call.off(event, listener); + this.updateRoom(room); + }; + + call.on(CallEvent.ConnectionState, onConnectionState); + call.on(CallEvent.Destroy, onDestroy); + + this.calls.set(room.roomId, call); + this.callListeners.set( + call, + new Map unknown>([ + [CallEvent.ConnectionState, onConnectionState], + [CallEvent.Destroy, onDestroy], + ]), + ); + } else { + logger.debug(`updateRooms(${room.roomId}) called but no Call has been constructed`); } + + this.emit(CallStoreEvent.Call, call, room.roomId); } /** diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index 5a76df71035..fbae950dbc7 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -189,6 +189,7 @@ export default class WidgetStore extends AsyncStoreWithClient { const app = WidgetUtils.makeAppConfig(widget.id, widget, widget.creatorUserId, roomId, undefined); this.widgetMap.set(WidgetUtils.getWidgetUid(app), app); this.roomMap.get(roomId)!.widgets.push(app); + this.emit(UPDATE_EVENT, roomId); return app; } @@ -198,6 +199,7 @@ export default class WidgetStore extends AsyncStoreWithClient { if (roomApps) { roomApps.widgets = roomApps.widgets.filter((app) => !(app.id === widgetId && app.roomId === roomId)); } + this.emit(UPDATE_EVENT, roomId); } } diff --git a/test/unit-tests/models/Call-test.ts b/test/unit-tests/models/Call-test.ts index ea33cfbcd14..ae3f7db0d1b 100644 --- a/test/unit-tests/models/Call-test.ts +++ b/test/unit-tests/models/Call-test.ts @@ -817,15 +817,6 @@ describe("ElementCall", () => { const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); expect(urlParams.get("sendNotificationType")).toBe("notification"); }); - - it("requests to skip lobby in params", async () => { - ElementCall.create(room, { skipLobby: true }); - const call = Call.get(room); - if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); - - const urlParams = new URLSearchParams(new URL(call.widget.url).hash.slice(1)); - expect(urlParams.get("skipLobby")).toBe("true"); - }); }); describe("instance in a non-video room", () => {