diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts
new file mode 100644
index 00000000000..773445cdbac
--- /dev/null
+++ b/playwright/e2e/voip/element-call.spec.ts
@@ -0,0 +1,157 @@
+/*
+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 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";
+
+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("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", () => {
+ 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", 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 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(null);
+ });
+
+ 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 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: "trusted_private_chat" as Preset.TrustedPrivateChat,
+ invite: [bot.credentials.userId],
+ });
+ await app.client.setAccountData("m.direct" as EventType.Direct, {
+ [bot.credentials.userId]: [roomId],
+ });
+ 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 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(null);
+ });
+
+ 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 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");
+ });
+ });
+});
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index df7ee8770fe..6ed41bd25c9 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -2609,7 +2609,6 @@ export class RoomView extends React.Component {
diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx
index e4f0dfa6086..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";
@@ -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 : false,
+ // If shift is held down, always skip lobby. Else, use defaults.
+ skipLobby: ("shiftKey" in ev && ev.shiftKey) || undefined,
metricsTrigger: undefined,
});
},
@@ -79,6 +80,7 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) =>
interface Props {
roomId: Room["roomId"];
+ dispatcher?: MatrixDispatcher;
}
const RoomCallBanner: React.FC = ({ roomId }) => {
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/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 81970da6b1a..1d9a79be509 100644
--- a/src/models/Call.ts
+++ b/src/models/Call.ts
@@ -97,6 +97,17 @@ 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 {
+ /**
+ * Skip showing the lobby screen of a call.
+ */
+ skipLobby?: boolean;
+}
+
/**
* A group call accessed through a widget.
*/
@@ -180,8 +191,8 @@ export abstract class Call extends TypedEventEmitter params.append("font", font));
}
+ this.appendAnalyticsParams(params, client);
+ this.appendRoomParams(params, client, roomId);
const replacedUrl = params.toString().replace(/%24/g, "$");
url.hash = `#?${replacedUrl}`;
@@ -675,33 +723,20 @@ 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,
- skipLobby: boolean | undefined,
- returnToLobby: boolean | undefined,
+ params: WidgetGenerationParameters = {},
): 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 (skipLobby !== undefined) {
- overwrites.skipLobby = skipLobby;
- }
- 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;
}
// 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
@@ -710,15 +745,7 @@ export class ElementCall extends Call {
type: WidgetType.CALL.preferred,
url: url.toString(),
waitForIframeLoad: false,
- data: ElementCall.getWidgetData(
- client,
- roomId,
- {},
- {
- skipLobby: skipLobby ?? false,
- returnToLobby: returnToLobby ?? false,
- },
- ),
+ data: ElementCall.getWidgetData(client, roomId, {}),
},
roomId,
);
@@ -726,12 +753,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() &&
@@ -740,13 +762,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(
@@ -766,7 +787,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);
@@ -777,20 +798,15 @@ 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,
- undefined,
- isVideoRoom(room),
- );
+ const availableOrCreatedWidget = ElementCall.createOrGetCallWidget(room.roomId, room.client, params);
return new ElementCall(session, availableOrCreatedWidget, room.client);
}
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);
}
public async start(): Promise {
diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts
index 6347cc898ed..8185be7de87 100644
--- a/src/stores/CallStore.ts
+++ b/src/stores/CallStore.ts
@@ -8,9 +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 MatrixRTCSession, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc";
+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";
@@ -46,16 +45,11 @@ 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);
- this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart);
+ // 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);
// If the room ID of a previously connected call is still in settings at
@@ -85,12 +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.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart);
- }
+ 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);
}
@@ -118,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);
}
/**
@@ -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..7483eeb680e 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;
@@ -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/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/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);
diff --git a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx
index 38cb77eb03f..24d9631930f 100644
--- a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx
+++ b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx
@@ -16,7 +16,8 @@ import {
} 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 userEvent from "@testing-library/user-event";
import {
mkRoomMember,
@@ -31,6 +32,27 @@ 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";
+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
+ const EventEmitter = require("events");
+ class MockOwnBeaconStore extends EventEmitter {
+ public getLiveBeaconIds = jest.fn().mockReturnValue([{}]);
+ public isMonitoringLiveLocation = false;
+ }
+ return {
+ // @ts-ignore
+ ...jest.requireActual("../../../../../src/stores/OwnBeaconStore"),
+ OwnBeaconStore: {
+ instance: new MockOwnBeaconStore() as unknown as OwnBeaconStore,
+ },
+ };
+});
+
+jest.mock("../../../../../src/dispatcher/dispatcher");
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
@@ -79,6 +105,7 @@ describe("", () => {
describe("call started", () => {
let call: MockedCall;
let widget: Widget;
+ let beaconStore: MockedObject;
beforeEach(() => {
MockedCall.create(room, "1");
@@ -92,6 +119,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
@@ -109,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();
@@ -116,6 +182,16 @@ describe("", () => {
expect(banner).toBeFalsy();
});
+ it("doesn't show banner if live location is ongoing", async () => {
+ 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");
+ 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 +202,4 @@ describe("", () => {
});
// TODO: test clicking buttons
- // TODO: add live location share warning test (should not render if there is an active live location share)
});
diff --git a/test/unit-tests/components/views/voip/CallView-test.tsx b/test/unit-tests/components/views/voip/CallView-test.tsx
index ca6a50b4184..2b971a02528 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");
});
@@ -97,9 +97,4 @@ describe("CallView", () => {
await renderView();
expect(cleanSpy).toHaveBeenCalled();
});
-
- it("updates the call's skipLobby parameter", async () => {
- await renderView(true);
- expect(call.widget.data?.skipLobby).toBe(true);
- });
});
diff --git a/test/unit-tests/models/Call-test.ts b/test/unit-tests/models/Call-test.ts
index a91f951be33..ae3f7db0d1b 100644
--- a/test/unit-tests/models/Call-test.ts
+++ b/test/unit-tests/models/Call-test.ts
@@ -828,7 +828,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;