diff --git a/packages/shared-components/yarn.lock b/packages/shared-components/yarn.lock index 7d199c92f8..2af3430d9c 100644 --- a/packages/shared-components/yarn.lock +++ b/packages/shared-components/yarn.lock @@ -3752,7 +3752,7 @@ foreground-child@^2.0.0: cross-spawn "^7.0.0" signal-exit "^3.0.2" -foreground-child@^3.1.0: +foreground-child@^3.1.0, foreground-child@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== @@ -3902,6 +3902,18 @@ glob@^10.0.0, glob@^10.3.10: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" +glob@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-11.1.0.tgz#4f826576e4eb99c7dad383793d2f9f08f67e50a6" + integrity sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw== + dependencies: + foreground-child "^3.3.1" + jackspeak "^4.1.1" + minimatch "^10.1.1" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^2.0.0" + glob@^13.0.0: version "13.0.0" resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.0.tgz#9d9233a4a274fc28ef7adce5508b7ef6237a1be3" @@ -4403,6 +4415,13 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae" + integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + jest-changed-files@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.2.0.tgz#602266e478ed554e1e1469944faa7efd37cee61c" diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index 18bf0c33c0..f3bf2d084f 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -82,6 +82,22 @@ async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "n }); } +test.use({ + synapseConfig: { + experimental_features: { + msc4143_enabled: true, + }, + matrix_rtc: { + transports: [ + { + type: "livekit", + livekit_service_url: "https://example.org/can-be-anything", + }, + ], + }, + }, +}); + test.describe("Element Call", () => { test.use({ config: { diff --git a/src/hooks/room/useRoomCall.tsx b/src/hooks/room/useRoomCall.tsx index 1384858732..dca383e074 100644 --- a/src/hooks/room/useRoomCall.tsx +++ b/src/hooks/room/useRoomCall.tsx @@ -9,12 +9,13 @@ Please see LICENSE files in the repository root for full details. import { type Room } from "matrix-js-sdk/src/matrix"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import type React from "react"; import { useFeatureEnabled, useSettingValue } from "../useSettings"; import SdkConfig from "../../SdkConfig"; import { useEventEmitter, useEventEmitterState } from "../useEventEmitter"; -import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; +import { LegacyCallHandlerEvent } from "../../LegacyCallHandler"; import { useWidgets } from "../../utils/WidgetUtils"; import { WidgetType } from "../../widgets/WidgetType"; import { useCall, useConnectionState, useParticipantCount } from "../useCall"; @@ -37,6 +38,9 @@ import { type InteractionName } from "../../PosthogTrackers"; import { ElementCallMemberEventType } from "../../call-types"; import { LocalRoom, LocalRoomState } from "../../models/LocalRoom"; import { useScopedRoomContext } from "../../contexts/ScopedRoomContext"; +import { SdkContextClass } from "../../contexts/SDKContext"; + +const logger = rootLogger.getChild("useRoomCall"); export enum PlatformCallType { ElementCall, @@ -67,6 +71,8 @@ export const getPlatformCallTypeProps = ( label: _t("voip|legacy_call"), analyticsName: "WebVoipOptionLegacy", }; + default: + throw Error(`Unexpected PlatformCallType ${platformCallType}`); } }; @@ -110,10 +116,22 @@ export const useRoomCall = ( return SdkConfig.get("element_call").use_exclusively; }, []); + const serverIsConfiguredForElementCall = CallStore.instance + .getConfiguredRTCTransports() + .some((s) => s.type === "livekit" && s.livekit_service_url); + + useEffect(() => { + if (useElementCallExclusively && !serverIsConfiguredForElementCall) { + logger.warn( + "Element Call is configured to be used exclusively, but the server is not configured with a transport", + ); + } + }, [useElementCallExclusively, serverIsConfiguredForElementCall]); + const hasLegacyCall = useEventEmitterState( - LegacyCallHandler.instance, + SdkContextClass.instance.legacyCallHandler, LegacyCallHandlerEvent.CallsChanged, - () => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, + () => SdkContextClass.instance.legacyCallHandler.getCallForRoom(room.roomId) !== null, ); // settings const widgets = useWidgets(room); @@ -143,11 +161,13 @@ export const useRoomCall = ( // room const memberCount = useRoomMemberCount(room); - const [mayEditWidgets, mayCreateElementCalls] = useRoomState(room, () => [ + const [mayEditWidgets, mayCreateElementCallState] = useRoomState(room, () => [ room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), room.currentState.mayClientSendStateEvent(ElementCallMemberEventType.name, room.client), ]); + const mayCreateElementCalls = mayCreateElementCallState && serverIsConfiguredForElementCall; + // The options provided to the RoomHeader. // If there are multiple options, the user will be prompted to choose. const callOptions = useMemo((): PlatformCallType[] => { @@ -221,6 +241,10 @@ export const useRoomCall = ( if (!callOptions.includes(PlatformCallType.LegacyCall) && !mayCreateElementCalls && !mayEditWidgets) { return State.NoPermission; } + // Catch-all for just not having any call options available. + if (!callOptions.length) { + return State.NoPermission; + } return State.NoCall; }, [ callOptions, diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 6dc41dfce1..5997365fc0 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -7,9 +7,9 @@ Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; -import { type MatrixRTCSession, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc"; +import { type MatrixRTCSession, MatrixRTCSessionManagerEvents, type Transport } from "matrix-js-sdk/src/matrixrtc"; +import { MatrixError, type EmptyObject, type Room } from "matrix-js-sdk/src/matrix"; -import type { EmptyObject, Room } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; import { UPDATE_EVENT } from "./AsyncStore"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; @@ -35,6 +35,8 @@ export class CallStore extends AsyncStoreWithClient { return this._instance; } + private readonly configuredMatrixRTCTransports = new Set(); + private constructor() { super(defaultDispatcher); this.setMaxListeners(100); // One for each RoomTile @@ -44,8 +46,36 @@ export class CallStore extends AsyncStoreWithClient { // nothing to do } + /** + * Fetch transports used by MatrixRTC services, such as Element Call. + * This function is called once during Store startup which means we don't refetch + * transports every time we need to check for Element Call support. + */ + protected async fetchTransports(): Promise { + if (!this.matrixClient) return; + // Prefer checking the proper endpoint for transports. + try { + const transports = await this.matrixClient._unstable_getRTCTransports(); + transports.forEach((t) => this.configuredMatrixRTCTransports.add(t)); + } catch (ex) { + // Expected, MSC not implemented. + if (ex instanceof MatrixError === false || ex.errcode !== "M_NOT_FOUND") { + logger.warn("Unexpected error when trying to fetch RTC transports", ex); + } + } + // See https://github.com/matrix-org/matrix-spec-proposals/blob/d61969a9a3696b6c54d7987b1643b5bc03670927/proposals/4143-matrix-rtc.md#discovery-of-foci-using-well-knownmatrixclient + // This well-known option has since been removed from the spec but is still widely deployed. + await this.matrixClient.waitForClientWellKnown(); + const foci = this.matrixClient.getClientWellKnown()?.["org.matrix.msc4143.rtc_foci"]; + if (Array.isArray(foci)) { + foci.forEach((foci) => this.configuredMatrixRTCTransports.add(foci)); + } + } + protected async onReady(): Promise { if (!this.matrixClient) return; + // Fetch transports, but don't await the result. + void this.fetchTransports(); // 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 @@ -81,6 +111,7 @@ export class CallStore extends AsyncStoreWithClient { this.callListeners.clear(); this.calls.clear(); this._connectedCalls.clear(); + this.configuredMatrixRTCTransports.clear(); this.matrixClient?.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart); WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets); @@ -187,6 +218,10 @@ export class CallStore extends AsyncStoreWithClient { } }; + public getConfiguredRTCTransports(): Transport[] { + return [...this.configuredMatrixRTCTransports]; + } + private onRTCSessionStart = (roomId: string, session: MatrixRTCSession): void => { this.updateRoom(session.room); }; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index d3d6e0bec1..043f4590a8 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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(), diff --git a/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx b/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx index 22b8de923a..5d5c9fb337 100644 --- a/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx @@ -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 }) => ( - + {children} ), @@ -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(() => { diff --git a/test/unit-tests/hooks/useRoomCall-test.tsx b/test/unit-tests/hooks/useRoomCall-test.tsx new file mode 100644 index 0000000000..9d429f938c --- /dev/null +++ b/test/unit-tests/hooks/useRoomCall-test.tsx @@ -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 }) => ( + + {children} + + ), + }); + } + + 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]), + ); + }); + }); +}); diff --git a/test/unit-tests/stores/CallStore-test.ts b/test/unit-tests/stores/CallStore-test.ts index 30f42757e2..847c4b3592 100644 --- a/test/unit-tests/stores/CallStore-test.ts +++ b/test/unit-tests/stores/CallStore-test.ts @@ -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; + 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" }, + ]); + }); }); diff --git a/test/unit-tests/stores/RoomViewStore-test.ts b/test/unit-tests/stores/RoomViewStore-test.ts index 2f7c666895..b2130fcb2c 100644 --- a/test/unit-tests/stores/RoomViewStore-test.ts +++ b/test/unit-tests/stores/RoomViewStore-test.ts @@ -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 {