From b99c8821d3d3d25823fcb133c86c357641f08cb1 Mon Sep 17 00:00:00 2001 From: fkwp Date: Fri, 5 Jun 2026 11:49:43 +0200 Subject: [PATCH] Add `matrix_rtc_mode` config option (#4014) * Move MatrixRTCMode enum from settings.ts to ConfigOptions.ts * Add matrix_rtc_mode config option * add matrix_rtc_mode to config.sample.json * Update src/settings/DeveloperSettingsTab.tsx Co-authored-by: Johannes Marbach * Update src/settings/DeveloperSettingsTab.test.tsx Co-authored-by: Johannes Marbach * reviewer comments --------- Co-authored-by: Johannes Marbach --- config/config.sample.json | 1 + src/config/Config.test.ts | 54 ++++++++++ src/config/Config.ts | 23 +++- src/config/ConfigOptions.ts | 27 +++++ src/settings/DeveloperSettingsTab.test.tsx | 101 ++++++++++++++++++ src/settings/DeveloperSettingsTab.tsx | 19 +++- src/settings/settings.ts | 13 +-- src/state/CallViewModel/CallViewModel.test.ts | 2 +- src/state/CallViewModel/CallViewModel.ts | 12 ++- .../CallViewModel/CallViewModelTestUtils.ts | 2 +- .../localMember/LocalMember.test.ts | 2 +- .../CallViewModel/localMember/LocalMember.ts | 2 +- src/state/CallViewModelWidget.test.ts | 2 +- src/utils/test-viewmodel.ts | 2 +- 14 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 src/config/Config.test.ts diff --git a/config/config.sample.json b/config/config.sample.json index 126d7626..71bfecbc 100644 --- a/config/config.sample.json +++ b/config/config.sample.json @@ -12,6 +12,7 @@ "feature_use_device_session_member_events": true }, "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", + "matrix_rtc_mode": "legacy", "matrix_rtc_session": { "wait_for_key_rotation_ms": 3000, "membership_event_expiry_ms": 180000000, diff --git a/src/config/Config.test.ts b/src/config/Config.test.ts new file mode 100644 index 00000000..34dd44cb --- /dev/null +++ b/src/config/Config.test.ts @@ -0,0 +1,54 @@ +/* +Copyright 2026 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, it, vi, afterEach } from "vitest"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { validateConfig } from "./Config"; +import { MatrixRTCMode } from "./ConfigOptions"; + +describe("validateConfig", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("passes through a missing matrix_rtc_mode unchanged", () => { + const result = validateConfig({}); + expect(result.matrix_rtc_mode).toBeUndefined(); + }); + + it.each(Object.values(MatrixRTCMode))( + "keeps a valid matrix_rtc_mode value (%s)", + (mode) => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const result = validateConfig({ matrix_rtc_mode: mode }); + expect(result.matrix_rtc_mode).toBe(mode); + expect(warnSpy).not.toHaveBeenCalled(); + }, + ); + + it("drops an invalid matrix_rtc_mode value and warns", () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const result = validateConfig({ + // Intentionally bypass the type to simulate bad JSON. + matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode, + }); + expect(result.matrix_rtc_mode).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain("nonsense"); + }); + + it("does not touch unrelated fields when dropping an invalid mode", () => { + vi.spyOn(logger, "warn").mockImplementation(() => {}); + const result = validateConfig({ + matrix_rtc_mode: "nope" as unknown as MatrixRTCMode, + ssla: "https://example.invalid/ssla", + }); + expect(result.matrix_rtc_mode).toBeUndefined(); + expect(result.ssla).toBe("https://example.invalid/ssla"); + }); +}); diff --git a/src/config/Config.ts b/src/config/Config.ts index b52acc46..f52b28fd 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { merge } from "lodash-es"; +import { logger } from "matrix-js-sdk/lib/logger"; import { getUrlParams } from "../UrlParams"; import { @@ -14,6 +15,11 @@ import { type ResolvedConfigOptions, } from "./ConfigOptions"; import { isFailure } from "../utils/fetch"; +import { MatrixRTCMode } from "./ConfigOptions"; + +const VALID_MATRIX_RTC_MODES: ReadonlySet = new Set( + Object.values(MatrixRTCMode), +); export class Config { private static internalInstance: Config | undefined; @@ -44,7 +50,11 @@ export class Config { Config.internalInstance.initPromise = downloadConfig(fetchTarget).then( (config) => { - internalInstance.config = merge({}, DEFAULT_CONFIG, config); + internalInstance.config = merge( + {}, + DEFAULT_CONFIG, + validateConfig(config), + ); }, ); } @@ -84,6 +94,17 @@ export class Config { private initPromise?: Promise; } +export function validateConfig(config: ConfigOptions): ConfigOptions { + const mode = config.matrix_rtc_mode; + if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) { + logger.warn( + `Ignoring invalid matrix_rtc_mode in config.json: ${String(mode)}`, + ); + delete config.matrix_rtc_mode; + } + return config; +} + async function downloadConfig(fetchTarget: string): Promise { const response = await fetch(fetchTarget); diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 165a14f0..3d9fcfb5 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -6,6 +6,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +/** + * The MatrixRTC mode determines how Element Call interacts with the + * MatrixRTC backend and other participants. Selectable via the Developer + * Settings, or pinned for a deployment via `matrix_rtc_mode` in config.json. + */ +export enum MatrixRTCMode { + /** Legacy single-SFU + user-keyed memberships + legacy JWT endpoint. */ + Legacy = "legacy", + /** Multi-SFU transport, legacy JWT endpoint, no sticky events. */ + Compatibility = "compatibility", + /** + * Multi-SFU transport with: + * - sticky events + * - hashed RTC backend identity + * - the new endpoint for the jwt token on the local membership (remote memberships will always try the new jwt endpoint first -> then the legacy one) + * - use the hashed identity for the local membership + */ + Matrix_2_0 = "matrix_2_0", +} + export interface ConfigOptions { /** * The Posthog endpoint to which analytics data will be sent. @@ -104,6 +124,13 @@ export interface ConfigOptions { */ sync_disconnect_grace_period_ms?: number; + /** + * Pins the {@link MatrixRTCMode} for all clients on this deployment, + * overriding any per-user choice from the Developer Settings. If unset, + * the user's Developer Settings choice (or its default of `Legacy`) wins. + */ + matrix_rtc_mode?: MatrixRTCMode; + /** * These are low level options that are used to configure the MatrixRTC session. * Take care when changing these options. diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index 5ff0d4d2..d4c7b8c8 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -17,7 +17,10 @@ import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; import { customLivekitUrl as customLivekitUrlSetting, enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting, + matrixRTCMode as matrixRTCModeSetting, } from "./settings"; +import { MatrixRTCMode } from "../config/ConfigOptions"; +import { mockConfig } from "../utils/test"; // Mock url params hook to avoid environment-dependent snapshot churn. vi.mock("../UrlParams", () => ({ @@ -311,4 +314,102 @@ describe("DeveloperSettingsTab", () => { expect(enableExtendedLivekitLogsSetting.getValue()).toBe(true); }); }); + + describe("matrix rtc mode", () => { + afterEach(() => { + matrixRTCModeSetting.setValue(MatrixRTCMode.Legacy); + vi.restoreAllMocks(); + }); + + function getModeRadios(): { + legacy: HTMLInputElement; + compatibility: HTMLInputElement; + matrix20: HTMLInputElement; + } { + return { + legacy: screen.getByDisplayValue( + MatrixRTCMode.Legacy, + ) as HTMLInputElement, + compatibility: screen.getByDisplayValue( + MatrixRTCMode.Compatibility, + ) as HTMLInputElement, + matrix20: screen.getByDisplayValue( + MatrixRTCMode.Matrix_2_0, + ) as HTMLInputElement, + }; + } + + it("radios reflect the localStorage setting when config does not force the mode", async () => { + mockConfig({}); + matrixRTCModeSetting.setValue(MatrixRTCMode.Compatibility); + const client = createMockMatrixClient(); + + render( + + + , + ); + + await waitFor(() => + expect(client.doesServerSupportUnstableFeature).toHaveBeenCalled(), + ); + + const radios = getModeRadios(); + expect(radios.compatibility).toBeChecked(); + expect(radios.legacy).not.toBeChecked(); + expect(radios.matrix20).not.toBeChecked(); + // None are disabled by config; only Matrix_2_0 may be disabled by sticky-events support. + expect(radios.legacy).not.toBeDisabled(); + expect(radios.compatibility).not.toBeDisabled(); + }); + + it.each([ + MatrixRTCMode.Legacy, + MatrixRTCMode.Compatibility, + MatrixRTCMode.Matrix_2_0, + ])( + "disables all radios and shows the config value (%s) as checked when matrix_rtc_mode is set", + async (configMode) => { + mockConfig({ matrix_rtc_mode: configMode }); + // Local setting is intentionally different from the config value to + // prove config wins. + matrixRTCModeSetting.setValue( + configMode === MatrixRTCMode.Legacy + ? MatrixRTCMode.Compatibility + : MatrixRTCMode.Legacy, + ); + const client = createMockMatrixClient(); + + render( + + + , + ); + + await waitFor(() => + expect(client.doesServerSupportUnstableFeature).toHaveBeenCalled(), + ); + + const radios = getModeRadios(); + expect(radios.legacy).toBeDisabled(); + expect(radios.compatibility).toBeDisabled(); + expect(radios.matrix20).toBeDisabled(); + + const checkedValue = ( + { + [MatrixRTCMode.Legacy]: radios.legacy, + [MatrixRTCMode.Compatibility]: radios.compatibility, + [MatrixRTCMode.Matrix_2_0]: radios.matrix20, + } as const + )[configMode]; + expect(checkedValue).toBeChecked(); + }, + ); + }); }); diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 74c878e9..cc15ae54 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -33,6 +33,7 @@ import { import { type Room as LivekitRoom } from "livekit-client"; import { FieldRow, InputField } from "../input/Input"; +import { Config } from "../config/Config"; import { useSetting, duplicateTiles as duplicateTilesSetting, @@ -42,9 +43,9 @@ import { alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, matrixRTCMode as matrixRTCModeSetting, customLivekitUrl as customLivekitUrlSetting, - MatrixRTCMode, enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting, } from "./settings"; +import { MatrixRTCMode } from "../config/ConfigOptions"; import styles from "./DeveloperSettingsTab.module.css"; import { useUrlParams } from "../UrlParams"; import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; @@ -93,6 +94,11 @@ export const DeveloperSettingsTab: FC = ({ }, [setMatrixRTCMode], ); + const configMatrixRTCMode = Config.get().matrix_rtc_mode as + | MatrixRTCMode + | undefined; + const matrixRTCModeForced = configMatrixRTCMode !== undefined; + const effectiveMatrixRTCMode = configMatrixRTCMode ?? matrixRTCMode; const [showConnectionStats, setShowConnectionStats] = useSetting( showConnectionStatsSetting, @@ -312,13 +318,15 @@ export const DeveloperSettingsTab: FC = ({ {t("developer_mode.matrixRTCMode.title")} + {matrixRTCModeForced &&

Your deployment overrides the mode.

}
} @@ -332,8 +340,9 @@ export const DeveloperSettingsTab: FC = ({ name={matrixRTCModeRadioGroup} control={ } @@ -347,9 +356,9 @@ export const DeveloperSettingsTab: FC = ({ name={matrixRTCModeRadioGroup} control={ } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index cf0d9d66..8d3a9983 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -11,6 +11,7 @@ import { BehaviorSubject } from "rxjs"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { type Behavior } from "../state/Behavior"; import { useBehavior } from "../useBehavior"; +import { MatrixRTCMode } from "../config/ConfigOptions"; export class Setting { public constructor( @@ -134,18 +135,6 @@ export const enableExtendedLivekitLogs = new Setting( false, ); -export enum MatrixRTCMode { - Legacy = "legacy", - Compatibility = "compatibility", - /** This implies using - * - sticky events - * - hashed RTC backend identity - * - the new endpoint for the jwt token on the local membership (remote memberships will always try the new jwt endpoint first -> then the legacy one) - * - use the hashed identity for the local membership - */ - Matrix_2_0 = "matrix_2_0", -} - export const matrixRTCMode = new Setting( "matrix-rtc-mode", MatrixRTCMode.Legacy, diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index fc25df48..9eb2787a 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -65,7 +65,7 @@ import { localParticipant, withCallViewModel as withCallViewModelInMode, } from "./CallViewModelTestUtils.ts"; -import { MatrixRTCMode } from "../../settings/settings.ts"; +import { MatrixRTCMode } from "../../config/ConfigOptions.ts"; import { initializeWidget } from "../../widget.ts"; initializeWidget(); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 2bbf6f4e..aaf67950 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -60,10 +60,11 @@ import { } from "../../utils/observable"; import { duplicateTiles, - MatrixRTCMode, playReactionsSound, showReactions, } from "../../settings/settings"; +import { Config } from "../../config/Config"; +import { MatrixRTCMode } from "../../config/ConfigOptions"; import { isFirefox, platform } from "../../Platform"; import { setPipEnabled$ } from "../../controls"; import { TileStore } from "../TileStore"; @@ -416,8 +417,15 @@ export function createCallViewModel$( options.encryptionSystem, matrixRTCSession, ); + // matrix_rtc_mode in config.json overrides the user's Developer Settings choice. + // It is validated at config load (src/config/Config.ts) so the cast is safe. + const configMatrixRTCMode = Config.get().matrix_rtc_mode as + | MatrixRTCMode + | undefined; const matrixRTCMode$ = - options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy); + configMatrixRTCMode !== undefined + ? constant(configMatrixRTCMode) + : (options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy)); // Each hbar seperates a block of input variables required for the CallViewModel to function. // The outputs of this block is written under the hbar. diff --git a/src/state/CallViewModel/CallViewModelTestUtils.ts b/src/state/CallViewModel/CallViewModelTestUtils.ts index 3155eb11..1d3d0fef 100644 --- a/src/state/CallViewModel/CallViewModelTestUtils.ts +++ b/src/state/CallViewModel/CallViewModelTestUtils.ts @@ -56,7 +56,7 @@ import { import { type Behavior, constant } from "../Behavior"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type MediaDevices } from "../MediaDevices"; -import { type MatrixRTCMode } from "../../settings/settings"; +import { type MatrixRTCMode } from "../../config/ConfigOptions"; mockConfig({ livekit: { livekit_service_url: "http://my-default-service-url.com" }, diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 25b7191e..8bca9182 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -26,7 +26,7 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { type LocalParticipant, type LocalTrack } from "livekit-client"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics"; -import { MatrixRTCMode } from "../../../settings/settings"; +import { MatrixRTCMode } from "../../../config/ConfigOptions"; import { type HomeserverDisconnectReason } from "./HomeserverConnected"; import { flushPromises, diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 88f3da0a..6ef494cd 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -53,7 +53,7 @@ import { import { ElementWidgetActions, widget } from "../../../widget.ts"; import { getUrlParams } from "../../../UrlParams.ts"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; -import { MatrixRTCMode } from "../../../settings/settings.ts"; +import { MatrixRTCMode } from "../../../config/ConfigOptions.ts"; import { Config } from "../../../config/Config.ts"; import { ConnectionState, diff --git a/src/state/CallViewModelWidget.test.ts b/src/state/CallViewModelWidget.test.ts index 76776720..2e4ef39d 100644 --- a/src/state/CallViewModelWidget.test.ts +++ b/src/state/CallViewModelWidget.test.ts @@ -15,7 +15,7 @@ import { constant } from "./Behavior.ts"; import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts"; import { ElementWidgetActions, widget } from "../widget.ts"; import { E2eeType } from "../e2ee/e2eeType.ts"; -import { MatrixRTCMode } from "../settings/settings.ts"; +import { MatrixRTCMode } from "../config/ConfigOptions.ts"; vi.mock("@livekit/components-core", { spy: true }); diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 7c670308..3ed60e99 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -38,7 +38,7 @@ import { type MediaDevices } from "../state/MediaDevices"; import { aliceRtcMember, localRtcMember } from "./test-fixtures"; import { type RaisedHandInfo, type ReactionInfo } from "../reactions"; import { constant } from "../state/Behavior"; -import { MatrixRTCMode } from "../settings/settings"; +import { MatrixRTCMode } from "../config/ConfigOptions"; import { createCallFooterViewModel } from "../components/CallFooterViewModel"; import { type FooterSnapshot } from "../components/CallFooter"; import { type ViewModel } from "../state/ViewModel";