Compare commits

..
Author SHA1 Message Date
Timo K. 42497733ff make errors more verbose 2026-08-30 11:39:18 +02: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
26 changed files with 842 additions and 169 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:133160c3c1e506276145cbb8ccaf48253f0a96af635129d785826265e4f3ca10
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:133160c3c1e506276145cbb8ccaf48253f0a96af635129d785826265e4f3ca10
+20 -20
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,7 +192,7 @@ importers:
version: 1.9.2
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#develop
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
matrix-widget-api:
specifier: ^1.18.0
version: 1.18.0
@@ -1191,8 +1191,8 @@ packages:
'@types/dom-mediacapture-transform': ^0.1.9
livekit-client: ^1.12.0 || ^2.1.0
'@matrix-org/matrix-sdk-crypto-wasm@18.4.0':
resolution: {integrity: sha512-osxkU1DQ+05+anGHapjWyvZqdHUb94Id37gy54mCKn1Cq/D7iGT5oEUEhjp4oTnCLo4TOtI6ULJ/LHsapaIptQ==}
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0':
resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==}
engines: {node: '>= 18'}
'@mdx-js/react@3.1.1':
@@ -3670,9 +3670,9 @@ packages:
constants-browserify@1.0.0:
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
content-type@2.0.0:
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
engines: {node: '>=18'}
content-type@3.0.0:
resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==}
engines: {node: '>=22'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -4685,9 +4685,9 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0:
resolution: {gitHosted: true, integrity: sha512-UmlkoXs9VMZvNaDeQqBb2k+gLifnIqEZc5cxrLeEwdYEDakQLbRqrFtmvIp4ipUCjdIPkCgtzb7QLlpPBLAwqA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0}
version: 42.1.0
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
resolution: {gitHosted: true, integrity: sha512-0EGwbXzvG88sBOxq4ZlCRwT4VpQj2xkizfyy0A3bTSK0ysb6/gXJ/o1WPFoqCjRGMDgPq4gAsk92xGtHA7EhPg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a}
version: 42.2.0
engines: {node: '>=22.0.0'}
matrix-widget-api@1.18.0:
@@ -7102,7 +7102,7 @@ snapshots:
'@types/dom-mediacapture-transform': 0.1.11
livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22)
'@matrix-org/matrix-sdk-crypto-wasm@18.4.0': {}
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
'@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8)':
dependencies:
@@ -8053,13 +8053,13 @@ snapshots:
'@rolldown/binding-win32-x64-msvc@1.1.5':
optional: true
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))':
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
picomatch: 4.0.4
rolldown: 1.1.5
optionalDependencies:
'@babel/runtime': 7.29.7
'@babel/runtime': 8.0.0
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)
'@rolldown/pluginutils@1.0.1': {}
@@ -8744,12 +8744,12 @@ snapshots:
- '@types/react-dom'
- react-dom
'@vitejs/plugin-react@6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))':
'@vitejs/plugin-react@6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)
optionalDependencies:
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))
babel-plugin-react-compiler: 1.0.0
'@vitest/browser-playwright@4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10)':
@@ -9195,7 +9195,7 @@ snapshots:
constants-browserify@1.0.0: {}
content-type@2.0.0: {}
content-type@3.0.0: {}
convert-source-map@2.0.0: {}
@@ -10275,13 +10275,13 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
dependencies:
'@babel/runtime': 8.0.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.4.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.5.0
another-json: 0.2.0
bs58: 6.0.0
content-type: 2.0.0
content-type: 3.0.0
loglevel: 1.9.2
matrix-events-sdk: 0.0.1
matrix-widget-api: 1.18.0
+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;
};
}
+16 -4
View File
@@ -79,8 +79,14 @@ describe("getSFUConfigWithOpenID", () => {
);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
// The cause is a wrapper naming the operation we were performing; the
// underlying MatrixError sits behind it.
const context = (ex as FailToGetOpenIdToken).cause as Error;
expect(context.message).toEqual(
"Failed to get a JWT from the legacy endpoint of the MatrixRTC backend at https://sfu.example.org",
);
expect(context.cause).toBeInstanceOf(MatrixError);
const mxError = context.cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
@@ -216,8 +222,14 @@ describe("getSFUConfigWithOpenID", () => {
);
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
// The cause is a wrapper naming the operation we were performing; the
// underlying MatrixError sits behind it.
const context = (ex as FailToGetOpenIdToken).cause as Error;
expect(context.message).toEqual(
"Failed to get a JWT from the legacy endpoint of the MatrixRTC backend at https://sfu.example.org",
);
expect(context.cause).toBeInstanceOf(MatrixError);
const mxError = context.cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
+97 -17
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> {
@@ -104,8 +109,14 @@ export async function getSFUConfigWithOpenID(
client.getOpenIdToken(),
);
} catch (error) {
// Note that in widget mode this is the `get_openid` widget action rather
// than a homeserver request, so the hosting client is a likely culprit.
throw new FailToGetOpenIdToken(
error instanceof Error ? error : new Error("Unknown error"),
new Error(
`Failed to get an OpenID token, needed to authenticate with the MatrixRTC backend at ${serviceUrl}` +
`${opts?.delayId ? " (re-authenticating to delegate delayed leave event " + opts.delayId + ")" : ""}`,
{ cause: error },
),
);
}
logger?.debug("Got openID token", openIdToken);
@@ -116,18 +127,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);
@@ -135,7 +148,12 @@ export async function getSFUConfigWithOpenID(
logger?.debug(`Failed fetching jwt with matrix 2.0 endpoint:`, e);
// Make this throw a hard error in case we force the matrix2.0 endpoint.
if (forceMatrix2Jwt) {
throw new NoMatrix2AuthorizationService(e as Error);
throw new NoMatrix2AuthorizationService(
new Error(
`Failed to get a JWT from the Matrix 2.0 endpoint of the MatrixRTC backend at ${serviceUrl}`,
{ cause: e },
),
);
}
}
}
@@ -147,17 +165,22 @@ 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);
} catch (ex) {
throw new FailToGetOpenIdToken(
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
new Error(
`Failed to get a JWT from the legacy endpoint of the MatrixRTC backend at ${serviceUrl}`,
{ cause: ex },
),
);
}
}
@@ -179,14 +202,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 +239,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 +278,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 +297,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 }),
});
});
+17 -8
View File
@@ -23,10 +23,10 @@ import {
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import { MatrixError } from "matrix-js-sdk";
import {
ConnectionLostError,
describeErrorChain,
ElementCallError,
ErrorCategory,
ErrorCode,
@@ -80,8 +80,13 @@ const ErrorPage: FC<ErrorPageProps> = ({
});
}
const technicalError =
error.cause instanceof MatrixError ? error.cause : null;
// Show the whole cause chain rather than just a `MatrixError` cause: the
// request that actually failed is often several wrappers deep, and errors
// that are not `MatrixError`s (widget API timeouts, LiveKit connection
// errors, ...) used to leave this section empty entirely. A chain of length
// one is just the error we already render above, so keep it hidden.
const errorChain = describeErrorChain(error);
const technicalDetails = errorChain.length > 1 ? errorChain.join("\n") : null;
return (
<FullScreenView>
@@ -119,14 +124,12 @@ const ErrorPage: FC<ErrorPageProps> = ({
/>
)}
</p>
{technicalError ? (
{technicalDetails ? (
<details className={styles.technicalDetails}>
<summary className={styles.technicalDetailsSummary}>
{t("technical_details")}
</summary>
<pre className={styles.technicalDetailsPre}>
{technicalError.message}
</pre>
<pre className={styles.technicalDetailsPre}>{technicalDetails}</pre>
</details>
) : null}
{actions &&
@@ -162,7 +165,13 @@ export const GroupCallErrorBoundary = ({
const callError =
error instanceof ElementCallError
? error
: new UnknownCallError(error instanceof Error ? error : new Error());
: new UnknownCallError(
error instanceof Error
? error
: new Error(
`Non-error value thrown during the call: ${String(error)}`,
),
);
return (
<ErrorPage
widget={widget ?? null}
+7 -1
View File
@@ -179,7 +179,13 @@ export const GroupCallView: FC<Props> = ({
) {
setExternalError(new StickyEventsRequiredError());
} else {
setExternalError(new ConnectionLostError());
logger.error("MembershipManager gave up on the session", error);
// Keep the original error as the cause: the scheduler only tells us
// that it shut down, while the request that actually failed (and its
// errcode) lives further down the cause chain.
setExternalError(
new ConnectionLostError(error instanceof Error ? error : undefined),
);
}
},
);
+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}
/>
),
};
+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) {
@@ -169,7 +179,11 @@ export const createLocalTransport$ = ({
`Failed to authenticate to transport ${transport.livekit_service_url}`,
e,
);
throw mapAuthErrorToUserFriendlyError(e);
throw mapAuthErrorToUserFriendlyError(
e,
transport.livekit_service_url,
delayId,
);
}
}),
);
@@ -213,6 +227,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 +236,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 +251,8 @@ async function doOpenIdAndJWTFromUrl(
roomId,
{
forceJwtEndpoint: forceJwtEndpoint,
delayEndpointBaseUrl: client.baseUrl,
delayId,
},
logger,
);
@@ -240,16 +262,26 @@ async function doOpenIdAndJWTFromUrl(
};
}
function mapAuthErrorToUserFriendlyError(e: unknown): Error {
function mapAuthErrorToUserFriendlyError(
e: unknown,
serviceUrl: string,
delayId: string | null,
): Error {
if (
e instanceof FailToGetOpenIdToken ||
e instanceof NoMatrix2AuthorizationService
) {
// rethrow as is
// Already carries its own context, rethrow as is.
return e;
}
// Catch others and rethrow as FailToGetOpenIdToken that has user friendly message.
// Catch others and rethrow as FailToGetOpenIdToken that has user friendly
// message. Record what we were doing, since this branch is reached by
// anything unexpected and otherwise leaves no trace of it.
return new FailToGetOpenIdToken(
e instanceof Error ? e : new Error(String(e)),
new Error(
`Unexpected error while authenticating with the MatrixRTC backend at ${serviceUrl}` +
`${delayId ? ` (re-authenticating to delegate delayed leave event ${delayId})` : ""}`,
{ cause: e },
),
);
}
@@ -85,7 +85,10 @@ export class RtcTransportAutoDiscovery {
);
}
} catch (ex) {
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
// Expected against homeservers without MSC4143, so this is not a
// warning, but pass the error itself so the stack and any errcode
// survive into the logs rather than being flattened into a string.
this.logger.info("Failed to use getRTCTransports end point", ex);
}
return null;
}
@@ -37,6 +37,7 @@ import {
import { ObservableScope } from "../../ObservableScope.ts";
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
import {
describeErrorChain,
ElementCallError,
FailToGetOpenIdToken,
} from "../../../utils/errors.ts";
@@ -284,7 +285,9 @@ describe("Start connection states", () => {
capturedState instanceof ElementCallError &&
capturedState.cause instanceof Error
) {
expect(capturedState.cause.message).toContain(
// The homeserver error is wrapped in a context error naming the
// operation that failed, so assert against the whole cause chain.
expect(describeErrorChain(capturedState).join("\n")).toContain(
"Failed to look up user info from homeserver",
);
expect(connection.transport.livekit_alias).toEqual(
+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,
);
};
+83 -1
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import { t } from "i18next";
import { type ConnectionError } from "livekit-client";
import { HTTPError, MatrixError } from "matrix-js-sdk";
import { i18nKey } from "./i18n";
@@ -95,12 +96,20 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
* Error indicating that the connection to the call was lost and could not be re-established.
*/
export class ConnectionLostError extends ElementCallError {
public constructor() {
/**
* Creates an instance of ConnectionLostError.
*
* @param cause - The underlying error that made us give up on the
* connection, if there is one. It is not shown to the user directly, but it
* ends up in the technical details of the error screen and in Sentry.
*/
public constructor(cause?: Error) {
super(
t("error.connection_lost"),
ErrorCode.CONNECTION_LOST_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
t("error.connection_lost_description"),
cause,
);
}
}
@@ -293,3 +302,76 @@ export class LivekitConnectionError extends ElementCallError {
this.localisedMessageValues = { reason: cause.reasonName };
}
}
/** Maximum number of `cause` levels walked by {@link describeErrorChain}. */
const MAX_CAUSE_DEPTH = 8;
/** Prefix that `@sentry/react` gives the synthetic error it appends as a cause. */
const SENTRY_WRAPPER_NAME = "React ErrorBoundary ";
/**
* Describes a single error, including the protocol level details that Matrix
* and HTTP errors carry but leave out of their `message`.
*/
function describeError(error: unknown): string {
if (error instanceof MatrixError) {
// An `M_UNKNOWN` errcode usually means the failure did not come from the
// homeserver at all: when we run as a widget, the hosting client's widget
// driver serialises any error it cannot map onto a Matrix error that way
// (see `MatrixError.asWidgetApiErrorData`). The url and status are then far
// more telling than the errcode, so always print them.
const parts = [
`errcode=${error.errcode ?? "<none>"}`,
`httpStatus=${error.httpStatus ?? "<none>"}`,
];
if (error.url) parts.push(`url=${error.url}`);
if (error.error) parts.push(`error=${error.error}`);
return `MatrixError(${parts.join(", ")}): ${error.message}`;
}
if (error instanceof HTTPError)
return `HTTPError(httpStatus=${error.httpStatus ?? "<none>"}): ${error.message}`;
if (error instanceof ElementCallError)
return `ElementCallError(code=${error.code}, category=${error.category}): ${error.localisedTitle}`;
if (error instanceof Error) return `${error.name}: ${error.message}`;
return `Non-error value: ${String(error)}`;
}
/**
* Renders an error together with its whole `cause` chain.
*
* By the time an error reaches the error screen it has usually been wrapped
* several times over (the MembershipManager scheduler, an
* {@link ElementCallError} subclass, a context wrapper at the call site, ...),
* so showing only the outermost error, or a single `cause` hop, tends to hide
* the request that actually failed.
*
* @param error - The error to describe.
* @returns One entry per error in the chain, outermost wrapper first. A chain
* of length one means we have nothing beyond the error itself to report.
*/
export function describeErrorChain(error: unknown): string[] {
const lines: string[] = [];
let current: unknown = error;
for (
let depth = 0;
current !== undefined && depth < MAX_CAUSE_DEPTH;
depth++
) {
// `@sentry/react` appends a synthetic error to the end of the chain to
// carry the React component stack. That is useful in Sentry, but here it
// only repeats the message of the error it is attached to.
if (
current instanceof Error &&
current.name.startsWith(SENTRY_WRAPPER_NAME)
)
break;
lines.push(
(depth === 0 ? "" : `${" ".repeat(depth)}caused by: `) +
describeError(current),
);
// Only `Error`s carry a `cause`, and a self-referencing one would loop.
if (!(current instanceof Error) || current.cause === current) break;
current = current.cause;
}
return lines;
}
+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 {