diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a20fcdec..5d8f89cab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ importers: version: 10.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@rolldown/plugin-babel': specifier: ^0.2.3 - version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) '@sentry/react': specifier: ^8.0.0 version: 8.55.2(react@19.2.8) @@ -138,7 +138,7 @@ importers: version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) '@vitest/browser-playwright': specifier: ^4.1.5 version: 4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10) @@ -192,7 +192,7 @@ importers: version: 1.9.2 matrix-js-sdk: specifier: github:matrix-org/matrix-js-sdk#develop - version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0 + version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a matrix-widget-api: specifier: ^1.18.0 version: 1.18.0 @@ -1191,8 +1191,8 @@ packages: '@types/dom-mediacapture-transform': ^0.1.9 livekit-client: ^1.12.0 || ^2.1.0 - '@matrix-org/matrix-sdk-crypto-wasm@18.4.0': - resolution: {integrity: sha512-osxkU1DQ+05+anGHapjWyvZqdHUb94Id37gy54mCKn1Cq/D7iGT5oEUEhjp4oTnCLo4TOtI6ULJ/LHsapaIptQ==} + '@matrix-org/matrix-sdk-crypto-wasm@18.5.0': + resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==} engines: {node: '>= 18'} '@mdx-js/react@3.1.1': @@ -3670,9 +3670,9 @@ packages: constants-browserify@1.0.0: resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} - engines: {node: '>=18'} + content-type@3.0.0: + resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==} + engines: {node: '>=22'} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -4685,9 +4685,9 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0: - resolution: {gitHosted: true, integrity: sha512-UmlkoXs9VMZvNaDeQqBb2k+gLifnIqEZc5cxrLeEwdYEDakQLbRqrFtmvIp4ipUCjdIPkCgtzb7QLlpPBLAwqA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0} - version: 42.1.0 + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a: + resolution: {gitHosted: true, integrity: sha512-0EGwbXzvG88sBOxq4ZlCRwT4VpQj2xkizfyy0A3bTSK0ysb6/gXJ/o1WPFoqCjRGMDgPq4gAsk92xGtHA7EhPg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a} + version: 42.2.0 engines: {node: '>=22.0.0'} matrix-widget-api@1.18.0: @@ -7102,7 +7102,7 @@ snapshots: '@types/dom-mediacapture-transform': 0.1.11 livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22) - '@matrix-org/matrix-sdk-crypto-wasm@18.4.0': {} + '@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {} '@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8)': dependencies: @@ -8053,13 +8053,13 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) picomatch: 4.0.4 rolldown: 1.1.5 optionalDependencies: - '@babel/runtime': 7.29.7 + '@babel/runtime': 8.0.0 vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) '@rolldown/pluginutils@1.0.1': {} @@ -8744,12 +8744,12 @@ snapshots: - '@types/react-dom' - react-dom - '@vitejs/plugin-react@6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) babel-plugin-react-compiler: 1.0.0 '@vitest/browser-playwright@4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10)': @@ -9195,7 +9195,7 @@ snapshots: constants-browserify@1.0.0: {} - content-type@2.0.0: {} + content-type@3.0.0: {} convert-source-map@2.0.0: {} @@ -10275,13 +10275,13 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0: + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a: dependencies: '@babel/runtime': 8.0.0 - '@matrix-org/matrix-sdk-crypto-wasm': 18.4.0 + '@matrix-org/matrix-sdk-crypto-wasm': 18.5.0 another-json: 0.2.0 bs58: 6.0.0 - content-type: 2.0.0 + content-type: 3.0.0 loglevel: 1.9.2 matrix-events-sdk: 0.0.1 matrix-widget-api: 1.18.0 diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index edd17e4c6..536966e00 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -219,6 +219,18 @@ export interface ConfigOptions { * This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours. */ membership_event_expiry_ms?: number; + + /** + * The number of participants in the session at which the media encryption key will no longer + * be rotated. + * + * Rotating a key requires sending it to every participant device, so in large sessions the + * cost of rotating on every join/leave becomes prohibitive. At this limit the current key is + * kept and distributed to new joiners; no new keys are generated for joiners/leavers. + * + * Defaults to the js-sdk default (undefined). Which means that rotation will always happen. + */ + key_rotation_participant_limit?: number; }; } @@ -252,6 +264,7 @@ export interface ResolvedConfigOptions extends ConfigOptions { delayed_leave_event_restart_ms?: number; network_error_retry_ms: number; membership_event_expiry_ms?: number; + key_rotation_participant_limit?: number; }; } diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 94b152e43..3113c0727 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -22,7 +22,7 @@ import { TooltipProvider } from "@vector-im/compound-web"; import { RoomContext, useLocalParticipant } from "@livekit/components-react"; import userEvent from "@testing-library/user-event"; -import { InCallView } from "./InCallView"; +import { ActiveCall, InCallView } from "./InCallView"; import { mockLivekitRoom, mockLocalParticipant, @@ -33,7 +33,10 @@ import { type MockRTCSession, } from "../utils/test"; import { E2eeType } from "../e2ee/e2eeType"; -import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; +import { + getBasicCallViewModelEnvironment, + getBasicRTCSession, +} from "../utils/test-viewmodel"; import { type CallViewModel, type CallViewModelOptions, @@ -45,6 +48,8 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer"; import { MediaDevicesContext } from "../MediaDevicesContext"; import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices"; import { AppBar } from "../AppBar"; +import { type MatrixInfo } from "./VideoPreview"; +import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { initializeWidget } from "../widget"; initializeWidget(); @@ -78,6 +83,17 @@ const remoteParticipant = mockRemoteParticipant({ identity: "@alice:example.org:AAAAAA", }); +const matrixInfo = { + userId: "", + displayName: "", + avatarUrl: "", + roomId: "", + roomName: "", + roomAlias: null, + roomAvatar: null, + e2eeSystem: { kind: E2eeType.NONE }, +} satisfies MatrixInfo; + let useRoomEncryptionSystemMock: MockedFunction; beforeEach(() => { @@ -122,12 +138,13 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { remoteParticipants$: of([remoteParticipant]), }, ); - const { vm, footerVm, rtcSession } = getBasicCallViewModelEnvironment( - [local, alice], - undefined, - mediaDevices, - args.callViewModelOptions, - ); + const { vm, footerVm, developerSettingsVm, rtcSession } = + getBasicCallViewModelEnvironment( + [local, alice], + undefined, + mediaDevices, + args.callViewModelOptions, + ); rtcSession.joined = true; const room = rtcSession.room; @@ -140,18 +157,8 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { muteStates={muteState} vm={vm} footerVm={footerVm} - matrixInfo={{ - userId: "", - displayName: "", - avatarUrl: "", - roomId: "", - roomName: "", - roomAlias: null, - roomAvatar: null, - e2eeSystem: { - kind: E2eeType.NONE, - }, - }} + developerSettingsVm={developerSettingsVm} + matrixInfo={matrixInfo} matrixRoom={room} onShareClick={null} /> @@ -226,3 +233,34 @@ describe("InCallView", () => { }); }); }); + +describe("ActiveCall", () => { + it("creates the view models and renders the call", async () => { + const mediaDevices = mockMediaDevices({}); + const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]); + const { findByTestId } = render( + + + + + + {}} + /> + + + + + , + ); + // Rendering at all proves ActiveCall created all of its view models + expect(await findByTestId("incall_leave")).toBeVisible(); + }); +}); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index cab450bc5..a57dcce2b 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -83,6 +83,8 @@ import { ObservableScope } from "../state/ObservableScope.ts"; import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx"; import { SettingsIconButton } from "../button/Button.tsx"; import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx"; +import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel.ts"; +import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab.tsx"; import { type ViewModel } from "../state/ViewModel.ts"; import { RingingStatus } from "../tile/RingingStatus.tsx"; import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx"; @@ -96,7 +98,7 @@ declare module "react" { export interface ActiveCallProps extends Omit< InCallViewProps, - "vm" | "livekitRoom" | "connState" | "footerVm" + "vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm" > { e2eeSystem: EncryptionSystem; // TODO refactor those reasons into an enum @@ -110,6 +112,9 @@ export const ActiveCall: FC = (props) => { const [footerVm, setFooterVm] = useState | null>( null, ); + const [developerSettingsVm, setDeveloperSettingsVm] = + useState | null>(null); + const urlParams = useUrlParams(); const mediaDevices = useMediaDevices(); const trackProcessorState$ = useTrackProcessorObservable$(); @@ -169,6 +174,7 @@ export const ActiveCall: FC = (props) => { `${props.client.getUserId()}:${props.client.getDeviceId()}`, ); setFooterVm(footerVm); + setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm)); return (): void => { scope.end(); @@ -188,10 +194,16 @@ export const ActiveCall: FC = (props) => { if (vm === null) return null; if (footerVm === null) return null; + if (developerSettingsVm === null) return null; return ( - + ); }; @@ -200,6 +212,7 @@ export interface InCallViewProps { client: MatrixClient; vm: CallViewModel; footerVm: ViewModel; + developerSettingsVm: ViewModel; matrixInfo: MatrixInfo; rtcSession: MatrixRTCSession; matrixRoom: MatrixRoom; @@ -211,6 +224,7 @@ export const InCallView: FC = ({ client, vm, footerVm, + developerSettingsVm, matrixInfo, matrixRoom, muteStates, @@ -633,6 +647,7 @@ export const InCallView: FC = ({ onDismiss={(): void => setSettingsOpen(false)} tab={settingsTab} onTabChange={setSettingsTab} + developerSettingsVm={developerSettingsVm} livekitRooms={allConnections .getConnections() .map((connectionItem) => ({ diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index a3a19938c..bd2b40b7d 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -12,7 +12,12 @@ import { TooltipProvider } from "@vector-im/compound-web"; import type { MatrixClient } from "matrix-js-sdk"; import type { Room as LivekitRoom } from "livekit-client"; -import { DeveloperSettingsTab } from "./DeveloperSettingsTab"; +import { + DeveloperSettingsTab, + type DeveloperSettingsSnapshot, +} from "./DeveloperSettingsTab"; +import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel"; +import { createStaticViewModel } from "../state/ViewModel"; import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; import { customLivekitUrl as customLivekitUrlSetting, @@ -107,6 +112,7 @@ describe("DeveloperSettingsTab", () => { roomId={"#room:example.org"} livekitRooms={livekitRooms} env={{ MY_MOCK_ENV: 10, ENV: "test" } as unknown as ImportMetaEnv} + vm={outOfCallDeveloperSettingsTabViewModel} />, ); @@ -136,6 +142,7 @@ describe("DeveloperSettingsTab", () => { , ); @@ -159,6 +166,7 @@ describe("DeveloperSettingsTab", () => { client={client} roomId="#testRoom" env={{} as unknown as ImportMetaEnv} + vm={outOfCallDeveloperSettingsTabViewModel} /> , ); @@ -181,6 +189,7 @@ describe("DeveloperSettingsTab", () => { client={client} roomId="#testRoom" env={{} as unknown as ImportMetaEnv} + vm={outOfCallDeveloperSettingsTabViewModel} /> , ); @@ -206,6 +215,7 @@ describe("DeveloperSettingsTab", () => { client={client} roomId="#testRoom" env={{} as unknown as ImportMetaEnv} + vm={outOfCallDeveloperSettingsTabViewModel} /> , ); @@ -236,6 +246,7 @@ describe("DeveloperSettingsTab", () => { client={client} roomId="#testRoom" env={{} as unknown as ImportMetaEnv} + vm={outOfCallDeveloperSettingsTabViewModel} /> , ); @@ -273,6 +284,7 @@ describe("DeveloperSettingsTab", () => { , ); @@ -305,6 +317,7 @@ describe("DeveloperSettingsTab", () => { , ); @@ -345,6 +358,7 @@ describe("DeveloperSettingsTab", () => { , ); @@ -378,6 +392,7 @@ describe("DeveloperSettingsTab", () => { , ); @@ -400,4 +415,74 @@ describe("DeveloperSettingsTab", () => { }, ); }); + + describe("KeyRotationStatus", () => { + it("displays active status when key rotation is not suppressed", async () => { + const client = createMockMatrixClient(); + const vm = createStaticViewModel({ + keyRotation: { suppressed: false, participantCount: 5 }, + }); + + render( + + + , + ); + + await waitFor(() => + expect( + screen.getByText(/Media key rotation: active \(5 participants\)/), + ).toBeInTheDocument(), + ); + }); + + it("displays suppressed status when key rotation is suppressed", async () => { + const client = createMockMatrixClient(); + const vm = createStaticViewModel({ + keyRotation: { suppressed: true, participantCount: 50 }, + }); + + render( + + + , + ); + + await waitFor(() => + expect( + screen.getByText( + /Media key rotation: suppressed, participant limit reached \(50 participants\)/, + ), + ).toBeInTheDocument(), + ); + }); + + it("does not render KeyRotationStatus when not in a call", async () => { + const client = createMockMatrixClient(); + + render( + + + , + ); + + await waitFor(() => + expect( + screen.queryByText(/Media key rotation:/), + ).not.toBeInTheDocument(), + ); + }); + }); }); diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 0b45b4d3e..25b3c81ce 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -68,6 +68,39 @@ import settingsStyles from "./SettingsModal.module.css"; import { Slider } from "../Slider"; import { useUrlParams } from "../UrlParams"; import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; +import { useBehavior } from "../useBehavior"; +import { type ViewModel } from "../state/ViewModel.ts"; + +/** + * The state of MatrixRTC's media key rotation. + */ +export interface KeyRotationInfo { + /** Whether the call is large enough that MatrixRTC has stopped rotating the media key. */ + suppressed: boolean; + participantCount: number; +} + +/** + * The Snapshot combines all fields the developer settings tab needs from the + * surrounding call. Everything else in this tab is read from the settings store + * or the environment directly. + */ +export interface DeveloperSettingsSnapshot { + /** The media key rotation state, or `null` when we are not in a call. */ + keyRotation: KeyRotationInfo | null; +} + +/** + * Shows whether the call is large enough that MatrixRTC has stopped rotating the media key. + */ +const KeyRotationStatus: FC<{ info: KeyRotationInfo }> = ({ info }) => ( +

+ Media key rotation:{" "} + {info.suppressed + ? `suppressed, participant limit reached (${info.participantCount} participants)` + : `active (${info.participantCount} participants)`} +

+); interface Props { client: MatrixClient; @@ -79,6 +112,7 @@ interface Props { livekitAlias?: string; }[]; env: ImportMetaEnv; + vm: ViewModel; } export const DeveloperSettingsTab: FC = ({ @@ -86,8 +120,10 @@ export const DeveloperSettingsTab: FC = ({ livekitRooms, roomId, env, + vm, }) => { const { t } = useTranslation(); + const keyRotation = useBehavior(vm.keyRotation$); const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting); const [debugTileLayout, setDebugTileLayout] = useSetting( debugTileLayoutSetting, @@ -364,6 +400,7 @@ export const DeveloperSettingsTab: FC = ({ id: client.getDeviceId() || "unknown", })}

+ {keyRotation !== null && } { + it("projects the key rotation state of the call", () => { + const keyRotationSuppressed$ = new BehaviorSubject(false); + const participantCount$ = new BehaviorSubject(5); + const vm = createDeveloperSettingsTabViewModel(testScope(), { + keyRotationSuppressed$, + participantCount$, + } as unknown as CallViewModel); + + expect(vm.keyRotation$.value).toEqual({ + suppressed: false, + participantCount: 5, + }); + + participantCount$.next(50); + keyRotationSuppressed$.next(true); + + expect(vm.keyRotation$.value).toEqual({ + suppressed: true, + participantCount: 50, + }); + }); +}); + +describe("outOfCallDeveloperSettingsTabViewModel", () => { + it("has no key rotation state", () => { + expect(outOfCallDeveloperSettingsTabViewModel.keyRotation$.value).toBe( + null, + ); + }); +}); diff --git a/src/settings/DeveloperSettingsTabViewModel.ts b/src/settings/DeveloperSettingsTabViewModel.ts new file mode 100644 index 000000000..9bfc7440b --- /dev/null +++ b/src/settings/DeveloperSettingsTabViewModel.ts @@ -0,0 +1,43 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { combineLatest } from "rxjs"; + +import { type CallViewModel } from "../state/CallViewModel/CallViewModel.ts"; +import { type ObservableScope } from "../state/ObservableScope.ts"; +import { createStaticViewModel, type ViewModel } from "../state/ViewModel.ts"; +import { type DeveloperSettingsSnapshot } from "./DeveloperSettingsTab.tsx"; + +/** + * Creates the ViewModel for the developer settings tab while in a call. + * + * Only the call state the tab actually renders is projected here, so that the + * tab does not need to know about the CallViewModel. + * + * @param scope - ObservableScope that bounds the lifetime of derived behaviors. + * @param callModel - The root CallViewModel; provides the key rotation state. + */ +export function createDeveloperSettingsTabViewModel( + scope: ObservableScope, + callModel: CallViewModel, +): ViewModel { + return { + keyRotation$: scope.behavior( + combineLatest( + [callModel.keyRotationSuppressed$, callModel.participantCount$], + (suppressed, participantCount) => ({ suppressed, participantCount }), + ), + ), + }; +} + +/** + * The ViewModel for the developer settings tab outside of a call (lobby, user + * menu), where no call state exists. All call specific fields are `null`. + */ +export const outOfCallDeveloperSettingsTabViewModel: ViewModel = + createStaticViewModel({ keyRotation: null }); diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 665eadf01..b2ffef4ab 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -29,11 +29,16 @@ import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { Slider } from "../Slider"; import { DeviceSelection } from "./DeviceSelection"; import { useTrackProcessor } from "../livekit/TrackProcessorContext"; -import { DeveloperSettingsTab } from "./DeveloperSettingsTab"; +import { + DeveloperSettingsTab, + type DeveloperSettingsSnapshot, +} from "./DeveloperSettingsTab"; import { FieldRow, InputField } from "../input/Input"; import { useSubmitRageshake } from "./submit-rageshake"; import { useUrlParams } from "../UrlParams"; import { useBehavior } from "../useBehavior"; +import { type ViewModel } from "../state/ViewModel.ts"; +import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel"; type SettingsTab = | "audio" @@ -56,6 +61,8 @@ interface Props { url: string; isLocal?: boolean; }[]; + /** Only available while in a call. Used by the developer tab. */ + developerSettingsVm?: ViewModel; } export const defaultSettingsTab: SettingsTab = "audio"; @@ -68,6 +75,7 @@ export const SettingsModal: FC = ({ client, roomId, livekitRooms, + developerSettingsVm, }) => { const { t } = useTranslation(); @@ -220,6 +228,7 @@ export const SettingsModal: FC = ({ client={client} livekitRooms={livekitRooms} roomId={roomId} + vm={developerSettingsVm ?? outOfCallDeveloperSettingsTabViewModel} /> ), }; diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 43d42a98c..09f73d1a6 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -114,6 +114,7 @@ import { type LocalTransport, } from "./localMember/LocalTransport.ts"; import { + createKeyRotationSuppressed$, createMemberships$, membershipsAndTransports$, } from "../SessionBehaviors.ts"; @@ -302,6 +303,12 @@ export interface CallViewModel { * multiple devices. */ participantCount$: Behavior; + /** + * Whether the call has grown large enough that MatrixRTC has stopped rotating the media + * encryption key. While this is true the key in use is still shared with new joiners, but no new + * key is generated when someone joins or leaves. + */ + keyRotationSuppressed$: Behavior; allConnections$: Behavior; /** Participants sorted by livekit room so they can be used in the audio rendering */ livekitRoomItems$: Behavior; @@ -861,6 +868,11 @@ export function createCallViewModel$( matrixLivekitMembers$.pipe(map((ms) => ms.length)), ); + const keyRotationSuppressed$ = createKeyRotationSuppressed$( + scope, + matrixRTCSession, + ); + const leaveSoundEffect$ = userMedia$.pipe( pairwise(), filter( @@ -1797,6 +1809,7 @@ export function createCallViewModel$( ), allConnections$, participantCount$: participantCount$, + keyRotationSuppressed$: keyRotationSuppressed$, handsRaised$: handsRaised$, reactions$: reactions$, joinSoundEffect$: joinSoundEffect$, diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 8743559da..9ea6bb72c 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -121,6 +121,57 @@ describe("LocalMembership", () => { expect.objectContaining({ manageMediaKeys: true }), ); }); + + it("passes keyRotationParticipantLimit from config to joinRTCSession", () => { + mockConfig({ + livekit: { livekit_service_url: "http://my-default-service-url.com" }, + matrix_rtc_session: { + delayed_leave_event_delay_ms: 0, + network_error_retry_ms: 0, + key_rotation_participant_limit: 50, + }, + }); + + const mockedSession = vi.mocked({ + room: { + roomId: "roomId", + client: { + getDomain: vi.fn().mockReturnValue("example.org"), + getOpenIdToken: vi.fn().mockResolvedValue({ + access_token: "ACCCESS_TOKEN", + token_type: "Bearer", + matrix_server_name: "localhost", + expires_in: 10000, + }), + }, + }, + memberships: [], + joinRTCSession: vi.fn(), + }) as unknown as MatrixRTCSession; + + enterRTCSession( + mockedSession, + ownMemberMock, + { + livekit_alias: "roomId", + livekit_service_url: "http://my-livekit-service-url.com", + type: "livekit", + }, + { + encryptMedia: true, + matrixRTCMode: MATRIX_RTC_MODE, + }, + ); + + expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith( + expect.any(Object), + [], + expect.any(Object), + expect.objectContaining({ + keyRotationParticipantLimit: 50, + }), + ); + }); }); const defaultCreateLocalMemberValues = { diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index bf4ce01cf..2f4fde26b 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -902,6 +902,8 @@ export function enterRTCSession( makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms, membershipEventExpiryMs: matrixRtcSessionConfig?.membership_event_expiry_ms, + keyRotationParticipantLimit: + matrixRtcSessionConfig?.key_rotation_participant_limit, unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0, maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount, }, diff --git a/src/state/SessionBehaviors.test.ts b/src/state/SessionBehaviors.test.ts new file mode 100644 index 000000000..fca24767c --- /dev/null +++ b/src/state/SessionBehaviors.test.ts @@ -0,0 +1,41 @@ +/* +Copyright 2025 Element Creations 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 } from "vitest"; +import { MatrixRTCSessionEvent } from "matrix-js-sdk/lib/matrixrtc"; +import { EventEmitter } from "events"; + +import { createKeyRotationSuppressed$ } from "./SessionBehaviors"; +import { testScope } from "../utils/test"; + +describe("SessionBehaviors", () => { + describe("createKeyRotationSuppressed$", () => { + it("emits initial value from isKeyRotationSuppressed and updates when KeyRotationSuppressedChanged event is emitted", () => { + const scope = testScope(); + const emitter = new EventEmitter(); + const mockSession = Object.assign(emitter, { + on: vi.fn(), + off: vi.fn(), + isKeyRotationSuppressed: false, + }); + + const keyRotationSuppressed$ = createKeyRotationSuppressed$( + scope, + mockSession as any, + ); + + expect(keyRotationSuppressed$.value).toBe(false); + emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, true); + + expect(keyRotationSuppressed$.value).toBe(true); + + emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, false); + + expect(keyRotationSuppressed$.value).toBe(false); + }); + }); +}); diff --git a/src/state/SessionBehaviors.ts b/src/state/SessionBehaviors.ts index 652e43b5f..784e33666 100644 --- a/src/state/SessionBehaviors.ts +++ b/src/state/SessionBehaviors.ts @@ -88,3 +88,22 @@ export const createMemberships$ = ( new Epoch(matrixRTCSession.memberships), ); }; + +/** + * Whether the session has grown large enough that MatrixRTC has stopped rotating the media + * encryption key. While this is true the key in use is still shared with new joiners, but no new + * key is generated when someone joins or leaves. + */ +export const createKeyRotationSuppressed$ = ( + scope: ObservableScope, + matrixRTCSession: MatrixRTCSession, +): Behavior => { + return scope.behavior( + fromEvent( + matrixRTCSession, + MatrixRTCSessionEvent.KeyRotationSuppressedChanged, + (suppressed: boolean) => suppressed, + ), + matrixRTCSession.isKeyRotationSuppressed, + ); +}; diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index c8282ce51..f53910024 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -42,6 +42,8 @@ import { MatrixRTCMode } from "../config/ConfigOptions"; import { createCallFooterViewModel } from "../components/CallFooterViewModel"; import { type FooterSnapshot } from "../components/CallFooter"; import { type ViewModel } from "../state/ViewModel"; +import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel"; +import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab"; mockConfig({ livekit: { livekit_service_url: "https://example.com" } }); @@ -140,6 +142,7 @@ export function getBasicCallViewModelEnvironment( ): { vm: CallViewModel; footerVm: ViewModel; + developerSettingsVm: ViewModel; rtcMemberships$: BehaviorSubject; rtcSession: MockRTCSession; handRaisedSubject$: BehaviorSubject>; @@ -188,6 +191,7 @@ export function getBasicCallViewModelEnvironment( return { vm, footerVm, + developerSettingsVm: createDeveloperSettingsTabViewModel(testScope(), vm), rtcMemberships$, rtcSession, handRaisedSubject$: handRaisedSubject$, diff --git a/src/utils/test.ts b/src/utils/test.ts index fd4ce58f1..de74ac53e 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -487,6 +487,8 @@ export class MockRTCSession extends TypedEventEmitter< return this.joined; } + public isKeyRotationSuppressed = false; + public withMemberships( rtcMembers$: Behavior[]>, ): MockRTCSession {