Ensure correct focus configuration for Element Call before allowing users to call. (#31490)

* fixup type

* Validate Element Call foci config

* revert changes

* Split out logic to CallStore so we don't repeat checks.

* Refactor to use CallStore so we only fetch once.

* Add test for useRoomCall

* lint

* Ensure we enable MatrixRTC when configuring element call.

* fix test

* Update @element-hq/element-web-playwright-common to 2.2.2 and enable matrix rtc

* lint

* Ensure call is configured for header test

* type

* Improve coverage

* Update based on feedback

* fix type
This commit is contained in:
Will Hunt
2026-01-09 12:04:31 +00:00
committed by GitHub
parent 239527996a
commit 7ad6b4b411
9 changed files with 290 additions and 20 deletions
+2 -1
View File
@@ -207,6 +207,7 @@ export function createTestClient(): MatrixClient {
});
}),
getAccountDataFromServer: jest.fn(),
mxcUrlToHttp: jest.fn().mockImplementation((mxc: string) => `http://this.is.a.url/${mxc.substring(6)}`),
setAccountData: jest.fn(),
deleteAccountData: jest.fn(),
@@ -310,7 +311,7 @@ export function createTestClient(): MatrixClient {
_unstable_sendScheduledDelayedEvent: jest.fn(),
_unstable_sendStickyEvent: jest.fn(),
_unstable_sendStickyDelayedEvent: jest.fn(),
_unstable_getRTCTransports: jest.fn(),
searchUserDirectory: jest.fn().mockResolvedValue({ limited: false, results: [] }),
setDeviceVerified: jest.fn(),
joinRoom: jest.fn(),
@@ -17,6 +17,7 @@ import {
Room,
RoomStateEvent,
RoomMember,
type MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
@@ -37,7 +38,7 @@ import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycl
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import { filterConsole, stubClient } from "../../../../../test-utils";
import { filterConsole, setupAsyncStoreWithClient, stubClient } from "../../../../../test-utils";
import RoomHeader from "../../../../../../src/components/views/rooms/RoomHeader/RoomHeader";
import DMRoomMap from "../../../../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
@@ -85,12 +86,14 @@ describe("RoomHeader", () => {
emit: jest.fn(),
};
let client: MatrixClient;
let roomContext: RoomContextType;
function getWrapper(): RenderOptions {
return {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={MatrixClientPeg.safeGet()}>
<MatrixClientContext.Provider value={client}>
<ScopedRoomContextProvider {...roomContext}>{children}</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
),
@@ -98,8 +101,8 @@ describe("RoomHeader", () => {
}
beforeEach(async () => {
stubClient();
room = new Room(ROOM_ID, MatrixClientPeg.get()!, "@alice:example.org", {
client = stubClient();
room = new Room(ROOM_ID, client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
DMRoomMap.setShared({
@@ -405,12 +408,18 @@ describe("RoomHeader", () => {
});
describe("group call enabled", () => {
beforeEach(() => {
beforeEach(async () => {
SdkConfig.put({
features: {
feature_group_calls: true,
},
});
// Enable Element Call
client._unstable_getRTCTransports = jest
.fn()
.mockResolvedValue([{ type: "livekit", livekit_service_url: "https://example.org" }]);
// And ensure the CallStore has the transports configured.
await setupAsyncStoreWithClient(CallStore.instance, client);
});
afterEach(() => {
+133
View File
@@ -0,0 +1,133 @@
/*
Copyright 2026 Element Creations 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 { renderHook, waitFor } from "jest-matrix-react";
import React from "react";
import { PlatformCallType, useRoomCall } from "../../../src/hooks/room/useRoomCall";
import {
getMockClientWithEventEmitter,
mkRoom,
mockClientMethodsRooms,
mockClientMethodsServer,
mockClientMethodsUser,
MockEventEmitter,
setupAsyncStoreWithClient,
} from "../../test-utils";
import { ScopedRoomContextProvider } from "../../../src/contexts/ScopedRoomContext";
import RoomContext, { type RoomContextType } from "../../../src/contexts/RoomContext";
import { MatrixClientContextProvider } from "../../../src/components/structures/MatrixClientContextProvider";
import type LegacyCallHandler from "../../../src/LegacyCallHandler";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import SettingsStore from "../../../src/settings/SettingsStore";
import { CallStore } from "../../../src/stores/CallStore";
describe("useRoomCall", () => {
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
...mockClientMethodsServer(),
...mockClientMethodsRooms(),
matrixRTC: new MockEventEmitter(),
_unstable_getRTCTransports: jest.fn().mockResolvedValue([]),
getCrypto: () => null,
});
const room = mkRoom(client, "!test-room");
// Create a stable room context for this test
const mockRoomViewStore = {
isViewingCall: jest.fn().mockReturnValue(false),
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
};
const roomContext = {
...RoomContext,
roomId: room.roomId,
roomViewStore: mockRoomViewStore,
} as unknown as RoomContextType;
beforeEach(() => {
const callHandler = {
getCallForRoom: jest.fn().mockReturnValue(null),
isCallSidebarShown: jest.fn().mockReturnValue(true),
addListener: jest.fn(),
removeListener: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
jest.spyOn(SdkContextClass.instance, "legacyCallHandler", "get").mockReturnValue(
callHandler as unknown as LegacyCallHandler,
);
const origGetValue = SettingsStore.getValue;
jest.spyOn(SettingsStore, "getValue").mockImplementation((name, ...params): any => {
if (name === "feature_group_calls") return true;
return origGetValue(name, ...params);
});
});
afterEach(() => {
jest.restoreAllMocks();
});
function render() {
return renderHook(() => useRoomCall(room), {
wrapper: ({ children }) => (
<MatrixClientContextProvider client={client}>
<ScopedRoomContextProvider {...roomContext}>{children}</ScopedRoomContextProvider>
</MatrixClientContextProvider>
),
});
}
describe("Element Call focus detection", () => {
it("Blocks Element Call if required foci are not configured", async () => {
await setupAsyncStoreWithClient(CallStore.instance, client);
const { result } = render();
await waitFor(() => expect(result.current.callOptions).toEqual([PlatformCallType.LegacyCall]));
});
it("Blocks Element Call if transport foci are the wrong type", async () => {
client._unstable_getRTCTransports.mockResolvedValue([{ type: "anything-else" }]);
await setupAsyncStoreWithClient(CallStore.instance, client);
const { result } = render();
await waitFor(() => expect(result.current.callOptions).toEqual([PlatformCallType.LegacyCall]));
});
it("Blocks Element Call if well-known foci are the wrong type", async () => {
client.getClientWellKnown.mockReturnValue({
"org.matrix.msc4143.rtc_foci": {
type: "anything-else",
},
});
await setupAsyncStoreWithClient(CallStore.instance, client);
const { result } = render();
await waitFor(() => expect(result.current.callOptions).toEqual([PlatformCallType.LegacyCall]));
});
it("Allows Element Call if foci is provided via getRTCTransports", async () => {
client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://example.org" },
]);
await setupAsyncStoreWithClient(CallStore.instance, client);
const { result } = render();
await waitFor(() =>
expect(result.current.callOptions).toEqual([PlatformCallType.ElementCall, PlatformCallType.LegacyCall]),
);
});
it("Allows Element Call if foci is provided via .well-known", async () => {
client.getClientWellKnown.mockReturnValue({
"org.matrix.msc4143.rtc_foci": {
type: "livekit",
livekit_service_url: "https://example.org",
},
});
await setupAsyncStoreWithClient(CallStore.instance, client);
const { result } = render();
await waitFor(() =>
expect(result.current.callOptions).toEqual([PlatformCallType.ElementCall, PlatformCallType.LegacyCall]),
);
});
});
});
+38 -7
View File
@@ -6,6 +6,8 @@
*/
import { type CallMembership, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc";
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { type MockedObject } from "jest-mock";
import { ElementCall } from "../../../src/models/Call";
import { CallStore } from "../../../src/stores/CallStore";
@@ -16,11 +18,22 @@ import {
enableCalls,
} from "../../test-utils";
enableCalls();
describe("CallStore", () => {
let client: MockedObject<MatrixClient>;
let room: Room;
beforeEach(() => {
enableCalls();
const res = setUpClientRoomAndStores();
client = res.client;
room = res.room;
});
test("CallStore constructs one call for one MatrixRTC session", () => {
const { client, room } = setUpClientRoomAndStores();
try {
afterEach(() => {
cleanUpClientRoomAndStores(client, room);
jest.restoreAllMocks();
});
it("constructs one call for one MatrixRTC session", () => {
setupAsyncStoreWithClient(CallStore.instance, client);
const getSpy = jest.spyOn(ElementCall, "get");
@@ -32,7 +45,25 @@ test("CallStore constructs one call for one MatrixRTC session", () => {
expect(getSpy).toHaveBeenCalledTimes(1);
expect(getSpy).toHaveReturnedWith(expect.any(ElementCall));
expect(CallStore.instance.getCall(room.roomId)).not.toBe(null);
} finally {
cleanUpClientRoomAndStores(client, room);
}
expect(CallStore.instance.getConfiguredRTCTransports()).toHaveLength(0);
});
it("calculates RTC transports with both modern and legacy endpoints", async () => {
client._unstable_getRTCTransports.mockResolvedValue([
{ type: "type-a", some_data: "value" },
{ type: "type-b", some_data: "foo" },
]);
client.getClientWellKnown.mockReturnValue({
"org.matrix.msc4143.rtc_foci": [
{ type: "type-c", other_data: "bar" },
{ type: "type-d", other_data: "baz" },
],
});
await setupAsyncStoreWithClient(CallStore.instance, client);
expect(CallStore.instance.getConfiguredRTCTransports()).toEqual([
{ type: "type-a", some_data: "value" },
{ type: "type-b", some_data: "foo" },
{ type: "type-c", other_data: "bar" },
{ type: "type-d", other_data: "baz" },
]);
});
});
@@ -134,6 +134,8 @@ describe("RoomViewStore", function () {
leave: jest.fn(),
setRoomAccountData: jest.fn(),
getAccountData: jest.fn(),
waitForClientWellKnown: jest.fn().mockResolvedValue(undefined),
getClientWellKnown: jest.fn().mockReturnValue({}),
matrixRTC: new (class extends EventEmitter {
getRoomSession() {
return new (class extends EventEmitter {