Compare commits

..
Author SHA1 Message Date
Robin 1695d3f518 Use flushPromises for readability 2026-09-10 21:15:48 +02:00
Matthew HodgsonandClaude Fable 5.1 b284bb00c0 Cover the stopProcessor failure path and trackProcessorSync
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSB8Qb89KDoTNRV83dWJHa
2026-09-10 18:28:57 +01:00
Matthew Hodgson 2e5b5cb186 Don't attach the background blur processor to an ended camera track
If the camera track has already ended by the time the blur processor is
applied to it, MediaStreamTrackProcessor cannot be constructed and
setProcessor rejects. Both call sites used `void`, so this surfaced as

    Unhandled promise rejection: TypeError: Failed to construct
    'MediaStreamTrackProcessor': Input track cannot be ended

(element-call-rageshakes#17225). Skip ended tracks, and catch and log
processor attach/detach failures instead of leaking them.
2026-09-03 14:50:28 +01:00
Johannes Marbach d765267c43 Merge pull request #4229 from element-hq/matthew/fix-rageshake-flush
Fix periodic rageshake log flush never running
2026-09-03 07:29:51 +02:00
Matthew Hodgson 698a98a9c8 Test that logs are flushed to IndexedDB periodically 2026-09-02 18:55:18 +01:00
Matthew Hodgson 2db24e2868 Fix periodic rageshake log flush never running
The throttled flush callback returned this.flush instead of calling it
(regressed in #2607), so logs were only persisted to IndexedDB on
rageshake submission or beforeunload. When the host removes the widget
iframe at hangup, the whole call's logs were lost, so a rageshake filed
from a later call carries nothing from the affected one.
2026-09-02 18:46:12 +01:00
renovate[bot] efdee7dcdb Update ghcr.io/element-hq/element-web:develop Docker digest to c107ec3 (#4221)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 02:53:52 +00:00
Johannes Marbach d7d3dcde58 Merge pull request #4219 from element-hq/renovate/matrix-widget-api
Update dependency matrix-widget-api to v1.19.0
2026-08-31 07:34:07 +02:00
renovate[bot] 9330b907fc Update ghcr.io/element-hq/element-web:develop Docker digest to 7c5eb96 (#4218)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-31 00:49:32 +00:00
renovate[bot] 256ff7a0b6 Update dependency matrix-widget-api to v1.19.0 2026-08-31 00:40:41 +00:00
renovate[bot] 6ddcc15206 Update ghcr.io/element-hq/element-web:develop Docker digest to e8727bb (#4216)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 01:09:28 +00:00
renovate[bot] a56e934841 Update ghcr.io/element-hq/element-web:develop Docker digest to e29b408 (#4215)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 02:25:08 +00:00
Timo efde51b8c1 Merge pull request #4172 from element-hq/toger5/add-key-rotation-participant-limit
Add key rotation participant limit to devtools and config.json
2026-08-28 16:38:20 +02:00
Timo K. 2a7c6c3c06 Oh dear test coverage... 2026-08-28 16:22:08 +02:00
Timo K. 5bab17b668 Update InCallView.tsx 2026-08-28 15:52:52 +02:00
Timo K. d7d28a1b55 formatting 2026-08-28 15:50:48 +02:00
Timo K. 790bd1e2bf Update LocalMember.test.ts 2026-08-28 15:44:46 +02:00
Timo K. 17231e545c Update DeveloperSettingsTab.test.tsx 2026-08-28 15:38:57 +02:00
Timo K. 17d9d19c91 ObservableScope -> testScope 2026-08-28 15:32:59 +02:00
Timo K. 3d37abf490 merge two tests 2026-08-28 15:31:23 +02:00
Timo K. 92f504d886 remove oldest membership mocking 2026-08-28 15:29:16 +02:00
Timo K. b0d098e065 Merge branch 'main' into toger5/add-key-rotation-participant-limit 2026-08-28 15:27:43 +02:00
Timo K. a526a8ae19 Update config.sample.json 2026-08-28 15:26:00 +02:00
Timo K. 1146d3820a review. Dont pass full vm to developer settings 2026-08-28 15:20:49 +02:00
renovate[bot] a4dda428f5 Update ghcr.io/element-hq/element-web:develop Docker digest to 133160c (#4213)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 03:01:16 +00:00
renovate[bot] 5982586f24 Update ghcr.io/element-hq/element-web:develop Docker digest to f163e7a (#4211)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 01:06:37 +00:00
Robin e6b4aae714 Merge pull request #4208 from element-hq/remove-legacy-mode
Remove MatrixRTC legacy mode
2026-08-26 20:27:46 +02:00
Timo K. 06b9105cee Update DeveloperSettingsTab.test.tsx 2026-08-25 23:32:22 +02:00
Timo K. 2b5979162a add more tests 2026-08-25 23:30:22 +02:00
Timo K. 5399f14012 add tests 2026-08-25 22:43:15 +02:00
Timo K. f2ffb06f6c use develop branch of matrix-js-sdk 2026-08-25 22:03:59 +02:00
Timo K. 9999a755dd Update pnpm-lock.yaml 2026-08-17 12:33:31 +02:00
Timo K. 9857ed53e3 Add key roatation limit to config.json 2026-08-17 11:17:22 +02:00
Timo K. 0aa9d94826 add key rotation debug information 2026-08-17 11:17:22 +02:00
Timo K. f2622f0032 Add vm to developer settings tab 2026-08-17 11:17:22 +02:00
24 changed files with 857 additions and 167 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:0332028836603e91689053f157847b4deef9342bd19e3195eeb81793b7e4046e
image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:0332028836603e91689053f157847b4deef9342bd19e3195eeb81793b7e4046e
image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f
+25 -25
View File
@@ -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,10 +192,10 @@ 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
version: 1.19.0
node-stdlib-browser:
specifier: ^1.3.1
version: 1.3.1
@@ -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,13 +4685,13 @@ 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:
resolution: {integrity: sha512-4T2f2koWmx05p1BLcT/9YGGGPSXpPT+PA4Oap/5fjhXsWPxMGJiGL97YpANMYLKsnE1sScYFeuyxIgdc1Qo+Ew==}
matrix-widget-api@1.19.0:
resolution: {integrity: sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw==}
md5.js@1.3.5:
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
@@ -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,21 +10275,21 @@ 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
matrix-widget-api: 1.19.0
p-retry: 8.0.0
sdp-transform: 3.0.0
unhomoglyph: 1.0.6
matrix-widget-api@1.18.0:
matrix-widget-api@1.19.0:
dependencies:
'@types/events': 3.0.3
events: 3.3.0
+13
View File
@@ -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;
};
}
+87
View File
@@ -0,0 +1,87 @@
/*
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 { describe, expect, it, vi } from "vitest";
import { type LocalVideoTrack } from "livekit-client";
import {
type BackgroundOptions,
type ProcessorWrapper,
} from "@livekit/track-processors";
import { applyProcessor, trackProcessorSync } from "./TrackProcessorContext";
import { constant } from "../state/Behavior";
import { flushPromises, testScope } from "../utils/test";
const processor = {} as ProcessorWrapper<BackgroundOptions>;
function mockTrack(
readyState: MediaStreamTrackState,
current?: ProcessorWrapper<BackgroundOptions>,
): LocalVideoTrack {
return {
mediaStreamTrack: { readyState },
getProcessor: vi.fn().mockReturnValue(current),
setProcessor: vi.fn().mockResolvedValue(undefined),
stopProcessor: vi.fn().mockResolvedValue(undefined),
} as unknown as LocalVideoTrack;
}
describe("applyProcessor", () => {
it("attaches the processor to a live track", () => {
const track = mockTrack("live");
applyProcessor(track, processor);
expect(track.setProcessor).toHaveBeenCalledWith(processor);
});
it("does not attach the processor to an ended track", () => {
const track = mockTrack("ended");
applyProcessor(track, processor);
expect(track.setProcessor).not.toHaveBeenCalled();
});
it("does not surface a rejected setProcessor", async () => {
const track = mockTrack("live");
vi.mocked(track.setProcessor).mockRejectedValue(
new TypeError("Input track cannot be ended"),
);
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
applyProcessor(track, processor);
await flushPromises();
process.off("unhandledRejection", unhandled);
expect(unhandled).not.toHaveBeenCalled();
});
it("stops the processor when none is wanted", () => {
const track = mockTrack("live", processor);
applyProcessor(track, undefined);
expect(track.stopProcessor).toHaveBeenCalled();
});
it("does not surface a rejected stopProcessor", async () => {
const track = mockTrack("live", processor);
vi.mocked(track.stopProcessor).mockRejectedValue(new Error("nope"));
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
applyProcessor(track, undefined);
await flushPromises();
process.off("unhandledRejection", unhandled);
expect(unhandled).not.toHaveBeenCalled();
});
});
describe("trackProcessorSync", () => {
it("applies the processor to the current track", () => {
const track = mockTrack("live");
trackProcessorSync(
testScope(),
constant(track),
constant({ supported: true, processor }),
);
expect(track.setProcessor).toHaveBeenCalledWith(processor);
});
});
+31 -13
View File
@@ -19,6 +19,7 @@ import {
useMemo,
} from "react";
import { type LocalVideoTrack } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { combineLatest, map, type Observable } from "rxjs";
import { useObservable } from "observable-hooks";
@@ -65,6 +66,34 @@ export function useTrackProcessorObservable$(): Observable<ProcessorState> {
return state$;
}
/**
* Attaches or detaches the processor so that the track matches the desired
* state, without throwing.
*/
export function applyProcessor(
videoTrack: LocalVideoTrack,
processor: ProcessorWrapper<BackgroundOptions> | undefined,
): void {
if (processor && !videoTrack.getProcessor()) {
// A MediaStreamTrackProcessor cannot be constructed on an ended track
// (e.g. the camera was stopped while the processor was being applied),
// and setProcessor rejects with a TypeError. The track is going away
// anyway, so there is nothing to attach to.
if (videoTrack.mediaStreamTrack.readyState === "ended") {
logger.debug("Not attaching video processor to an ended track");
return;
}
videoTrack.setProcessor(processor).catch((e) => {
logger.warn("Failed to attach video processor", e);
});
}
if (!processor && videoTrack.getProcessor()) {
videoTrack.stopProcessor().catch((e) => {
logger.warn("Failed to stop video processor", e);
});
}
}
/**
* Updates your video tracks to always use the given processor.
*/
@@ -78,13 +107,7 @@ export const trackProcessorSync = (
.subscribe(([videoTrack, processorState]) => {
if (!processorState) return;
if (!videoTrack) return;
const { processor } = processorState;
if (processor && !videoTrack.getProcessor()) {
void videoTrack.setProcessor(processor);
}
if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor();
}
applyProcessor(videoTrack, processorState.processor);
});
};
@@ -94,12 +117,7 @@ export const useTrackProcessorSync = (
const { processor } = useTrackProcessor();
useEffect(() => {
if (!videoTrack) return;
if (processor && !videoTrack.getProcessor()) {
void videoTrack.setProcessor(processor);
}
if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor();
}
applyProcessor(videoTrack, processor);
}, [processor, videoTrack]);
};
+80 -14
View File
@@ -18,6 +18,7 @@ import {
NoMatrix2AuthorizationService,
} from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix";
import { Config } from "../config/Config";
import { JwtEndpointVersion } from "../state/CallViewModel/localMember/LocalTransport";
/**
@@ -84,6 +85,8 @@ export type OpenIDClientParts = Pick<
* This function by default uses whatever is possible with the current jwt service installed next to the SFU.
* For remote connections this does not matter, since we will not publish there we can rely on the newest option.
* For our own connection we can only use the hashed version if we also send the new matrix2.0 sticky events.
* @param opts.delayEndpointBaseUrl The URL of the matrix homeserver.
* @param opts.delayId The delay id used for the jwt service to manage.
* @param logger optional logger.
* @returns Object containing the token information
* @throws FailToGetOpenIdToken
@@ -95,6 +98,8 @@ export async function getSFUConfigWithOpenID(
roomId: string,
opts?: {
forceJwtEndpoint?: JwtEndpointVersion;
delayEndpointBaseUrl?: string;
delayId?: string;
},
logger?: Logger,
): Promise<SFUConfig> {
@@ -116,18 +121,20 @@ export async function getSFUConfigWithOpenID(
const forceMatrix2Jwt =
opts?.forceJwtEndpoint === JwtEndpointVersion.Matrix_2_0;
// We want to start using the new endpoint if we can use both or if we are
// forced to use the new one.
// We want to start using the new endpoint (with optional delay delegation)
// if we can use both or if we are forced to use the new one.
if (tryBothJwtEndpoints || forceMatrix2Jwt) {
try {
logger?.info(
`Trying to get JWT with delegation for focus ${serviceUrl}...`,
);
const sfuConfig = await getLiveKitJWT(
const sfuConfig = await getLiveKitJWTWithDelayDelegation(
membership,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
return extractFullConfigFromToken(sfuConfig);
@@ -147,11 +154,13 @@ export async function getSFUConfigWithOpenID(
logger?.info(
`Trying to get JWT with legacy endpoint for focus ${serviceUrl}...`,
);
sfuConfig = await getLiveKitJWTLegacy(
sfuConfig = await getLiveKitJWT(
membership.deviceId,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
logger?.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
@@ -179,14 +188,33 @@ function extractFullConfigFromToken(sfuConfig: {
};
}
async function getLiveKitJWTLegacy(
async function getLiveKitJWT(
deviceId: string,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
const res = await doNetworkOperationWithRetry(async () =>
fetch(livekitServiceURL + "/sfu/get", {
interface IDelayParams {
delay_id?: string;
delay_timeout?: number;
delay_cs_api_url?: string;
}
let bodyDalayParts: IDelayParams = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
const delayTimeoutMs =
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
bodyDalayParts = {
delay_id: delayId,
delay_timeout: delayTimeoutMs,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
return await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -197,9 +225,31 @@ async function getLiveKitJWTLegacy(
room: matrixRoomId,
openid_token: openIDToken,
device_id: deviceId,
...delayParts,
}),
}),
);
});
};
const res = await doNetworkOperationWithRetry(async () => {
let response = await makeRequest(bodyDalayParts);
// Old service compatibility check
const oldServiceDoesNotSupportDelayParts =
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
if (oldServiceDoesNotSupportDelayParts) {
try {
const errorBody = await response.json();
if (errorBody.errcode === "M_BAD_JSON") {
response = await makeRequest({});
}
} catch {
// If we can't parse the error, treat as real error
}
}
return response;
});
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
@@ -214,15 +264,17 @@ class NotSupportedError extends Error {
}
}
export async function getLiveKitJWT(
export async function getLiveKitJWTWithDelayDelegation(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
const { userId, deviceId, memberId } = membership;
const body = JSON.stringify({
const body = {
room_id: matrixRoomId,
slot_id: "m.call#ROOM",
openid_token: openIDToken,
@@ -231,13 +283,27 @@ export async function getLiveKitJWT(
claimed_user_id: userId,
claimed_device_id: deviceId,
},
});
};
let bodyDalayParts = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
const delayTimeoutMs =
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
bodyDalayParts = {
delay_id: delayId,
delay_timeout: delayTimeoutMs,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const res = await doNetworkOperationWithRetry(async () => {
return await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, ...bodyDalayParts }),
});
});
+58 -20
View File
@@ -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<typeof useRoomEncryptionSystem>;
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(
<BrowserRouter>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<TooltipProvider>
<RoomContext value={mockLivekitRoom({ localParticipant })}>
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
matrixRoom={matrixRoom}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
onShareClick={null}
e2eeSystem={{ kind: E2eeType.NONE }}
onLeft={(): void => {}}
/>
</RoomContext>
</TooltipProvider>
</ProcessorProvider>
</MediaDevicesContext>
</BrowserRouter>,
);
// Rendering at all proves ActiveCall created all of its view models
expect(await findByTestId("incall_leave")).toBeVisible();
});
});
+17 -2
View File
@@ -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<ActiveCallProps> = (props) => {
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
null,
);
const [developerSettingsVm, setDeveloperSettingsVm] =
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
const urlParams = useUrlParams();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
@@ -169,6 +174,7 @@ export const ActiveCall: FC<ActiveCallProps> = (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<ActiveCallProps> = (props) => {
if (vm === null) return null;
if (footerVm === null) return null;
if (developerSettingsVm === null) return null;
return (
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView {...props} vm={vm} footerVm={footerVm} />
<InCallView
{...props}
vm={vm}
footerVm={footerVm}
developerSettingsVm={developerSettingsVm}
/>
</ReactionsSenderProvider>
);
};
@@ -200,6 +212,7 @@ export interface InCallViewProps {
client: MatrixClient;
vm: CallViewModel;
footerVm: ViewModel<FooterSnapshot>;
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
@@ -211,6 +224,7 @@ export const InCallView: FC<InCallViewProps> = ({
client,
vm,
footerVm,
developerSettingsVm,
matrixInfo,
matrixRoom,
muteStates,
@@ -633,6 +647,7 @@ export const InCallView: FC<InCallViewProps> = ({
onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab}
onTabChange={setSettingsTab}
developerSettingsVm={developerSettingsVm}
livekitRooms={allConnections
.getConnections()
.map((connectionItem) => ({
+86 -1
View File
@@ -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", () => {
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -159,6 +166,7 @@ describe("DeveloperSettingsTab", () => {
client={client}
roomId="#testRoom"
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -181,6 +189,7 @@ describe("DeveloperSettingsTab", () => {
client={client}
roomId="#testRoom"
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -206,6 +215,7 @@ describe("DeveloperSettingsTab", () => {
client={client}
roomId="#testRoom"
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -236,6 +246,7 @@ describe("DeveloperSettingsTab", () => {
client={client}
roomId="#testRoom"
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -273,6 +284,7 @@ describe("DeveloperSettingsTab", () => {
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -305,6 +317,7 @@ describe("DeveloperSettingsTab", () => {
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -345,6 +358,7 @@ describe("DeveloperSettingsTab", () => {
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -378,6 +392,7 @@ describe("DeveloperSettingsTab", () => {
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -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<DeveloperSettingsSnapshot>({
keyRotation: { suppressed: false, participantCount: 5 },
});
render(
<TooltipProvider>
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={vm}
/>
</TooltipProvider>,
);
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<DeveloperSettingsSnapshot>({
keyRotation: { suppressed: true, participantCount: 50 },
});
render(
<TooltipProvider>
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={vm}
/>
</TooltipProvider>,
);
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(
<TooltipProvider>
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
await waitFor(() =>
expect(
screen.queryByText(/Media key rotation:/),
).not.toBeInTheDocument(),
);
});
});
});
+37
View File
@@ -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 }) => (
<p>
Media key rotation:{" "}
{info.suppressed
? `suppressed, participant limit reached (${info.participantCount} participants)`
: `active (${info.participantCount} participants)`}
</p>
);
interface Props {
client: MatrixClient;
@@ -79,6 +112,7 @@ interface Props {
livekitAlias?: string;
}[];
env: ImportMetaEnv;
vm: ViewModel<DeveloperSettingsSnapshot>;
}
export const DeveloperSettingsTab: FC<Props> = ({
@@ -86,8 +120,10 @@ export const DeveloperSettingsTab: FC<Props> = ({
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<Props> = ({
id: client.getDeviceId() || "unknown",
})}
</p>
{keyRotation !== null && <KeyRotationStatus info={keyRotation} />}
<Separator />
<FieldRow>
<InputField
@@ -0,0 +1,48 @@
/*
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 { describe, expect, it } from "vitest";
import { BehaviorSubject } from "rxjs";
import { testScope } from "../utils/test";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import {
createDeveloperSettingsTabViewModel,
outOfCallDeveloperSettingsTabViewModel,
} from "./DeveloperSettingsTabViewModel";
describe("createDeveloperSettingsTabViewModel", () => {
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,
);
});
});
@@ -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<DeveloperSettingsSnapshot> {
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<DeveloperSettingsSnapshot> =
createStaticViewModel<DeveloperSettingsSnapshot>({ keyRotation: null });
+10 -1
View File
@@ -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<DeveloperSettingsSnapshot>;
}
export const defaultSettingsTab: SettingsTab = "audio";
@@ -68,6 +75,7 @@ export const SettingsModal: FC<Props> = ({
client,
roomId,
livekitRooms,
developerSettingsVm,
}) => {
const { t } = useTranslation();
@@ -220,6 +228,7 @@ export const SettingsModal: FC<Props> = ({
client={client}
livekitRooms={livekitRooms}
roomId={roomId}
vm={developerSettingsVm ?? outOfCallDeveloperSettingsTabViewModel}
/>
),
};
+52
View File
@@ -0,0 +1,52 @@
/*
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 { afterEach, expect, it, vi } from "vitest";
import { init as initRageshake } from "./rageshake";
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("flushes logs to IndexedDB periodically without an explicit flush", async () => {
vi.useFakeTimers();
const add = vi.fn();
const txn = {
oncomplete: undefined as (() => void) | undefined,
onerror: undefined,
objectStore: (name: string) =>
name === "logs"
? {
add: (entry: unknown): void => {
add(entry);
queueMicrotask(() => txn.oncomplete?.());
},
}
: { put: vi.fn() },
};
const open = (): unknown => {
const req = {
result: { transaction: () => txn },
onsuccess: undefined as (() => void) | undefined,
};
queueMicrotask(() => req.onsuccess?.());
return req;
};
vi.stubGlobal("indexedDB", { open });
await initRageshake();
global.mx_rage_logger.log(1, "test", "hello from the buffer");
expect(add).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2000);
expect(add).toHaveBeenCalledOnce();
expect(add.mock.calls[0][0]).toMatchObject({
lines: expect.stringContaining("hello from the buffer"),
});
});
+7 -4
View File
@@ -204,10 +204,13 @@ class IndexedDBLogStore {
// Throttled function to flush logs. We use throttle rather
// than debounce as we want logs to be written regularly, otherwise
// if there's a constant stream of logging, we'd never write anything.
private throttledFlush = throttle(() => this.flush, MAX_FLUSH_INTERVAL_MS, {
leading: false,
trailing: true,
});
private throttledFlush = throttle(
() => {
this.flush().catch((e) => logger.error("Failed to flush logs", e));
},
MAX_FLUSH_INTERVAL_MS,
{ leading: false, trailing: true },
);
/**
* Flush logs to disk.
+24 -11
View File
@@ -49,6 +49,7 @@ import {
import { type IWidgetApiRequest } from "matrix-widget-api";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { v4 as uuidv4 } from "uuid";
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
import {
createToggle$,
@@ -113,6 +114,7 @@ import {
type LocalTransport,
} from "./localMember/LocalTransport.ts";
import {
createKeyRotationSuppressed$,
createMemberships$,
membershipsAndTransports$,
} from "../SessionBehaviors.ts";
@@ -301,6 +303,12 @@ export interface CallViewModel {
* multiple devices.
*/
participantCount$: Behavior<number>;
/**
* 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<boolean>;
allConnections$: Behavior<ConnectionManagerData>;
/** Participants sorted by livekit room so they can be used in the audio rendering */
livekitRoomItems$: Behavior<LivekitRoomItem[]>;
@@ -487,6 +495,16 @@ export function createCallViewModel$(
memberships$: memberships$,
ownMembershipIdentity,
client,
delayId$: scope.behavior(
(
fromEvent(
matrixRTCSession,
MembershipManagerEvent.DelayIdChanged,
// The type of reemitted event includes the original emitted as the second arg.
) as Observable<[string | undefined, IMembershipManager]>
).pipe(map(([delayId]) => delayId ?? null)),
matrixRTCSession.delayId ?? null,
),
roomId: matrixRoom.roomId,
forceJwtEndpoint:
mode === MatrixRTCMode.Matrix_2_0
@@ -577,20 +595,9 @@ export function createCallViewModel$(
);
},
connectionManager,
client,
matrixRTCSession,
localTransport$,
roomId: matrixRoom.roomId,
ownMembershipIdentity,
delayId$: scope.behavior(
(
fromEvent(
matrixRTCSession,
MembershipManagerEvent.DelayIdChanged,
) as Observable<[string | undefined]>
).pipe(map(([delayId]) => delayId ?? null)),
matrixRTCSession.delayId ?? null,
),
logger: logger.getChild(`[${Date.now()}]`),
});
@@ -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$,
@@ -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 = {
@@ -15,11 +15,6 @@ import {
MediaDeviceFailure,
} from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core";
import {
type IOpenIDToken,
parseErrorResponse,
type MatrixClient,
} from "matrix-js-sdk";
import {
Status as RTCSessionStatus,
type LivekitTransport,
@@ -77,7 +72,6 @@ import {
import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { type LocalTransport } from "./LocalTransport.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
import { doNetworkOperationWithRetry } from "../../../utils/matrix.ts";
export enum TransportState {
/** Not even a transport is available to the LocalMembership */
@@ -142,14 +136,11 @@ interface Props {
joinMatrixRTC: (transport: LivekitTransportConfig) => void;
homeserverConnected: HomeserverConnected;
roomId: string;
ownMembershipIdentity: CallMembershipIdentityParts;
localTransport$: Behavior<LocalTransport>;
client: Pick<MatrixClient, "getOpenIdToken" | "baseUrl">;
matrixRTCSession: Pick<
MatrixRTCSession,
"slotId" | "updateCallIntent" | "leaveRoomSession"
"updateCallIntent" | "leaveRoomSession"
>;
delayId$: Behavior<string | null>;
logger: Logger;
}
@@ -185,11 +176,8 @@ export const createLocalMembership$ = ({
joinMatrixRTC,
logger: parentLogger,
muteStates,
client,
matrixRTCSession,
roomId,
ownMembershipIdentity,
delayId$,
}: Props): {
/**
* This request to start audio and video tracks.
@@ -651,60 +639,6 @@ export const createLocalMembership$ = ({
},
);
scope.reconcile(
scope.behavior(combineLatest([delayId$, activeTransport$])),
async ([delayId, transport]) => {
if (delayId !== null && transport !== null) {
logger.info(
`Delegating delayed leave ${delayId} to ${transport.livekit_service_url}…`,
);
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (e) {
logger.error("Failed to get OpenID token for delegation", e);
return;
}
const body = JSON.stringify({
room_id: roomId,
slot_id: matrixRTCSession.slotId,
member: {
id: ownMembershipIdentity.memberId,
claimed_user_id: ownMembershipIdentity.userId,
claimed_device_id: ownMembershipIdentity.deviceId,
},
delay_id: delayId,
delay_timeout:
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms,
delay_cs_api_url: client.baseUrl,
openid_token: openIdToken,
});
try {
const res = await doNetworkOperationWithRetry(async () =>
fetch(transport.livekit_service_url + "/delegate_delayed_leave", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
}),
);
if (res.ok) logger.info(`Delayed leave delegation successful`);
else if (res.status === 404)
logger.warn(
`Delayed event delegation not available on ${transport.livekit_service_url}`,
);
else throw parseErrorResponse(res, await res.text());
} catch (e) {
logger.error(
`Failed to delegate ${delayId} to ${transport.livekit_service_url}`,
e,
);
}
}
},
);
const participant$ = scope.behavior(
localConnection$.pipe(
map((c) => c?.livekitRoom?.localParticipant ?? null),
@@ -968,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,
},
@@ -14,8 +14,11 @@ import {
type MockedObject,
vi,
} from "vitest";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { lastValueFrom } from "rxjs";
import {
type CallMembership,
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, filter, lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock";
import {
@@ -24,7 +27,11 @@ import {
ownMemberMock,
testScope,
} from "../../../utils/test";
import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
import {
createLocalTransport$,
JwtEndpointVersion,
type LocalTransportWithSFUConfig,
} from "./LocalTransport";
import { constant } from "../../Behavior";
import { Epoch, ObservableScope } from "../../ObservableScope";
import {
@@ -61,6 +68,7 @@ describe("LocalTransport", () => {
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"),
});
await flushPromises();
@@ -101,6 +109,7 @@ describe("LocalTransport", () => {
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"),
});
active$.subscribe(
(o) => observations.push(o),
@@ -142,6 +151,7 @@ describe("LocalTransport", () => {
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"),
});
openIdResolver.resolve?.({
@@ -185,6 +195,7 @@ describe("LocalTransport", () => {
scope: testScope(),
roomId: "!example_room_id",
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
baseUrl: "https://example.org",
@@ -296,6 +307,7 @@ describe("LocalTransport", () => {
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
@@ -317,4 +329,84 @@ describe("LocalTransport", () => {
);
});
});
it("should not update advertised/active transport on delayID changes, but delay Id delegation should be called", async () => {
// For simplicity, we'll just use the config livekit
customLivekitUrl.setValue("https://lk.example.org");
const authCallSpy = vi
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
.mockResolvedValue(openIdResponse);
const delayId$ = new BehaviorSubject<string | null>(null);
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
// We want multi-sdu
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: delayId$,
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
baseUrl: "https://example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
const advertisedValues: LivekitTransportConfig[] = [];
const activeValues: LocalTransportWithSFUConfig[] = [];
advertised$
.pipe(filter((v) => v !== null))
.subscribe((t) => advertisedValues.push(t));
active$
.pipe(filter((v) => v !== null))
.subscribe((t) => activeValues.push(t));
await flushPromises();
// we have now an active and an advertised
expect(advertisedValues.length).toEqual(1);
expect(activeValues.length).toEqual(1);
expect(advertisedValues[0]!.livekit_service_url).toEqual(
"https://lk.example.org",
);
expect(activeValues[0]!.transport.livekit_service_url).toEqual(
"https://lk.example.org",
);
expect(authCallSpy).toHaveBeenCalledTimes(2);
// Now emits 3 new delays id
delayId$.next("delay_id_1");
await flushPromises();
delayId$.next("delay_id_2");
await flushPromises();
delayId$.next("delay_id_3");
await flushPromises();
// No new emissions should've happened, it is the same transport.
expect(advertisedValues.length).toEqual(1);
expect(activeValues.length).toEqual(1);
// Still we should have updated the delayID to auth
expect(authCallSpy).toHaveBeenCalledTimes(
4 * 2 /* 2 calls for each delayId ?? why */,
);
expect(authCallSpy).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
delayId: "delay_id_3",
}),
expect.anything(),
);
});
});
@@ -10,7 +10,14 @@ import {
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import { distinctUntilChanged, from, map, of, switchMap } from "rxjs";
import {
combineLatest,
distinctUntilChanged,
from,
map,
of,
switchMap,
} from "rxjs";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -47,6 +54,7 @@ interface Props {
// Used by the jwt service to create the livekit room and compute the livekit alias.
roomId: string;
forceJwtEndpoint: JwtEndpointVersion;
delayId$: Behavior<string | null>;
}
export enum JwtEndpointVersion {
@@ -115,6 +123,7 @@ export const createLocalTransport$ = ({
client,
roomId,
forceJwtEndpoint,
delayId$,
}: Props): LocalTransport => {
const logger = rootLogger.getChild("[LocalTransport]");
@@ -153,8 +162,8 @@ export const createLocalTransport$ = ({
distinctUntilChanged(areLivekitTransportsEqual),
);
const preferredTransport$ = preferredConfig$.pipe(
switchMap(async (transport) => {
const preferredTransport$ = combineLatest([preferredConfig$, delayId$]).pipe(
switchMap(async ([transport, delayId]) => {
try {
return await doOpenIdAndJWTFromUrl(
transport,
@@ -162,6 +171,7 @@ export const createLocalTransport$ = ({
ownMembershipIdentity,
roomId,
client,
delayId ?? undefined,
logger,
);
} catch (e) {
@@ -213,6 +223,7 @@ export const createLocalTransport$ = ({
* @param membership The identity of the local member.
* @param roomId The room ID to use for the JWT.
* @param client The client to use for the OpenID token.
* @param delayId The delayId to use for the JWT.
*
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
*/
@@ -221,7 +232,12 @@ async function doOpenIdAndJWTFromUrl(
forceJwtEndpoint: JwtEndpointVersion,
membership: CallMembershipIdentityParts,
roomId: string,
client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts,
client: Pick<
MatrixClient,
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
> &
OpenIDClientParts,
delayId?: string,
logger?: Logger,
): Promise<LocalTransportWithSFUConfig> {
const sfuConfig = await getSFUConfigWithOpenID(
@@ -231,6 +247,8 @@ async function doOpenIdAndJWTFromUrl(
roomId,
{
forceJwtEndpoint: forceJwtEndpoint,
delayEndpointBaseUrl: client.baseUrl,
delayId,
},
logger,
);
+41
View File
@@ -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);
});
});
});
+19
View File
@@ -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<boolean> => {
return scope.behavior(
fromEvent(
matrixRTCSession,
MatrixRTCSessionEvent.KeyRotationSuppressedChanged,
(suppressed: boolean) => suppressed,
),
matrixRTCSession.isKeyRotationSuppressed,
);
};
+4
View File
@@ -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<FooterSnapshot>;
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
rtcMemberships$: BehaviorSubject<CallMembership[]>;
rtcSession: MockRTCSession;
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
@@ -188,6 +191,7 @@ export function getBasicCallViewModelEnvironment(
return {
vm,
footerVm,
developerSettingsVm: createDeveloperSettingsTabViewModel(testScope(), vm),
rtcMemberships$,
rtcSession,
handRaisedSubject$: handRaisedSubject$,
+2
View File
@@ -487,6 +487,8 @@ export class MockRTCSession extends TypedEventEmitter<
return this.joined;
}
public isKeyRotationSuppressed = false;
public withMemberships(
rtcMembers$: Behavior<Partial<CallMembership>[]>,
): MockRTCSession {