Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/src/components/structures/MatrixChat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ describe("<MatrixChat />", () => {
getThirdpartyProtocols: vi.fn().mockResolvedValue({}),
getClientWellKnown: vi.fn().mockReturnValue({}),
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
cachedRtcTransports: {
wait: vi.fn().mockResolvedValue([]),
get: vi.fn().mockReturnValue([]),
},
waitForClientWellKnown: vi.fn().mockResolvedValue({}),
isVersionSupported: vi.fn().mockResolvedValue(false),
initRustCrypto: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ describe("RoomHeader", () => {
// Mock CallStore.instance.getCall to return null by default
// Individual tests can override this when they need a specific Call object
vi.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
vi.spyOn(CallStore.instance, "getConfiguredRTCTransports").mockReturnValue([]);

// Reset the mock RoomViewStore
mockRoomViewStore.isViewingCall.mockReturnValue(false);
Expand Down Expand Up @@ -419,9 +420,9 @@ describe("RoomHeader", () => {
beforeEach(async () => {
SdkConfig.put({});
// Enable Element Call
client._unstable_getRTCTransports = vi
.fn()
.mockResolvedValue([{ type: "livekit", livekit_service_url: "https://example.org" }]);
vi.spyOn(CallStore.instance, "getConfiguredRTCTransports").mockReturnValue([
{ type: "livekit", livekit_service_url: "https://example.org" },
]);
// And ensure the CallStore has the transports configured.
await setupAsyncStoreWithClient(CallStore.instance, client);
});
Expand Down Expand Up @@ -810,6 +811,9 @@ describe("RoomHeader", () => {
getMxcAvatarUrl: () => "mxc://avatar.url/image.png",
},
]);
vi.spyOn(CallStore.instance, "getConfiguredRTCTransports").mockReturnValue([
{ type: "livekit", livekit_service_url: "https://example.org" },
]);
});

afterEach(() => {
Expand Down
31 changes: 17 additions & 14 deletions apps/web/src/hooks/useRoomCall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.

// @vitest-environment happy-dom

import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "test-utils-rtl";
import {
getMockClientWithEventEmitter,
Expand All @@ -25,16 +25,18 @@ import RoomContext, { type RoomContextType } from "../contexts/RoomContext";
import type LegacyCallHandler from "../LegacyCallHandler";
import { CallStore } from "../stores/CallStore";
import { SDKContextClass } from "../contexts/SDKContextClass";
import { ClientEvent } from "matrix-js-sdk/src/matrix";
import { act } from "react";

describe("useRoomCall", () => {
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
...mockClientMethodsServer(),
...mockClientMethodsRooms(),
matrixRTC: new MockEventEmitter(),
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getCrypto: () => null,
});

const room = mkRoom(client, "!test-room");
// Create a stable room context for this test
const mockRoomViewStore = {
Expand Down Expand Up @@ -82,7 +84,7 @@ describe("useRoomCall", () => {
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" }]);
vi.mocked(client.cachedRtcTransports.get).mockReturnValue([{ type: "anything-else" }]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting we've changed this to prefill the cache rather than mocking the response, is that going to be more prone to breakage if the behaviour of _unstable_getRTCTransports changes?

await setupAsyncStoreWithClient(CallStore.instance, client);
const { result } = render();
await waitFor(() => expect(result.current.callOptions).toEqual([PlatformCallType.LegacyCall]));
Expand All @@ -98,7 +100,7 @@ describe("useRoomCall", () => {
await waitFor(() => expect(result.current.callOptions).toEqual([PlatformCallType.LegacyCall]));
});
it("Allows Element Call if foci is provided via getRTCTransports", async () => {
client._unstable_getRTCTransports.mockResolvedValue([
vi.mocked(client.cachedRtcTransports.get).mockReturnValue([
{ type: "livekit", livekit_service_url: "https://example.org" },
]);
await setupAsyncStoreWithClient(CallStore.instance, client);
Expand All @@ -108,13 +110,13 @@ describe("useRoomCall", () => {
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": {
it("Allows Element Call if transport is provided by client discovery", async () => {
vi.mocked(client.cachedRtcTransports.get).mockReturnValue([
{
type: "livekit",
livekit_service_url: "https://example.org",
},
});
]);
await setupAsyncStoreWithClient(CallStore.instance, client);
const { result } = render();
await waitFor(() =>
Expand All @@ -123,19 +125,20 @@ describe("useRoomCall", () => {
});
it("Ensure handler reacts to transport changes", async () => {
// Clear all transports
client._unstable_getRTCTransports.mockResolvedValue([]);
client.getClientWellKnown.mockReturnValue({});

vi.mocked(client.cachedRtcTransports.get).mockReturnValue([]);
await setupAsyncStoreWithClient(CallStore.instance, client);
const { result } = render();

// Ensure Element Call is not a call option.
expect(result.current.callOptions).toEqual([PlatformCallType.LegacyCall]);

// Now enable a transport and ensure that useRoomCall picks it up reactively.
client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://example.org" },
]);
act(() => {
const transports = [{ type: "livekit", livekit_service_url: "https://example.org" }];
vi.mocked(client.cachedRtcTransports.get).mockReturnValue(transports);
client.emit(ClientEvent.RtcTransportsUpdated, transports);
});

await setupAsyncStoreWithClient(CallStore.instance, client);
await waitFor(() =>
expect(result.current.callOptions).toEqual([PlatformCallType.ElementCall, PlatformCallType.LegacyCall]),
Expand Down
71 changes: 30 additions & 41 deletions apps/web/src/stores/CallStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@

import { logger } from "matrix-js-sdk/src/logger";
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 { ClientEvent, type EmptyObject, type Room } from "matrix-js-sdk/src/matrix";

import defaultDispatcher from "../dispatcher/dispatcher";
import { UPDATE_EVENT } from "./AsyncStore";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import WidgetStore from "./WidgetStore";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import SdkConfig from "../SdkConfig";
import { Call, CallEvent, ConnectionState } from "../models/Call";
import SdkConfig from "../SdkConfig.ts";

export enum CallStoreEvent {
// Signals a change in the call associated with a given room
Expand All @@ -41,8 +41,6 @@
return this._instance;
}

private readonly configuredMatrixRTCTransports = new Set<Transport>();

private constructor() {
super(defaultDispatcher);
this.setMaxListeners(100); // One for each RoomTile
Expand All @@ -52,44 +50,15 @@
// 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<void> {
if (!this.matrixClient) return;
this.configuredMatrixRTCTransports.clear();
// 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.
//
// Homeservers will return a 404 M_UNRECOGNIZED matrix error if they
// don't implement a requested endpoint.
if (ex instanceof MatrixError === false || ex.errcode !== "M_UNRECOGNIZED") {
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.
// Reading it can be disabled via config; the modern endpoint above is unaffected.
if (SdkConfig.get("enable_client_well_known_lookups")) {
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));
}
}
this.emit(CallStoreEvent.TransportsUpdated);
}

protected async onReady(): Promise<any> {
if (!this.matrixClient) return;
// Fetch transports, but don't await the result.
void this.fetchTransports();
this.matrixClient.cachedRtcTransports.wait().catch(() => {
if (SdkConfig.get("enable_client_well_known_lookups")) {
void this.matrixClient?.waitForClientWellKnown();

Check warning on line 58 in apps/web/src/stores/CallStore.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 58 is not covered by tests
}
});

// 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
Expand All @@ -99,6 +68,8 @@
this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart);
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets);

this.matrixClient.on(ClientEvent.RtcTransportsUpdated, this.onRTCTransportsUpdated);

// If the room ID of a previously connected call is still in settings at
// this time, that's a sign that we failed to disconnect from it
// properly, and need to clean up after ourselves
Expand All @@ -125,9 +96,9 @@
this.callListeners.clear();
this.calls.clear();
this._connectedCalls.clear();
this.configuredMatrixRTCTransports.clear();

this.matrixClient?.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSessionStart);
this.matrixClient?.off(ClientEvent.RtcTransportsUpdated, this.onRTCTransportsUpdated);
WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets);
}

Expand All @@ -138,6 +109,7 @@
public get connectedCalls(): Set<Call> {
return this._connectedCalls;
}

private set connectedCalls(value: Set<Call>) {
const prevValue = this._connectedCalls;
this._connectedCalls = value;
Expand All @@ -156,6 +128,7 @@
private callListeners = new Map<Call, Map<CallEvent, (...args: unknown[]) => unknown>>();

private inUpdateRoom = false;

private updateRoom(room: Room): void {
// XXX: This method is guarded with the flag this.inUpdateRoom because
// we need to block this method from calling itself recursively. That
Expand Down Expand Up @@ -242,10 +215,26 @@
};

public getConfiguredRTCTransports(): Transport[] {
return [...this.configuredMatrixRTCTransports];
const rtcTransports = this.matrixClient?.cachedRtcTransports.get();
const enableClientWellKnownLookups = SdkConfig.get("enable_client_well_known_lookups");
if (rtcTransports || !enableClientWellKnownLookups) {
return rtcTransports ?? [];
}
const wellKnown = this.matrixClient?.getClientWellKnown();
const foci = wellKnown?.["org.matrix.msc4143.rtc_foci"];
if (foci !== undefined) {
if (Array.isArray(foci))
return foci; // Contents assumed to be valid Transports
else logger.warn(`org.matrix.msc4143.rtc_foci is not an array in .well-known`);

Check warning on line 228 in apps/web/src/stores/CallStore.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Lines 226-228 are not covered by tests

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Surprised this passes linting, I thought we always used curly braces for else

}
return [];
}

private onRTCSessionStart = (roomId: string, session: MatrixRTCSession): void => {
this.updateRoom(session.room);
};

private onRTCTransportsUpdated = (transports: Transport[]): void => {
this.emit(CallStoreEvent.TransportsUpdated, transports);
};
}
29 changes: 23 additions & 6 deletions apps/web/src/stores/widgets/ElementWidgetDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
import { toWidgetDescriptor } from "../../modules/WidgetLifecycleApi";
import SettingsStore from "../../settings/SettingsStore";
import { mediaFromMxc } from "../../customisations/Media";
import SdkConfig from "../../SdkConfig.ts";

function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] {
return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]");
Expand Down Expand Up @@ -748,12 +749,28 @@

public async getRtcTransports(): Promise<IRtcTransportsResult> {
const client = MatrixClientPeg.safeGet();
// Delegate to the authenticated CS endpoint (MSC4143). Any error (e.g. the
// homeserver not supporting it) propagates and is turned into a widget error
// response by ClientWidgetApi. The js-sdk Transport and widget-api IRtcTransport
// types are structurally identical.
const transports = await client._unstable_getRTCTransports();
return { rtc_transports: transports };
try {
// Delegate to the authenticated CS endpoint (MSC4519). The js-sdk Transport and
// widget-api IRtcTransport types are structurally identical.
const transports = await client.cachedRtcTransports.wait();
return { rtc_transports: transports ?? [] };
} catch (e) {
// If the homeserver does not support the API, fall back to legacy well-known lookup.
if (
e instanceof MatrixError &&
e.errcode === "M_NOT_FOUND" &&
SdkConfig.get("enable_client_well_known_lookups")
) {
const wellKnown = await client.waitForClientWellKnown();
const foci = wellKnown?.["org.matrix.msc4143.rtc_foci"];
if (foci !== undefined) {
if (Array.isArray(foci)) return { rtc_transports: foci };
else logger.warn(`org.matrix.msc4143.rtc_foci is not an array in .well-known`);

Check warning on line 768 in apps/web/src/stores/widgets/ElementWidgetDriver.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 768 is not covered by tests
}
}
// Re-throw to turn the error into a widget error response
throw e;
}
}

public async readEventRelations(
Expand Down
5 changes: 4 additions & 1 deletion apps/web/test/test-utils/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,10 @@ export function setUpClientRoomAndStores(): {
stubClient();
const client = mocked<MatrixClient>(MatrixClientPeg.safeGet());
DMRoomMap.makeShared(client);

client.cachedRtcTransports = {
wait: jest.fn().mockResolvedValue(undefined),
get: jest.fn().mockReturnValue(undefined),
} as unknown as any;
const room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
Expand Down
8 changes: 7 additions & 1 deletion apps/web/test/test-utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,19 @@ export const mockClientPushProcessor = () =>
/**
* Returns basic mocked client methods related to server support
*/
export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
export const mockClientMethodsServer = (): Partial<
Record<MethodLikeKeys<MatrixClient> | PropertyLikeKeys<MatrixClient>, unknown>
> => ({
getIdentityServerUrl: vi.fn(),
getHomeserverUrl: vi.fn(),
getCapabilities: vi.fn().mockResolvedValue({}),
getCachedCapabilities: vi.fn().mockResolvedValue({}),
getClientWellKnown: vi.fn().mockReturnValue({}),
waitForClientWellKnown: vi.fn().mockResolvedValue({}),
cachedRtcTransports: {
wait: vi.fn().mockResolvedValue([]),
get: vi.fn().mockReturnValue([]),
},
doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(false),
isVersionSupported: vi.fn().mockResolvedValue(false),
getVersions: vi.fn().mockResolvedValue({}),
Expand Down
4 changes: 4 additions & 0 deletions apps/web/test/test-utils/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@ export function createTestClient(): MatrixClient {
_unstable_sendStickyEvent: vi.fn(),
_unstable_sendStickyDelayedEvent: vi.fn(),
_unstable_getRTCTransports: vi.fn(),
cachedRtcTransports: {
wait: vi.fn().mockResolvedValue([]),
get: vi.fn().mockReturnValue([]),
} as unknown as MockedObject<typeof client.cachedRtcTransports>,
searchUserDirectory: vi.fn().mockResolvedValue({ limited: false, results: [] }),
setDeviceVerified: vi.fn(),
joinRoom: vi.fn(),
Expand Down
Loading
Loading