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$,