diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 94b152e43..eafb54c55 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -122,12 +122,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,6 +141,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { muteStates={muteState} vm={vm} footerVm={footerVm} + developerSettingsVm={developerSettingsVm} matrixInfo={{ userId: "", displayName: "", diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 5aa9667ce..f8a8f3896 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$(); @@ -168,7 +173,9 @@ export const ActiveCall: FC = (props) => { mediaDevices, `${props.client.getUserId()}:${props.client.getDeviceId()}`, ); + const developerSettingsVm = createDeveloperSettingsTabViewModel(scope, vm); setFooterVm(footerVm); + setDeveloperSettingsVm(developerSettingsVm); return (): void => { scope.end(); @@ -188,10 +195,16 @@ export const ActiveCall: FC = (props) => { if (vm === null) return null; if (footerVm === null) return null; + if (developerSettingsVm === null) return null; return ( - + ); }; @@ -200,6 +213,7 @@ export interface InCallViewProps { client: MatrixClient; vm: CallViewModel; footerVm: ViewModel; + developerSettingsVm: ViewModel; matrixInfo: MatrixInfo; rtcSession: MatrixRTCSession; matrixRoom: MatrixRoom; @@ -211,6 +225,7 @@ export const InCallView: FC = ({ client, vm, footerVm, + developerSettingsVm, matrixInfo, matrixRoom, muteStates, @@ -632,7 +647,7 @@ export const InCallView: FC = ({ onDismiss={(): void => setSettingsOpen(false)} tab={settingsTab} onTabChange={setSettingsTab} - vm={vm} + developerSettingsVm={developerSettingsVm} livekitRooms={allConnections .getConnections() .map((connectionItem) => ({ diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index 3abb4990a..c6777ad5e 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -9,11 +9,15 @@ import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; import { render, waitFor, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { TooltipProvider } from "@vector-im/compound-web"; -import { BehaviorSubject } from "rxjs"; 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, @@ -108,6 +112,7 @@ describe("DeveloperSettingsTab", () => { roomId={"#room:example.org"} livekitRooms={livekitRooms} env={{ MY_MOCK_ENV: 10, ENV: "test" } as unknown as ImportMetaEnv} + vm={outOfCallDeveloperSettingsTabViewModel} />, ); @@ -137,6 +142,7 @@ describe("DeveloperSettingsTab", () => { , ); @@ -160,6 +166,7 @@ describe("DeveloperSettingsTab", () => { client={client} roomId="#testRoom" env={{} as unknown as ImportMetaEnv} + vm={outOfCallDeveloperSettingsTabViewModel} /> , ); @@ -182,6 +189,7 @@ describe("DeveloperSettingsTab", () => { client={client} roomId="#testRoom" env={{} as unknown as ImportMetaEnv} + vm={outOfCallDeveloperSettingsTabViewModel} /> , ); @@ -207,6 +215,7 @@ describe("DeveloperSettingsTab", () => { client={client} roomId="#testRoom" env={{} as unknown as ImportMetaEnv} + vm={outOfCallDeveloperSettingsTabViewModel} /> , ); @@ -237,6 +246,7 @@ describe("DeveloperSettingsTab", () => { client={client} roomId="#testRoom" env={{} as unknown as ImportMetaEnv} + vm={outOfCallDeveloperSettingsTabViewModel} /> , ); @@ -274,6 +284,7 @@ describe("DeveloperSettingsTab", () => { , ); @@ -306,6 +317,7 @@ describe("DeveloperSettingsTab", () => { , ); @@ -350,6 +362,7 @@ describe("DeveloperSettingsTab", () => { , ); @@ -389,6 +402,7 @@ describe("DeveloperSettingsTab", () => { , ); @@ -417,17 +431,16 @@ describe("DeveloperSettingsTab", () => { describe("KeyRotationStatus", () => { it("displays active status when key rotation is not suppressed", async () => { const client = createMockMatrixClient(); - const mockVm = { - keyRotationSuppressed$: new BehaviorSubject(false), - participantCount$: new BehaviorSubject(5), - } as unknown as any; + const vm = createStaticViewModel({ + keyRotation: { suppressed: false, participantCount: 5 }, + }); render( , ); @@ -443,17 +456,16 @@ describe("DeveloperSettingsTab", () => { it("displays suppressed status when key rotation is suppressed", async () => { const client = createMockMatrixClient(); - const mockVm = { - keyRotationSuppressed$: new BehaviorSubject(true), - participantCount$: new BehaviorSubject(50), - } as unknown as any; + const vm = createStaticViewModel({ + keyRotation: { suppressed: true, participantCount: 50 }, + }); render( , ); @@ -469,7 +481,7 @@ describe("DeveloperSettingsTab", () => { ).toBeInTheDocument(); }); - it("does not render KeyRotationStatus when vm is not provided", async () => { + it("does not render KeyRotationStatus when not in a call", async () => { const client = createMockMatrixClient(); render( @@ -477,6 +489,7 @@ describe("DeveloperSettingsTab", () => { , ); diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 76e0c03ec..915052bd1 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -67,25 +67,40 @@ import styles from "./DeveloperSettingsTab.module.css"; import settingsStyles from "./SettingsModal.module.css"; import { Slider } from "../Slider"; import { useUrlParams } from "../UrlParams"; -import { type CallViewModel } from "../state/CallViewModel/CallViewModel.ts"; 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<{ vm: CallViewModel }> = ({ vm }) => { - const suppressed = useBehavior(vm.keyRotationSuppressed$); - const participantCount = useBehavior(vm.participantCount$); - return ( -

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

- ); -}; +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; @@ -97,8 +112,7 @@ interface Props { livekitAlias?: string; }[]; env: ImportMetaEnv; - /** Only available while in a call. */ - vm?: CallViewModel; + vm: ViewModel; } export const DeveloperSettingsTab: FC = ({ @@ -109,6 +123,7 @@ export const DeveloperSettingsTab: FC = ({ vm, }) => { const { t } = useTranslation(); + const keyRotation = useBehavior(vm.keyRotation$); const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting); const [debugTileLayout, setDebugTileLayout] = useSetting( debugTileLayoutSetting, @@ -385,7 +400,7 @@ export const DeveloperSettingsTab: FC = ({ id: client.getDeviceId() || "unknown", })}

- {vm && } + {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 f47cfea4e..b2ffef4ab 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -29,12 +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 CallViewModel } from "../state/CallViewModel/CallViewModel.ts"; +import { type ViewModel } from "../state/ViewModel.ts"; +import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel"; type SettingsTab = | "audio" @@ -58,7 +62,7 @@ interface Props { isLocal?: boolean; }[]; /** Only available while in a call. Used by the developer tab. */ - vm?: CallViewModel; + developerSettingsVm?: ViewModel; } export const defaultSettingsTab: SettingsTab = "audio"; @@ -71,7 +75,7 @@ export const SettingsModal: FC = ({ client, roomId, livekitRooms, - vm, + developerSettingsVm, }) => { const { t } = useTranslation(); @@ -224,7 +228,7 @@ export const SettingsModal: FC = ({ client={client} livekitRooms={livekitRooms} roomId={roomId} - vm={vm} + vm={developerSettingsVm ?? outOfCallDeveloperSettingsTabViewModel} /> ), }; diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 526fc95c9..fde9aac59 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$,