Compare commits

...
Author SHA1 Message Date
Timo K. d38116d2f0 acutally use fallback url 2026-09-18 15:17:53 +02:00
Timo K. eb8db733a6 fix wasm loading in component build 2026-09-18 14:59:05 +02:00
Timo K. 0f4370749a rename useMatrixRtc -> userRustRtcSdk 2026-09-18 09:27:26 +02:00
Timo K. 48db16a08a formatting 2026-09-17 21:03:41 +02:00
Timo K. 14b595345f renames:
callParticipation -> rtcParticipationManager
driverCapabilites -> MatrixClientFeatures
2026-09-17 21:02:52 +02:00
Timo K. 7132e6e6ec simplefy call participation transport driver impl 2026-09-17 20:49:32 +02:00
Timo K. e791133e74 remove disconnect banner from header (we have the in call reconnect
view)
2026-09-17 20:49:04 +02:00
Timo K. edfdc23aef inherit secrets so they reach the npm install for rust-rtc 2026-09-17 11:37:17 +02:00
Timo K. edf2f39eec use the prebuild rust-sdk package 2026-09-17 09:46:56 +02:00
Timo K. e4049e3d69 Move ElementCall Component to the rust based call view model 2026-09-16 16:19:25 +02:00
Timo K. 8506c098e9 Add rust based createCallViewModel 2026-09-16 13:30:05 +02:00
Timo K. 36ed60d95c plan oxidation
- add the drivers
 - add the uniffi build toolchain
 - create the plan document
2026-09-15 19:06:04 +02:00
122 changed files with 11704 additions and 1191 deletions
+15 -11
View File
@@ -14,17 +14,13 @@ on:
description: The build mode for vite. Must be either 'development' or 'production'
required: false
default: production
secrets:
SENTRY_ORG:
required: true
SENTRY_PROJECT:
required: true
SENTRY_URL:
required: true
SENTRY_AUTH_TOKEN:
required: true
CODECOV_TOKEN:
required: false
# No `secrets:` block: every caller passes `secrets: inherit`, so the
# repository's secrets reach this workflow without each one being declared
# here and threaded through by name at the call sites -- the same pattern
# build-and-publish-docker.yaml uses. A secret this workflow starts using
# then needs no change to the callers. The ones read below are
# MATRIX_RTC_NPM_TOKEN, SENTRY_ORG, SENTRY_PROJECT, SENTRY_URL,
# SENTRY_AUTH_TOKEN and CODECOV_TOKEN.
jobs:
build:
@@ -42,6 +38,14 @@ jobs:
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
+11 -18
View File
@@ -15,12 +15,7 @@ jobs:
package: full
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
secrets: inherit
deploy_develop:
# Deploy main branch to call.element.dev after build completes
if: github.ref == 'refs/heads/main'
@@ -65,12 +60,7 @@ jobs:
package: embedded
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
secrets: inherit
build_sdk_element_call:
# Use the embedded package vite build
@@ -79,12 +69,7 @@ jobs:
package: sdk
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
secrets: inherit
build_storybook:
name: Build Storybook
@@ -102,6 +87,14 @@ jobs:
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install dependencies
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Build Storybook
+8
View File
@@ -17,6 +17,14 @@ jobs:
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
@@ -66,11 +66,7 @@ jobs:
with:
vite_app_version: embedded-${{ needs.versioning.outputs.PREFIXED_VERSION }}
package: embedded
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
secrets: inherit
publish_tarball:
needs: [build_element_call, versioning]
+1 -5
View File
@@ -13,11 +13,7 @@ jobs:
with:
package: full
vite_app_version: ${{ github.event.release.tag_name }} # Using ${{ env.VERSION }} here doesn't work
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
secrets: inherit
publish_tarball:
needs: build_element_call
+27 -2
View File
@@ -19,6 +19,14 @@ jobs:
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
@@ -44,9 +52,17 @@ jobs:
flags: unittests
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
name: Run end-to-end tests (${{ matrix.call_implementation }})
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Both MatrixRTC implementations, until matrix-rtc becomes the
# default and matrix-js-sdk goes (oxidation plan §5.15). The
# matrix-rtc run does not block until it has passed once.
call_implementation: [matrix-js-sdk, matrix-rtc]
continue-on-error: ${{ matrix.call_implementation == 'matrix-rtc' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -57,6 +73,14 @@ jobs:
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: pnpm install --frozen-lockfile --ignore-pnpmfile
@@ -80,11 +104,12 @@ jobs:
- name: Run Playwright tests
env:
USE_DOCKER: 1
CALL_VIEW_MODEL_IMPLEMENTATION: ${{ matrix.call_implementation }}
run: pnpm exec playwright test
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
name: html-report
name: html-report-${{ matrix.call_implementation }}
path: playwright-report
if-no-files-found: error
retention-days: 4
@@ -25,6 +25,14 @@ jobs:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install Deps
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
+9
View File
@@ -0,0 +1,9 @@
# The MatrixRTC SDK (`@element-hq/matrix-rtc`, aliased in package.json to the
# name the fork's CI publishes it under) comes from the GitHub Packages npm
# registry, which needs a token with `read:packages` even for public
# packages. This is a stop-gap while the SDK is in flux; once it is published
# to npmjs.com this file and the alias go away (docs/matrix_rtc_sdk.md).
# Only the scope->registry mapping lives here; the token goes in your
# user-level npm config (~/.npmrc), never in this file. CI writes it there
# from a repository secret (see .github/workflows).
@billcarsonfr:registry=https://npm.pkg.github.com
+5
View File
@@ -10,6 +10,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import EN from "../locales/en/app.json";
import { Config } from "../src/config/Config";
import { initReactI18next } from "react-i18next";
import { i18n } from "../src/utils/i18n";
import "../src/index.css";
@@ -35,6 +36,10 @@ i18n
})
.catch((e) => logger.warn("Failed to init i18n for stories", e));
// Stories run without a config.json; the defaults stand in, as they do for
// a component host that passes none.
Config.initWith({});
const preview: Preview = {
parameters: {
layout: "centered",
+17 -1
View File
@@ -206,6 +206,19 @@ corepack enable
pnpm install
```
`pnpm install` currently needs a GitHub token: the MatrixRTC SDK
(`@element-hq/matrix-rtc`) is, as a stop-gap until it is published to npmjs.com,
an npm package on the GitHub Packages registry, which rejects anonymous
requests even for public packages. Create a classic personal access token with
the `read:packages` scope and add it to your user-level npm config once:
```sh
echo "//npm.pkg.github.com/:_authToken=<your token>" >> ~/.npmrc
```
See [The MatrixRTC SDK package](./docs/matrix_rtc_sdk.md) for the details, CI
setup and developing against a local crate.
To use it, create a local config by, e.g.,
`cp ./config/config.devenv.json ./public/config.json` and adapt it if necessary.
The `config.devenv.json` config should work with the backend development
@@ -220,6 +233,7 @@ pnpm dev
See also:
- [Developing with linked packages](./docs/linking.md)
- [The MatrixRTC SDK package](./docs/matrix_rtc_sdk.md)
#### Element Call as a component (experimental)
@@ -298,7 +312,9 @@ The package is not published yet. A host installs it as a git dependency on the
whose `prepare` script runs the build on install. That build needs pnpm (via
Corepack) on the host's machine, runs a full `pnpm install` of this repository
and is memory-hungry, since it inherits the `--max-old-space-size` setting of
(so the host needs the GitHub Packages token described in
[The MatrixRTC SDK package](./docs/matrix_rtc_sdk.md) as well) and is
memory-hungry, since it inherits the `--max-old-space-size` setting of
the app build; the host's pnpm also has to allow it to run at all
(`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm
project of its own for this reason, so pnpm commands run from inside that
+2 -2
View File
@@ -18,7 +18,7 @@ import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import {
ElementCall,
ElementCallClientBased,
type ElementCallHandle,
supportedLanguages,
} from "../index";
@@ -160,7 +160,7 @@ const Pane: FC<{
one of the things we cannot find out from the standalone app */}
<div className={styles.paneCall} data-testid="call-container">
{mounted && (
<ElementCall
<ElementCallClientBased
ref={handle}
client={session.client}
roomId={roomId}
+115 -24
View File
@@ -43,7 +43,7 @@ import {
useRef,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { type MatrixClient, type Room } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { I18nextProvider } from "react-i18next";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -56,7 +56,6 @@ import LanguageDetector from "i18next-browser-languagedetector";
import EN from "../locales/en/app.json";
import { CallView } from "../src/room/CallView";
import { ErrorPage } from "../src/FullScreenView";
import { ClientProvider } from "../src/ClientContext";
import { HostBridgeProvider } from "../src/HostBridge";
import { RootElementProvider, useRootElement } from "../src/RootElementContext";
import {
@@ -78,6 +77,17 @@ import { type ConfigOptions } from "../src/config/ConfigOptions";
import { i18n } from "../src/utils/i18n";
import { useTheme } from "../src/useTheme";
import { useStableValue } from "../src/useStableValue";
import { type RtcMatrixDriver } from "../src/driver/RtcMatrixDriver";
import { type ElementCallMatrixClientDriver } from "../src/driver/ElementCallMatrixClientDriver";
import { useJsSdkDrivers } from "../src/driver/jsSdk/useJsSdkDrivers";
import {
initMatrixRtcSdk,
type MatrixRtcWasmSource,
} from "../src/matrix-rtc-sdk";
import {
type MatrixDrivers,
MatrixDriverProvider,
} from "../src/driver/MatrixDriverContext";
import styles from "./ElementCall.module.css";
import {
type ElementCallHandle,
@@ -89,6 +99,20 @@ import { supportedLanguages, translationsBackend } from "./localization";
// The languages Element Call can be shown in
export { supportedLanguages } from "./localization";
// What a host implements to drive Element Call with its own Matrix stack, and
// the implementations of both over a matrix-js-sdk client
export { type RtcMatrixDriver } from "../src/driver/RtcMatrixDriver";
export {
type ElementCallMatrixClientDriver,
type MatrixClientFeatures,
type RoomInfo,
type RoomMemberProfile,
type TimelineEvent,
type OwnProfile,
} from "../src/driver/ElementCallMatrixClientDriver";
export { JsSdkRtcMatrixDriver } from "../src/driver/jsSdk/JsSdkRtcMatrixDriver";
export { JsSdkElementCallMatrixClientDriver } from "../src/driver/jsSdk/JsSdkElementCallMatrixClientDriver";
// How the host and Element Call talk to each other, and what they say
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
export {
@@ -124,12 +148,25 @@ export type ElementCallConfiguration = Partial<UrlConfiguration> &
export interface ElementCallProps {
/**
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
* The MatrixRTC side of the host's Matrix stack, bound to the room to call
* in: what the crate needs to publish a membership, exchange keys and mint
* transport tokens. Element Call does not authenticate anyone or manage a
* session of its own; both drivers are the host's.
*/
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
rtcDriver: RtcMatrixDriver;
/**
* Everything else Element Call asks of a Matrix client for that room: who
* is in it, its name and avatar, the timeline for reactions and
* notifications, the user's own profile.
*/
clientDriver: ElementCallMatrixClientDriver;
/**
* The room to call in. Optional, and when given it must be the room the
* drivers are bound to (`clientDriver.roomId`). Kept for one release so
* that hosts written for the client-based component can move over one
* prop at a time; then it goes.
*/
roomId?: string;
/**
* What the user asked for — whether they started the call or joined one that
* was already running, and whether it is a call in a group or a DM. Element
@@ -176,16 +213,48 @@ export interface ElementCallProps {
language?: string;
}
/**
* {@link ElementCall} for a host with a matrix-js-sdk client: the two drivers
* are built here from the client and the room, so the host hands over the
* client as it always has.
*/
export type ElementCallClientBasedProps = Omit<
ElementCallProps,
"rtcDriver" | "clientDriver" | "roomId"
> & {
/**
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
*/
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
};
/**
* Prepares the things Element Call needs before it can be shown: translations,
* `Intl` polyfills for older browsers, and its configuration.
*
* Await this once, before rendering {@link ElementCall}.
*/
export interface InitializeElementCallOptions {
/**
* Where to load the MatrixRTC SDK's wasm from, for a host that serves it
* from somewhere other than next to this bundle. Left out, the bundled
* copy is loaded when the first call needs it.
*/
matrixRtcWasm?: MatrixRtcWasmSource;
}
export async function initializeElementCall(
config: ConfigOptions = {},
{ matrixRtcWasm }: InitializeElementCallOptions = {},
): Promise<void> {
const polyfills: Promise<unknown>[] = [];
// The call runs on the Rust MatrixRTC crate; its wasm is loaded up front
// when the host says where from, and lazily otherwise.
if (matrixRtcWasm !== undefined)
polyfills.push(initMatrixRtcSdk(matrixRtcWasm));
if (shouldPolyfillSegmenter())
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
if (shouldPolyfillDurationFormat())
@@ -230,8 +299,9 @@ const Decoration: FC<{ children: JSX.Element }> = ({ children }) => {
};
export const ElementCall: FC<ElementCallProps> = ({
client,
roomId,
rtcDriver,
clientDriver,
roomId: suppliedRoomId,
intent = UserIntent.JoinExistingCall,
config,
hostBridge: suppliedHostBridge,
@@ -240,6 +310,11 @@ export const ElementCall: FC<ElementCallProps> = ({
language,
}): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
const roomId = clientDriver.roomId;
if (suppliedRoomId !== undefined && suppliedRoomId !== roomId)
throw new Error(
`Element Call was asked to call in ${suppliedRoomId} with drivers bound to ${roomId}`,
);
useEffect(() => {
if (language !== undefined)
@@ -288,24 +363,19 @@ export const ElementCall: FC<ElementCallProps> = ({
};
}, [controlledAudioDevices, callIntent]);
const room = client.getRoom(roomId);
const rtcSession = useMemo(
() => (room === null ? null : client.matrixRTC.getRoomSession(room)),
[client, room],
// The call runs on the Rust MatrixRTC crate over these two drivers; no
// matrix-js-sdk client is involved.
const drivers = useMemo(
(): MatrixDrivers => ({ rtcDriver, clientDriver }),
[rtcDriver, clientDriver],
);
if (rtcSession === null)
logger.error(
`Element Call was asked to call in ${roomId}, which its host's client does not know about`,
);
// Everything the call needs is in hand once these exist, and the first
// render with them is where the call itself appears: the moment the host
// is told that Element Call has loaded, as the widget tells its client once
// its own initialisation is over. Once per mount, however often the pieces
// are later swapped out.
const ready =
container !== null && rtcSession !== null && mediaDevices !== null;
const ready = container !== null && mediaDevices !== null;
const announcedLoaded = useRef(false);
useEffect(() => {
if (!ready || announcedLoaded.current) return;
@@ -331,12 +401,10 @@ export const ElementCall: FC<ElementCallProps> = ({
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MatrixDriverProvider value={drivers}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
@@ -344,7 +412,7 @@ export const ElementCall: FC<ElementCallProps> = ({
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</MatrixDriverProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
@@ -356,3 +424,26 @@ export const ElementCall: FC<ElementCallProps> = ({
</I18nextProvider>
);
};
export const ElementCallClientBased: FC<ElementCallClientBasedProps> = ({
client,
roomId,
...props
}): ReactNode => {
const room = client.getRoom(roomId);
if (room === null) {
logger.error(
`Element Call was asked to call in ${roomId}, which its host's client does not know about`,
);
return null;
}
return <ClientBasedCall client={client} room={room} {...props} />;
};
/** {@link ElementCallClientBased} once the room is known: builds the drivers. */
const ClientBasedCall: FC<
Omit<ElementCallClientBasedProps, "roomId"> & { room: Room }
> = ({ client, room, ...props }): ReactNode => {
const drivers = useJsSdkDrivers(client, room);
return <ElementCall {...props} {...drivers} />;
};
+1
View File
@@ -9,6 +9,7 @@ This folder contains documentation for setup, usage, and development of Element
- [MatrixRTC modes](./matrix_rtc_modes.md)
- [Self-Hosting](./self_hosting.md)
- [Developing with linked packages](./linking.md)
- [The MatrixRTC SDK package](./matrix_rtc_sdk.md) (registry token, CI, local crate)
### Writing code for Element Call (agent-readable conventions)
+3
View File
@@ -29,6 +29,9 @@ Instead, create a file named `.links.cjs` in the Element Call project directory
module.exports = {
"matrix-js-sdk": "../your/path/matrix-js-sdk",
"matrix-widget-api": "../your/path/matrix-widget-api",
// The MatrixRTC SDK: run `npm run build` in that directory first
"@element-hq/matrix-rtc":
"../your/path/matrix-rust-rtc/MatrixSdkArchitectureDraft/web-test-app",
};
```
+135
View File
@@ -0,0 +1,135 @@
# The MatrixRTC SDK package
Element Call's MatrixRTC logic (session membership, keep-alive, transport
tokens, media key exchange) runs in the Rust `matrix-rtc` crate, compiled to
WebAssembly and shipped as the npm package `@element-hq/matrix-rtc`. The
package is built and published by the
[`npm-web-bindings.yml`](https://github.com/element-hq/matrix-rust-rtc/blob/main/.github/workflows/npm-web-bindings.yml)
workflow of the `matrix-rust-rtc` repository, from its
`MatrixSdkArchitectureDraft/web-test-app` directory. Element Call loads it in
`src/matrix-rtc-sdk/index.ts`, the only module that imports the package
directly.
## Where the package comes from
For now the package lives on the **GitHub Packages** npm registry
(`https://npm.pkg.github.com`), not on npmjs.com. **This is a stop-gap** for
the phase in which the crate's API still changes with every push: publishing
from the crate's CI into a registry that comes with the repository needs no
account or secret upstream, and nothing else consumes the package yet. Once
the SDK is ready for consumers beyond Element Call, it should be published to
**npmjs.com** as `@element-hq/matrix-rtc`, which makes everything in this
document about tokens and scopes go away: `pnpm install` works anonymously
again, forks and Renovate can install, component hosts need no setup, and the
alias in `package.json` and the scope line in `.npmrc` are deleted.
Until then, two properties of GitHub Packages shape the setup below:
- **Every request needs a token**, including reads of public packages. There
is no anonymous install.
- **A package is scoped to the owner of the repository whose CI published
it.** While the crate is developed on the `BillCarsonFr` fork, the package is
published as `@billcarsonfr/matrix-rtc`; once it is published from
`element-hq/matrix-rust-rtc` it becomes `@element-hq/matrix-rtc`.
So that the code does not have to change when that happens, `package.json`
installs the fork's package under the final name:
```json
"@element-hq/matrix-rtc": "npm:@billcarsonfr/matrix-rtc@next"
```
and the repository's `.npmrc` maps only the scope to the registry:
```ini
@billcarsonfr:registry=https://npm.pkg.github.com
```
When the package moves to `element-hq`, change the alias to the plain package
and the scope line in `.npmrc` accordingly; nothing else in the repository
refers to the fork's name.
## Setting up your machine
1. Create a **personal access token (classic)** at
<https://github.com/settings/tokens> with only the `read:packages` scope.
Fine-grained tokens do not work with GitHub Packages.
2. Add it to your **user-level** npm config. Never put it in the repository's
`.npmrc`:
```sh
echo "//npm.pkg.github.com/:_authToken=<your token>" >> ~/.npmrc
```
3. Run `pnpm install` as usual.
Without the token, `pnpm install` fails while resolving
`@billcarsonfr/matrix-rtc` with a `401` or `404` from `npm.pkg.github.com`;
that error always means "no or wrong token", never "the package does not
exist".
## CI and other consumers
A workflow's built-in `GITHUB_TOKEN` can only read packages owned by the
repository's own owner, so element-hq's workflows cannot read a package owned
by the `BillCarsonFr` user. The workflows that run `pnpm install` therefore
write a personal access token from the repository secret
`MATRIX_RTC_NPM_TOKEN` (scope `read:packages`) to `~/.npmrc` before installing.
Keep in mind:
- **Pull requests from forks and from Renovate do not receive secrets**, so
their installs fail until the package is readable with the built-in token.
- **Hosts of the [component](../README.md#element-call-as-a-component-experimental)**
install it as a git dependency whose `prepare` script runs
`pnpm install --frozen-lockfile` in this repository, so they need the same
token in their `~/.npmrc` or CI.
Publishing from `element-hq/matrix-rust-rtc` (and granting the Element Call
repository access to the package) would let the built-in token work for
element-hq's own workflows, but that only patches the CI case. The intended end
state is the npmjs.com publication described above, which removes the token
requirement for everyone.
## Versions
The package version tracks the crate's `Cargo.toml`. Every push to the tracked
branches of `matrix-rust-rtc` publishes a `next` pre-release named
`<version>-next.<run number>.<short sha>`, and a `matrix-rtc-v<version>` tag
publishes `latest`. Element Call follows `next`; `pnpm install` picks up a new
pre-release only when the lockfile is updated, e.g. with
`pnpm update @element-hq/matrix-rtc`.
The publish job runs only after the crate's own `cargo test` job passed on a
_push_ (pull-request runs never publish). If a `next` build you expect is
missing, check that workflow's push runs for a failed crate job and re-run it;
its tests are known to be timing-sensitive.
## Developing against a local crate checkout
To run Element Call against a crate you are changing, build the package there
and link it, instead of waiting for CI:
```sh
cd ../matrix-rust-rtc/MatrixSdkArchitectureDraft/web-test-app
npm install && npm run build # produces dist/
```
then add it to `.links.cjs` in this repository and run `pnpm links:on`, as
described in [Developing with linked packages](./linking.md):
```cjs
"@element-hq/matrix-rtc": "../matrix-rust-rtc/MatrixSdkArchitectureDraft/web-test-app",
```
`pnpm links:off` restores the registry package before committing.
## How the wasm is loaded
`initMatrixRtcSdk()` in `src/matrix-rtc-sdk/index.ts` is the single entry
point: it awaits the package's `initAsync`, then installs the log sink that
routes the crate's logs into Element Call's logger. The wasm is imported as
`@element-hq/matrix-rtc/wasm?url`, so the app builds emit it as an asset next
to the other `.wasm` files and the component build inlines it. Unit tests read
the bytes from the installed package instead (`src/utils/test-matrix-rtc.ts`),
since vitest has no server to fetch from. A component host can point at a copy
it serves itself through `initializeElementCall(config, { matrixRtcWasm })`.
+977
View File
@@ -0,0 +1,977 @@
# Element Call oxidation plan
Move Element Call's MatrixRTC participation logic off `matrix-js-sdk`'s
`MatrixRTCSession` and onto the Rust `matrix-rtc` crate
(`~/Projects/matrix-rust-rtc/MatrixSdkArchitectureDraft`), consumed through its
uniffi wasm bindings. The Element Call **component** then depends on one
host-supplied object, a `MatrixDriver`, and never on a `MatrixClient`. The
standalone app and the widget become hosts like any other: they build a
`MatrixDriver` from their `matrix-js-sdk` client and hand it to the component.
Revision 2 — incorporates the independent review (§10 lists what changed and
the assumptions taken where only the user can decide).
Status legend: ☐ todo · ◐ in progress · ☑ done.
**Where things stand (2026-09-14):** S0a, S0b, S1a, S1b and S2 are
implemented and green (`pnpm lint`, `pnpm format:check`, `pnpm test:unit`:
101 files / 785 tests). Nothing is committed yet, in either repository: the
crate changes C2–C12 (C1 reverted in favour of slot opening) are done; C2–C8,
C11 and C12 are committed in the draft repo as `a8e21b3`, C5 (final form), C9
and C10 are still uncommitted there. In Element Call everything sits
uncommitted in
`~/Projects/matrix-rust-rtc/MatrixSdkArchitectureDraft`, and Element Call's
branch `toger5/oxidation` holds the vendored bindings, the driver layer
(`src/driver/**`), the participation layer (`src/state/rtc/**`) and the
config/lint changes. S3a is the next slice; its design is in §6 and the
files it touches are listed there.
---
## 1. Goals and non-goals
**Goals**
1. `ElementCall` (component) takes `driver: MatrixDriver` instead of
`client: MatrixClient`. Nothing rendered under `CallView` imports
`MatrixClient`, `Room`, `RoomMember`, `MatrixEvent` or
`matrix-js-sdk/lib/matrixrtc`.
2. All MatrixRTC participation logic (session projection, own membership
join/leave/keep-alive, transport tokens, media key exchange) comes from the
crate's `FfiParticipationManager`. Element Call keeps only what the crate
deliberately leaves to the host: the LiveKit media plane, tiles, room
metadata, reactions, notifications, UI.
3. The standalone SPA, widget mode and `sdk/main.ts` construct a
`JsSdkMatrixDriver` (a port of the draft's `web-test-app/src/jsSdkDriver.ts`
that also works on js-sdk's `RoomWidgetClient`, extended with what Element
Call needs beyond RTC) and stop using `client.matrixRTC`.
4. Every existing gate stays green: `pnpm lint` (tsc, oxlint, knip, component
externals), `pnpm format:check`, `pnpm test` (unit + storybook),
`pnpm i18n:check`, all four builds, Playwright (standalone, widget,
component).
**Non-goals**
- Replacing `matrix-js-sdk` in the standalone shell (login, registration, room
creation, home page, crypto bootstrap). The shell keeps its client and wraps
it. `src/home/useGroupCallRooms.ts` stays on `client.matrixRTC` for now.
- Removing `matrix-js-sdk/lib/logger`. It is isolated behind
`src/utils/logger.ts` (S6) so a later swap is one line.
- Writing a matrix-rust-sdk-backed driver (Element X). The interface is shaped
so one can be written; none is written here.
- Publishing the crate as an npm package: done upstream (`@element-hq/matrix-rtc`,
built and published by `npm-web-bindings.yml` in matrix-rust-rtc; consumed
here since 2026-09-16, §5.1).
- Turning on MSC4153 (cross-signed sender) enforcement. Parity first (§5.8).
---
## 2. Where the code is today (inventory)
Entry: `component/index.tsx` → `src/room/CallView.tsx` → `LobbyView` |
`ActiveCall` (`src/room/InCallView.tsx`) | `CallEndedView`. `ActiveCall`
builds the view model with
`createCallViewModel$(scope, rtcSession, matrixRoom, mediaDevices, muteStates, options, raisedHands$, reactions$, trackProcessorState$)`.
| Concern | Files | js-sdk surface used |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session memberships | `src/state/SessionBehaviors.ts`, `src/useMatrixRTCSessionMemberships.ts` | `rtcSession.memberships`, `MembershipsChanged`, `membership.getTransport`, `isKeyRotationSuppressed` |
| Own membership | `localMember/LocalMember.ts` (`enterRTCSession`), `localMember/HomeserverConnected.ts` | `joinRTCSession`, `leaveRoomSession(1000)`, `updateCallIntent`, `MembershipManagerEvent.*`, `ClientEvent.Sync`; delegation probe at `LocalMember.ts:298-313`, delegation through the JWT service at `:731-756` |
| SFU / JWT | `src/livekit/openIDSFU.ts`, `localMember/LocalTransport.ts`, `localMember/RtcTransportAutoDiscovery.ts`, `remoteMembers/Connection.ts`, `ConnectionFactory.ts`, `ConnectionManager.ts` | `getOpenIdToken`, `_unstable_getRTCTransports`, `POST /get_token` (+ `delay_id`, `delay_timeout`, `delay_cs_api_url`), legacy `/sfu/get` |
| Remote member ↔ LiveKit identity | `remoteMembers/MatrixLivekitMembers.ts` | `rtcBackendIdentity`, `userId`, `deviceId`, `memberId` |
| E2EE media keys | `src/e2ee/matrixKeyProvider.ts`, `src/e2ee/sharedKeyManagement.ts` | `EncryptionKeyChanged`, `reemitEncryptionKeys`, `room.hasEncryptionStateEvent` |
| Room metadata | `remoteMembers/MatrixMemberMetadata.ts`, `src/utils/displayname.ts`, `src/room/useRoomName.ts`, `useRoomState.ts`, `useRoomAvatar.ts`, `useJoinRule.ts`, `InviteModal.tsx`, `CallView.tsx` | `getMembersWithMembership`, `RoomStateEvent.Members`, `room.name`, `getMxcAvatarUrl`, `getJoinRule`, `getCanonicalAlias` |
| Own profile / avatars | `src/profile/useProfile.ts`, `src/Avatar.tsx` | `getUser`, `UserEvent.*`, `setDisplayName`, `setAvatarUrl`, `uploadContent`, `mxcUrlToHttp`, `getAccessToken` |
| Reactions / hand raise | `src/reactions/ReactionsReader.ts`, `useReactionsSender.tsx`, `src/reactions/index.ts` | `RoomEvent.Timeline/Redaction/LocalEchoUpdated`, `MatrixEventEvent.Decrypted`, `relations.getChildEventsForEvent`, `sendEvent`, `redactEvent`, membership `eventId`, `RelationType` |
| Call notifications | `CallViewModel/CallNotificationLifecycle.ts` | `DidSendCallNotification`, `RoomEvent.Timeline` + `EventType.RTCDecline` |
| Rageshake / dev settings | `src/settings/submit-rageshake.ts`, `rageshake.ts`, `FeedbackSettingsTab.tsx`, `DeveloperSettingsTab.tsx` | `getCrypto`, `sendEvent(org.matrix.rageshake_request)`, `ClientEvent.Event`, `secureRandomString`, `doesServerSupportUnstableFeature`, `getSFUConfigWithOpenID` |
| Analytics | `src/analytics/PosthogEvents.ts`, `PosthogAnalytics.ts` | `rtcSession.statistics`, account data |
| Types only | `src/UrlParams.ts`, `src/state/MediaDevices.ts`, `AndroidControlledAudioOutput.ts`, `IOSControlledAudioOutput.ts`, `initialMuteState.ts`, `state/media/RingingMediaViewModel.ts` (`RTCCallIntent`), `src/useEvents.ts` (`TypedEventEmitter` types) | replaced by a local `CallIntent` type / kept as generic emitter typing |
| Runtime misc | `src/useLocalStorage.ts` (`TypedEventEmitter`), `src/room/GroupCallErrorBoundary.tsx` (`MatrixError`), `src/room/KnockLobbyView.tsx` (shell) | see S6 |
| Context | `src/ClientContext.tsx` | `useClient`/`useClientState` used by `Avatar`, `sharedKeyManagement`, `useReactionsSender`, `submit-rageshake` |
Hosts: `component/index.tsx:291`, `src/room/useLoadGroupCall.ts:335`,
`sdk/main.ts:128` (own `MatrixRTCSessionManager`; waits on
`MatrixRTCSessionEvent.JoinStateChanged` at `:292`). Test kit:
`src/utils/test.ts` (`MockRTCSession`, `mockRtcMembership`, `mockMatrixRoom`),
`src/utils/test-viewmodel.ts`, `CallViewModelTestUtils.ts`. Baseline on
`main` (fe911628): tsc green, 97 unit files / 757 tests green.
Component build: `vite-component.config.ts` (single string `fileName`,
externals list with 18 `matrix-js-sdk/lib/*` subpaths), `pnpm lint:externals`,
`component/package.json` (`matrix-js-sdk: "*"` peer, `exports` without a
wildcard).
---
## 3. What the crate gives us, what it does not, and what must change in it
Verified against `src/uniffi_api/mod.rs`, `src/participation/mod.rs`,
`src/session/state.rs`, `src/own_membership/machine.rs`,
`src/encryption/matrix_encryption_event.rs` and the generated
`web-test-app/src/generated/matrix_rtc.ts` (acceptance suites: 32 pass).
**Provided** (`FfiParticipationManager`, one per `(room, slot)`, any number
share one `FfiMatrixDriver`):
- `join(FfiTransportIntent, FfiJoinParams)` / `leave(code?, reason?)`; a
`Publish` intent with a bare LiveKit transport triggers discovery through
`driver.getRtcTransports()` (`connections/mod.rs:470-499`); a driver _error_
there is `NoTransport`, not a fallback.
- `memberships()` + listener: `FfiMembership { member { memberId, userId,
deviceId, displayName?, avatarUrl?, intent?, applicationType?,
publishedTransports, canSubscribe }, state: Joined | LeftWithKeys,
connections: serviceUrl[] (the FFI doc comment saying ws urls is wrong),
transportIdentity?, mediaKey? }`. `transportIdentity` is today's
`rtcBackendIdentity`. `LeftWithKeys` entries have empty `connections`.
- `connections()` + listener: `{ connection { serviceUrl, wsUrl, jwtToken,
expiresAtTs }, members }[]`; tokens re-minted a minute before `exp`.
- `keyMap()` + `setKeyMapListener(map, change)`: `FfiMediaKey { memberId, key:
ArrayBuffer, index, creationTsMs: bigint }`, inbound keys **and our own**
(`encryption/inbound.rs:251-257`).
- `status()` + listener: `Disconnected{cause} | Joining | Connected{keepAlive,
membership, roster, encryption, impairments} | Leaving`.
- `session()`, `ownMemberId()` (available as soon as `join()` starts),
`ownMembership()`, `connectionProblems()`, `debugSnapshot()`.
- Member display names and avatars, from the room's `m.room.member` state
(C8): the session keeps them current, so a rename is a memberships change.
- Slots are required: once the seed has read slot state, a slot with no event
is closed, so a room without an `m.rtc.slot` has no call, in every dialect.
`openSlot(application, encrypted)` / `closeSlot()` send the state event;
the slot id `m.call#ROOM` matches js-sdk's default.
- `FfiElementCallCompat.{Off, StateEvents}`: spec MSC4143, or MSC3401 state
events for the clients that predate sticky events (C9 removed the never
deployed 2025 sticky dialect).
- All `u64` fields are `bigint` in TypeScript; `Vec<u8>` is `ArrayBuffer`.
- Listener callbacks arrive one timer tick after the emitting call (pumps
sleep through `setTimeout`, `executor.rs:86`); getters are fresh.
**Not provided — Element Call keeps it, through the driver** (§4.1 slices):
| Need | Why not in the crate | Where it goes |
| ------------------------------------------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Room name, alias, avatar, join rule, encryption flag | room metadata | `RoomDriver.getRoomInfo` |
| Profiles of room members who are not in the call (ringing, name-tag threshold) | not RTC | `RoomDriver.subscribeRoomMembers` |
| Reactions, hand raise, `org.matrix.rageshake_request` | application events | `TimelineDriver` |
| MSC4075 notification + decline | out of scope (only `wire_event_type` knows the type) | `TimelineDriver.sendRoomEvent`, decided in `CallNotificationLifecycle` (§4.3) |
| Own profile read/write | not RTC | `ProfileDriver` |
| `mxc://` thumbnails with auth | not RTC | `MediaDriver.thumbnailUrl` |
| Homeserver sync connectivity | needed by the crate too | `RtcMatrixDriver` (`isHomeserverConnected`, `subscribeConnectivity`, C12); reaches Element Call as `HomeserverUnreachable` in the status |
| Sticky-events support probe | capability probe | `MatrixDriver.getMatrixClientFeatures()` |
**Must change in the crate (S0a) — each blocks a later slice:**
| # | Problem | Change |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| C1 | A successful `read_state("m.rtc.slot")` returning `[]` marks slot state supplied and every slot other than the legacy `""` resolves `Closed` (`session/state.rs`); `join()` then fails with `SlotClosed` and every MSC4143 peer is excluded. Element Call never sent `m.rtc.slot`. | **Kept as the crate has it: no slot means no call.** Element Call opens the slot when nobody has (`RtcParticipationManager.join` with a `SlotPolicy`: `openSlot("m.call", encrypted)`, then wait for the echo), which needs the power level to send `org.matrix.msc4143.rtc.slot`; without it the join fails with `NoOpenSlotError`. Existing rooms keep working because the first call in a room opens its slot. A compat-mode relaxation was tried and reverted. |
| C2 | `manage_media_keys`, `require_cross_signed_sender`, `use_key_delay_ms` are not settable over the FFI; defaults are `true`, `true`, 1000 ms. | New record `FfiParticipationConfig { compat, manage_media_keys, require_cross_signed_sender, use_key_delay_ms }` as the constructor argument (replaces the bare `compat`). |
| C3 | `StickyEvents` compat sent keys as `org.matrix.msc4143.rtc.encryption_key` while deployed clients read only `io.element.call.encryption_keys`. | Made moot by C9 and reverted with it: the sticky dialect goes away entirely, so `Off` sends the spec key message and `StateEvents` the legacy one, with no middle case. |
| C4 | `FfiMember` has no membership `event_id`; reactions relate to it (§4.3). | `Member.event_id: Option<String>` threaded through `session/dispatch.rs` → `convert/*` → `state.rs` (currently dropped at `state.rs:402`) → `FfiMember.event_id`. |
| C5 | **Done.** Delegating the delayed leave (MSC4195) is split between the crate and the host: the draft's driver method makes the _adapter_ perform the whole delegation, its demo adapter calls a homeserver endpoint that 401s on a widget client, and Element Call today does it differently (probe, then the authorisation service's `get_token` with `delay_id`/`delay_timeout`/`delay_cs_api_url`). Nothing of this exists in the real `crates/` yet. | **The crate owns the policy; the driver keeps two primitives.** (a) `delegate_delayed_leave_via_homeserver(room, slot, member, delay_id)`: one authenticated POST to the CS API endpoint (`/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`, the spelling Element Call probes today; a widget client answers `Unsupported`). (b) `LivekitTokenRequest.delegation: Option<{ delay_id, delay_timeout_ms }>`: the adapter appends `delay_id`, `delay_timeout` and `delay_cs_api_url` (its own homeserver URL) to the `get_token` / `sfu/get` body it already sends. The own-membership machine tries (a) first and, on `Unsupported` or any failure, (b) against the transport we publish on, i.e. Element Call's OpenID → JWT → scheduled-event path; only if both fail does it keep restarting the switch itself. **Arm-after-confirm:** the short delayed leave stays armed through the join; delegation arms a second, ≥ 1 h delayed leave, delegates _that_, and cancels the short one on success (or the long one on failure), so no moment is left without an armed leave and a failed delegation costs nothing. `KeepAlive::Delegated` says which route succeeded. The interim C5 (service URL and delay on the request) and Element Call's driver-side probe and JWT delegation are replaced by this. **Real-backend check (2026-09-15):** both routes send the MSC4195 `member` _claims_ (`{ id, claimed_user_id, claimed_device_id }`), not the member block of the event. The homeserver route body is what lk-jwt-service 0.7 accepts behind a Synapse that proxies `rtc/livekit/*` to it (MSC4512, `backend/app-service.yaml`): `{ url, room_id, slot_id, member, delay_id, delay_timeout }`, where `url` is the SFU websocket URL the transport's token named (the service checks it is its own). The transport resolver therefore returns `ResolvedTransport { transport, sfu_url }` and the machine carries the url into `Action::DelegateViaHomeserver`; without an SFU url it goes straight to the service route, and a receive-only member (no transport) delegates nothing. Verified end to end against `pnpm backend`: the homeserver route takes the 1 h leave (`KeepAlive::Delegated { via: Homeserver }`, one delayed event of 3 600 000 ms on the server). |
| C6 | The transport identity is a pure function (`connections/mod.rs:116-135`) but not exported; the own identity is needed before our membership echo to set our own media key. | Export `FfiParticipationManager.own_transport_identity(): Option<String>`. |
| C7 | Doc comment on `FfiMembership.connections` says `ws_url`s. | Fix the comment. |
| C8 | Both converters set `display_name` / `avatar_url` to `None`, so every host would re-derive them from room members. | The session records each `m.room.member` profile (whether or not the roster condition is enforced) and stamps it on members at projection time. |
| C9 | `ElementCallCompat::StickyEvents` models the 2025 Element Call sticky dialect (`member: {user_id, device_id, id}`, flat `rtc_transports`, `versions`, legacy key message). No deployment uses it: sticky-event calls have not shipped. | **Done.** Removed: `own_membership/compat_2025.rs`, the 2025 block in `session/convert/msc4143.rs` and its dispatch arm, the `StickyEvents` variants of `ElementCallCompat` / `FfiElementCallCompat`, and their tests and acceptance tests; `outbound_event_type` / `build_content` keep two arms (`Off`, `StateEvents`). Element Call then maps `Matrix_2_0 → Off`: spec MSC4143 sticky events with slots (which Element Call opens, C1) and the spec key message. |
| C10 | `MediaKeyState` has `holds_our_key`, `have_their_key` and `rejection`, so a tile learns about an unsigned sender only when `require_cross_signed_sender` is on (the key is discarded, `rejection: NotCrossSigned`). With the check off the verdict is dropped on accept and the tile cannot show an unverified sender. | **Done.** The inbound key store keeps the MSC4153 verdict of the accepted key per member and exposes it as `FfiMediaKeyState.sender_cross_signed: Option<bool>` (`None` when the host could not tell). Element Call runs with the check off (§5.8) and wants to show the state on the tile until it is turned on. |
| C11 | No way to change `application["m.call.intent"]` while joined; Element Call flips it between `audio` and `video` when the camera is toggled (`updateCallIntent`). | `update_application(intent)` on the own-membership manager, facade and FFI: while connected the membership is re-published at once on the refresh path (a failure retries like a refresh); during a join the join event carries it; refused with `NotJoined` otherwise. |
| C12 | Homeserver connectivity lived only in Element Call's driver; the crate could not tell a dead homeserver from a quiet one, and a participation's status said nothing about it. | **Done.** `ConnectivityDriver` (`is_homeserver_connected`, `subscribe_connectivity`) joins the `MatrixDriver` sum; the FFI adds `ConnectivitySink`, the two callback methods and `FfiParticipationManager.is_homeserver_connected()`; the facade pump consumes the stream and reports `Impairment::HomeserverUnreachable { since_ts }` (Critical, sorted first) in every non-disconnected status until the driver reports the homeserver back. The web-test-app mock and js-sdk driver implement it. A matrix-rust-sdk adapter implements the same two methods later. |
| C13 | `session()` moved (seed done, slot opened by somebody else) without any listener firing, so a host's `session$` stayed stale until a membership or status change happened to refresh it. Found by the real-backend check. | **Done.** `SessionListener` / `set_session_listener` on the FFI manager (`on_session_change` on the facade), fired publish-on-change from `refresh_outputs`; `RtcParticipationManager.session$` is fed from it. |
| C14 | The crate logs through the `log` facade (104 call sites) but installed no logger, so in wasm every line was dropped: seeding, joins, delegation fallbacks and key rotation left no trace in a rageshake. | **Done (2026-09-16).** `LogSink` foreign trait (`log(level, target, message)`), `FfiLogLevel`, and `set_log_sink(sink, max_level)`, which installs a `log::Log` forwarding to the sink (a host that already installed a Rust logger keeps it). Element Call installs `matrixRtcLogSink` under `[matrix-rtc]` on the js-sdk root logger from `initMatrixRtcSdk()` at debug; the web-test-app installs a console sink (warn in tests). |
No crate work is deferred: `update_application` is C11.
---
## 4. Target architecture
```text
host (SPA · widget · sdk · a third-party page)
├─ RtcMatrixDriver = the crate's MatrixDriverCallback, verbatim
│ (events, to-device, tokens, sinks, connectivity)
└─ ElementCallMatrixClientDriver = RoomDriver + TimelineDriver + ProfileDriver
+ MediaDriver + capabilities
│
▼ component/index.tsx <ElementCall rtcDriver clientDriver …/>
┌─ MatrixDriverProvider (src/driver/MatrixDriverContext.tsx) ─────────────┐
│ CallView owns one RtcParticipationManager for lobby → call → ended │
│ RtcParticipationManager (src/state/rtc/RtcParticipationManager.ts) │
│ FfiMatrixDriver(driver) → FfiParticipationManager(room, slot, me, cfg)│
│ memberships$ · connections$ · keyChanges$ · status$ · session$ │
│ ownMemberId$ · ownMembership$ · ownTransportIdentity$ │
│ join(intent, params) · leave(reason) │
│ │ │
│ ▼ │
│ createCallViewModel$(scope, callParticipation, roomInfo, mediaDevices, …) │
│ ConnectionManager ← connections$ (wsUrl + jwt; no OpenID in EC) │
│ RemoteMembers ← memberships$ (transportIdentity ↔ LK participant)│
│ MatrixKeyProvider ← keyChanges$ (memberId → transportIdentity) │
│ LocalMember ← status$, ownMembership; join/leave → participation│
│ MemberMetadata ← driver.room members │
│ Notifications ← driver.timeline + memberships$ │
│ React: Lobby / InCall / Settings / Avatar / Reactions │
│ read the driver via useMatrixDriver(), never a client │
└──────────────────────────────────────────────────────────────────────────┘
```
### 4.1 The two drivers (host-facing, framework-neutral)
Two files, two objects. `src/driver/RtcMatrixDriver.ts` is one line: the
crate's `MatrixDriverCallback`, re-exported. Everything MatrixRTC, including
homeserver connectivity (C12), goes through it and is consumed by the crate.
`src/driver/ElementCallMatrixClientDriver.ts` is what a call needs beyond
MatrixRTC. Plain TypeScript: async methods and
`subscribeX(listener) → unsubscribe` pairs, mirroring the crate's sink style.
No RxJS crosses this boundary; Element Call wraps subscriptions into
`Behavior`s internally (`src/driver/observe.ts`).
```ts
export interface ElementCallMatrixClientDriver
extends RoomDriver, TimelineDriver, ProfileDriver, MediaDriver {
readonly userId: string;
readonly deviceId: string;
/** The room this driver is bound to (one driver per room, as in the crate). */
readonly roomId: string;
getMatrixClientFeatures(): Promise<MatrixClientFeatures>;
/** Free-form diagnostics for rageshakes (crypto version, sync state, …). */
getDiagnostics?(): Promise<Record<string, string>>;
}
export interface MatrixClientFeatures {
stickyEvents: boolean;
/** The host's events carry decryption metadata (false on a widget client). */
verifiedEventOrigins: boolean;
/** The host can evaluate MSC4153 cross-signing of senders. */
crossSigningVerdicts: boolean;
}
// src/driver/RtcMatrixDriver.ts
export type RtcMatrixDriver = MatrixDriverCallback;
export interface RoomInfo {
name: string;
canonicalAlias: string | null;
avatarUrl: string | null;
joinRule: string | null;
encrypted: boolean;
}
export interface RoomMemberProfile {
userId: string;
displayName: string | null;
avatarUrl: string | null;
membership: "join" | "invite";
}
export interface RoomDriver {
getRoomInfo(): RoomInfo;
subscribeRoomInfo(listener: (info: RoomInfo) => void): () => void;
getRoomMembers(): RoomMemberProfile[];
subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): () => void;
}
export interface TimelineEvent {
eventId: string;
type: string;
sender: string;
content: Record<string, unknown>;
originServerTs: number;
redacts?: string;
}
export interface TimelineDriver {
sendRoomEvent(
eventType: string,
content: unknown,
): Promise<{ eventId: string }>;
redactEvent(eventId: string): Promise<void>;
/** Decrypted live room events (not sticky), incl. redactions; no local echoes. */
subscribeTimeline(listener: (event: TimelineEvent) => void): () => void;
/** Events already known that relate to `eventId` (hand-raise catch-up). */
getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[];
}
export interface OwnProfile {
displayName: string | null;
avatarUrl: string | null;
}
export interface ProfileDriver {
getOwnProfile(): OwnProfile;
subscribeOwnProfile(listener: (profile: OwnProfile) => void): () => void;
/** Absent when the host does not allow profile changes. */
setDisplayName?(name: string): Promise<void>;
setAvatar?(file: Blob): Promise<void>;
}
export interface MediaDriver {
/** An `<img>`-usable URL for an mxc thumbnail (may be a blob: URL), or null. */
thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
resizeMethod: "crop" | "scale",
): Promise<string | null>;
}
```
The client driver is a union of capability slices, the way the crate splits
its own driver; the RTC driver is the crate's contract untouched, so a host
with a crate-side adapter (matrix-rust-sdk) implements nothing extra for
MatrixRTC.
### 4.2 `RtcParticipationManager` (Element Call's RxJS view of the manager)
Naming: _participation_ is the crate's FFI concept (`FfiParticipationManager`,
`FfiParticipationConfig`); `RtcParticipationManager` is Element Call's RxJS wrapper
over it.
`src/state/rtc/RtcParticipationManager.ts`, a class taking the scope in its constructor:
```ts
new RtcParticipationManager(scope, driver, {
slotId: "m.call#ROOM", compat, manageMediaKeys, requireCrossSignedSender,
useKeyDelayMs, transportFallbackUrl?, logger })
memberships$: Behavior<Epoch<FfiMembership[]>> // Joined only; LeftWithKeys filtered (v1)
connections$: Behavior<FfiConnectionWithMembers[]>
keyChanges$: Observable<FfiMediaKey> // one changed key per emission
keyMap$: Behavior<FfiMediaKey[]>
status$: Behavior<FfiStatus>
session$: Behavior<FfiSessionSnapshot>
ownMemberId$: Behavior<string | null>
ownTransportIdentity$: Behavior<string | null>
ownMembership$: Behavior<FfiMembership | null>
join(intent: FfiTransportIntent, params: FfiJoinParams): Promise<void>
leave(code?: string, reason?: string): Promise<void>
```
- Wraps `new FfiMatrixDriver(driver)` and `new FfiParticipationManager(...)`;
`uniffiDestroy()` on scope end (leave first unless `Disconnected`).
- `transportFallbackUrl` decorates `getRtcTransports`: when the host's call
**throws or returns no LiveKit transport**, answer with
`Config.get().livekit.livekit_service_url` (today's precedence,
`RtcTransportAutoDiscovery.ts:72-94`).
- Lives at `CallView` level (the lobby reads memberships for the participant
count, auto-mute threshold and notification decision, `CallView.tsx:164-413`;
`useReactionsSender` needs the own membership). `join()` after `leave()` on
one manager is supported and mints a fresh member id.
- Behaviors are seeded from the getters and updated by the listeners.
### 4.3 `createCallViewModel$` after the change
```ts
createCallViewModel$(
scope,
participation,
roomInfo,
mediaDevices,
muteStates,
options,
handsRaised$,
reactions$,
trackProcessorState$,
);
// roomInfo: { roomId, userId, deviceId, members$: Behavior<RoomMemberProfile[]>,
// homeserverConnected$: Behavior<boolean>, timeline: TimelineDriver }
```
| Today | After |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createMemberships$(scope, rtcSession)` | `callParticipation.memberships$` |
| `membershipsAndTransports$` (`getTransport(oldest)`) | `membership.connections[]` (service urls) |
| `createLocalTransport$` + `RtcTransportAutoDiscovery` + `openIDSFU` | deleted; `join(Publish(custom url or bare))`; own transport = `ownMembership.member.publishedTransports[0]` |
| `Connection.start()` fetching a JWT | `Connection` takes `{ wsUrl, jwt, expiresAtTs }` from `connections$`, keyed by `serviceUrl`, holds `token$`; a refreshed token is used on the next full (re)connect; `expiresAtTs` logged on connect; `livekitAlias` decoded from the JWT stays |
| `createRemoteMatrixLivekitMembers$` on `rtcBackendIdentity` | matches `membership.transportIdentity`; key = `member.memberId` |
| `MatrixKeyProvider.setRTCSession` | `MatrixKeyProvider.attach(participation)`: `keyChanges$` × `memberships$` × `ownTransportIdentity$` → `onSetEncryptionKey(material, identity, index)`; keys whose member has no identity yet are held per member id and replayed |
| `enterRTCSession` (`joinRTCSession(...)`) | `callParticipation.join(intent, joinParamsFromConfig(...), { encrypted: roomInfo.encrypted, canOpen: roomInfo.canOpenSlot })` in the same `scope.reconcile`: opens the room's slot first when none is open (power level permitting, otherwise `NoOpenSlotError`), then joins; cleanup calls `callParticipation.leave()` |
| `createHomeserverConnected$` | `status$` alone: `Impairment::HomeserverUnreachable` (C12) = disconnected; `Connected` with `keepAlive` `Armed`/`Delegated`/`Unavailable` = connected; `RestartFailing`/`Expired` = reconnecting (**behaviour change**: local media pauses in that window, today it does not). Outside a participation, `RtcParticipationManager.homeserverConnected$` from the manager's getter |
| `delayId$` + JWT-service delegation | gone from Element Call. `FfiJoinParams.delegateDelayedLeave` is always `true`; the crate tries the CS API, then the authorisation service's token endpoint (with `delay_id`, `delay_timeout`, `delay_cs_api_url`), then falls back to its own restarts (C5). `config.matrix_rtc_session.delegated_delayed_leave.delay_ms` becomes `FfiJoinParams.delegatedDelayMs` (default 1 h) |
| `createMatrixMemberMetadata$(scope, matrixRoom)` | tiles read `member.displayName` / `avatarUrl` from the crate; disambiguation runs over the call's members; `roomInfo.members$` remains only for the ringing name and the name-tag threshold |
| `createSentCallNotification$` / `createReceivedDecline$` | `CallNotificationLifecycle`: after **our own membership echo** (`ownMembership$` non-null) and when no other member was in the session before our join, send `m.rtc.notification` (wire `org.matrix.msc4075.rtc.notification`) via `driver.sendRoomEvent` with the fields js-sdk sends today (`m.mentions`, `notification_type`, `sender_ts`, `lifetime` 90 s, `m.call.intent`, `m.relates_to: m.reference → own membership event id`, `MatrixRTCSession.ts:725-756`); decline from `driver.subscribeTimeline` on both `org.matrix.msc4310.rtc.decline` and `m.rtc.decline` |
| `updateCallIntent` on camera toggle | `callParticipation.updateApplication(videoEnabled ? "video" : "audio")` (C11); Element X's room-header intent keeps following the camera |
| `createKeyRotationSuppressed$` + `key_rotation_participant_limit` | dropped (the crate has no participant limit; the indicator has no equivalent) |
| `rtcSession.statistics` (PostHog) | counts of `keyChanges$` (sent = own member id, received = others) |
| `MembershipManagerError` → `StickyEventsRequiredError` | `status$` `Disconnected{cause: JoinFailed{error: Driver/Unsupported}}` → `StickyEventsRequiredError`; `NoTransport` → `MatrixRTCTransportMissingError`; `ManagerStopped`/`SlotClosed` → `ConnectionLostError` |
| `impairments` | fed into the inert `src/state/ServiceInterruptionsViewModel.ts` (S6) |
Join parameters (`joinParamsFromConfig`, from `Config.get().matrix_rtc_session`):
`stickyDurationMs = BigInt(Math.min(membership_event_expiry_ms ?? 4 h, 1 h))`
(js-sdk caps sticky at 1 h, the crate clamps to 1 h; the config default is
undefined today); `keepAliveTimeoutMs = BigInt(delayed_leave.delay_ms)`;
`degradedLifetimeMs = undefined`; `applicationType = "m.call"`;
`intent = options.callIntent`. Config keys that stop having an effect and are
documented as such in `docs/`: `delayed_leave.restart_ms`,
`restart_timeout_ms`, `network_error_retry_ms`,
`key_rotation_participant_limit`, `delegated_delayed_leave.*` (the crate arms
1 h when delegating). `wait_for_key_rotation_ms` maps to
`FfiParticipationConfig.useKeyDelayMs`.
Compat: `MatrixRTCMode.Compatibility → StateEvents`, `Matrix_2_0 → Off` (spec
MSC4143: sticky member events, slots, the spec key message), in
`compatForMode`.
### 4.4 React tree
- `src/driver/MatrixDriverContext.tsx`: `MatrixDriverProvider` holding both
drivers, `useRtcMatrixDriver()` / `useClientDriver()`; replaces every
`useClient()`/`useClientState()` under `CallView`. `ClientContext` stays for the shell.
- `CallView` props: `{ driver, isPasswordlessUser, confineToRoom, preload, skipLobby }`.
It creates the `RtcParticipationManager` (scope tied to its mount) and hands it to
`LobbyView`, `ActiveCall`, `useReactionsSender`. `MatrixInfo` comes from
`driver.getRoomInfo()` / `driver.getOwnProfile()`.
- `InCallView`'s own id becomes `${driver.userId}:${driver.deviceId}`.
- `Avatar` → `driver.thumbnailUrl` (host bridge `downloadMedia` first).
- `useProfile(client)` → `useOwnProfile()`; `ProfileSettingsTab` hides editing
when `setDisplayName` is absent.
- `ReactionsReader(scope, participation, driver)`, `useReactionsSender` over
`TimelineDriver`. Relation target stays the **current own membership event
id** (`member.eventId`, C4) — protocol status quo; the reader keys raised
hands by `memberId` and re-resolves the event id on re-send instead of
dropping the hand.
- `useRoomEncryptionSystem` reads `getRoomInfo().encrypted`.
- `submit-rageshake`: `useMatrixDriver()` for ids and `getDiagnostics?()`;
rageshake requests via `subscribeTimeline`.
- `DeveloperSettingsTab`: sticky probe → `getMatrixClientFeatures()`; custom LiveKit
URL validation → `driver.getLivekitToken(...)`.
- `window.rtcSession` debug handle → `window.matrixRtc = { participation }`.
### 4.5 Hosts
- **Component**: `ElementCallProps.rtcDriver: RtcMatrixDriver` and
`clientDriver: ElementCallMatrixClientDriver`; `roomId` stays as an
**optional** prop for one release and must equal `clientDriver.roomId` when given
(assertion), then goes. `initializeElementCall(config, { matrixRtcWasm? })`
awaits `initMatrixRtcSdk()`. Externals shrink to `react*`, `livekit-client`,
`matrix-js-sdk/lib/logger`. `JsSdkRtcMatrixDriver` and `JsSdkElementCallMatrixClientDriver` are
exported from a second entry `@element-hq/element-call-component/matrix-js-sdk` (needs
`lib.fileName` as a function and a new `exports` key); only that entry has
`matrix-js-sdk` as a peer.
- **SPA / widget**: `useLoadGroupCall` returns the `Room`; `RoomPage` memoises
the two js-sdk drivers and renders `<CallView rtcDriver clientDriver>`. The
widget capability list in `src/widget.ts` grows by: send and receive state
`m.rtc.slot` and `org.matrix.msc4143.rtc.slot` (opening the slot), `m.room.avatar`,
`m.room.canonical_alias`, `m.room.join_rules`; send/receive to-device
`org.matrix.msc4143.rtc.encryption_key` and `m.rtc.encryption_key`
(alongside `io.element.call.encryption_keys`); events `m.rtc.decline`.
- **`sdk/main.ts`**: builds the driver from the widget client, a
`RtcParticipationManager`, and waits on `status$` instead of `JoinStateChanged`.
- **`component/dev` harness**: the two js-sdk drivers per pane.
### 4.6 The js-sdk drivers — two classes, two clients each
`src/driver/jsSdk/JsSdkRtcMatrixDriver.ts` (the crate seam, a port of the
draft's `jsSdkDriver.ts`, connectivity from `ClientEvent.Sync`) and
`JsSdkElementCallMatrixClientDriver.ts` (the §4.1 slices), both written so
that they work on a full `MatrixClient`
**and** on js-sdk's `RoomWidgetClient` (`node_modules/matrix-js-sdk/src/embedded.ts`).
Differences from the draft, all required by the widget client:
- to-device inbound: listen on `ClientEvent.ToDeviceEvent` (the widget client
never emits `ReceivedToDeviceMessage`, `embedded.ts:788-802`); on a full
client use `ReceivedToDeviceMessage` for the `encryptionInfo`.
- to-device outbound: always `encryptAndSendToDevice` (works without a crypto
backend on the widget client, `embedded.ts:598-612`; plain `sendToDevice`
there is **unencrypted**, `:614-619`).
- transports: `client._unstable_getRTCTransports()` (the widget override,
`embedded.ts:641-646`), never a raw `http.authedRequest`; `.well-known`
fallback only on a full client.
- event origins: on a full client from decryption metadata (as the draft); on
a widget client events arrive decrypted without metadata, so the driver
reports `Encrypted{ senderDeviceId: content.member.device_id }` for member
events and `Encrypted{ senderDeviceId: content.device_id }` for key
events, i.e. the _claimed_ trust level js-sdk applies today
(`ToDeviceKeyTransport.ts:133-140`). `getMatrixClientFeatures().verifiedEventOrigins`
says which.
- cross-signing verdict: `undefined` on a widget client
(`crossSigningVerdicts: false`); Element Call then forces
`requireCrossSignedSender = false`.
- delegation: two primitives and no policy. `delegateDelayedLeaveViaHomeserver`
is one `authedRequest` on a full client and `Unsupported` on a widget
client; `getLivekitToken` appends `delay_id`, `delay_timeout` and
`delay_cs_api_url` (`client.baseUrl`) when the request carries a
delegation. The crate decides when to call which (C5). Element Call carries no
delegation probe or policy.
- sticky listener attached after `startClient()` resolves (the widget room
only exists then, `embedded.ts:326`).
- `getLivekitToken` reuses today's request shapes (`slot_id: "m.call#ROOM"`,
legacy `/sfu/get`), errors mapped to `RtcError` incl. `M_LIMIT_EXCEEDED →
RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
---
## 5. Decisions and assumptions
1. **The SDK is an npm package.** `@element-hq/matrix-rtc` (the draft's
`web-test-app/`, built by `npm-web-bindings.yml`: uniffi bindings, the
wasm-bindgen glue, a `wasm-opt`'d ~760 KB wasm, `.d.ts`, and `@ubjs/core`
as its own dependency) is a devDependency, aliased in `package.json` to
`@billcarsonfr/matrix-rtc@next` while it is published from the fork, so
the imports already say `@element-hq/matrix-rtc`. It lives on the GitHub
Packages registry: `.npmrc` maps the scope, a `read:packages` token goes
in `~/.npmrc` (CI: the `MATRIX_RTC_NPM_TOKEN` secret). **The GitHub
Packages registry is a stop-gap** for the phase in which the crate's API
moves with every push; once the SDK is ready for consumers beyond Element
Call it belongs on npmjs.com as `@element-hq/matrix-rtc`, which drops the
alias, `.npmrc`, the CI secret and every token requirement
(`docs/matrix_rtc_sdk.md`). `src/matrix-rtc-sdk/`
keeps only the loader (`initMatrixRtcSdk`: package `initAsync` + our log
sink, wasm via `@element-hq/matrix-rtc/wasm?url`) and the curated
re-exports. _History:_ until 2026-09-16 the bindings were vendored under
`src/matrix-rtc-sdk/generated/` by `scripts/sync-matrix-rtc-sdk.sh`; local
crate work now goes through `pnpm links:on` (`docs/linking.md`).
2. **Wasm loading.** Verified: Vite 8 library mode inlines `?url` and
`new URL(…, import.meta.url)` assets as base64 regardless of
`assetsInlineLimit`; `?url&no-inline` emits a file. App builds use `?url`;
the component build uses `?url&no-inline` plus an `exports` entry for
`./dist/assets/*`, and `initializeElementCall(config, { matrixRtcWasm })`
lets a host point elsewhere. Wasm boot is **lazy** everywhere: in the app
`useRtcParticipationManager` awaits `initMatrixRtcSdk()` before constructing
the participation (so the js-sdk path never fetches it, §5.15), not the
`Initializer`; vitest reads the file from disk and only suites that need
it call `initMatrixRtcSdk()`, never in
`src/vitest.setup.ts`; Storybook boots it in `.storybook/preview.tsx`
`beforeAll`. Suites using `vi.useFakeTimers` never share a file with
real-wasm tests (pumps sleep on `setTimeout`).
3. **Own identity via the driver.** `userId`/`deviceId` are properties of the
driver, not props.
4. **One driver per room**, as in the crate.
5. **`matrix-js-sdk/lib/logger` stays** behind `src/utils/logger.ts`.
6. **`updateCallIntent` stays**, through the crate's `update_application` (C11).
7. **Reactions relate to the current own membership event id** (status quo);
reader keys by `memberId`. Alternatives (stable join event id, or
`memberId` as relation target) are protocol changes left to the user.
8. **MSC4153 default off** (`requireCrossSignedSender = false`) for every
host, confirmed: js-sdk performs no such check today, and a passwordless
SPA peer would otherwise be inaudible to everyone. The intent is to turn
it on; the default carries a TODO (`src/state/rtc/joinParams.ts`), and C10
puts the sender's verdict on the tile so the UI can show it meanwhile.
9. **Delegation is the crate's alone.** Element Call always asks for it; the
crate tries the CS API, then the authorisation service's token endpoint,
then its own restarts, and arms the long delegated leave only once
delegation is confirmed (C5, done). Element Call keeps no probe and no
delegation code; `delegated_delayed_leave.delay_ms` feeds
`FfiJoinParams.delegatedDelayMs`.
10. **Widget trust model:** origins synthesised from claimed device ids equal
today's js-sdk trust level; the crate records them as
`DeviceAttribution::Verified` because it cannot tell. Documented in the
driver; a `Claimed` attribution flag on the sink is a follow-up crate ask.
11. **Scratch files** go to `agent-workspace/oxidation/`; this plan lives at
the repo root because it was asked for by name.
12. **Branch** `toger5/oxidation`, one commit per slice.
13. **Compatibility mode has no slot.** Under `StateEvents` the crate projects
the session from the MSC3401 state events alone and requires the legacy
slot id `""` (`LEGACY_SLOT_ID` in `src/state/rtc/slot.ts`,
`slotIdForCompat`); `RtcParticipationManager` picks it from the config and skips
the slot check and the slot open. Found by the real-backend check: with
`m.call#ROOM` the crate saw its own legacy membership as a candidate but
never projected it. Element Call's own rooms already let every member send
the legacy member state event (`state_default: 0`); a plain room does not,
which the backend test reproduces.
14. **Member events in encrypted rooms are Megolm-encrypted** by matrix-js-sdk
(sticky events are timeline events; the SDK exempts only reactions and
redactions), as they are with the js-sdk MatrixRTC code today. The driver
decrypts them on the way in; the sticky marker (`msc4354_sticky`) stays in
the clear. To cross-check against Element X before relying on it.
15. **The two view models stay switchable for one release.** A developer
setting, `callViewModelImplementation` (`"matrix-js-sdk"` |
`"matrix-rtc"`, `src/settings/settings.ts`, next to `matrixRTCMode`),
picks between `createJsClientCallViewModel$` and the driver-based
`createCallViewModel$` in the same build. Like `matrixRTCMode` it is
shown in the Developer Settings tab, sampled when the call is joined
(switching mid-call needs a rejoin), and a deployment can pin it through
`config.json` (`call_view_model_implementation`), which overrides the
user's choice and greys the control out. The default is
`"matrix-js-sdk"` when the setting lands (S4a) and flips to
`"matrix-rtc"` once the S5 gate is green; S6 removes the setting together
with the js-sdk path. The point is that a broken call can be compared
against the old path in the same session, and that Playwright can run
both paths from one build. Everything the js-sdk path needs (the client,
the `MatrixRTCSession`, `ReactionsReader` over the session) therefore
stays reachable from `CallView` until S6, and the `RtcParticipationManager` is
created only when the crate path is selected, so neither path pays for
the other.
---
## 6. Work breakdown
Each slice is independently green (`pnpm lint && pnpm test:unit` at least).
### S0a — crate changes ☑ (in `MatrixSdkArchitectureDraft`)
C2–C12 from §3 (C1 reverted), each with a Rust unit test; acceptance tests in
`web-test-app/test/`: a slot-less room refuses joins until `openSlot`, in every dialect;
`FfiParticipationConfig` with `manageMediaKeys: false` exchanges no keys;
no `StickyEvents` variant left anywhere (C9); `member.eventId` present;
delegation tries the homeserver first, then the token endpoint with the delay fields, and keeps the short leave armed until one succeeds;
`ownTransportIdentity()` equals the joined membership's `transportIdentity`.
Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uniffi -- -D warnings`,
`npm run ubrn:web && npm test`.
### S0b — SDK intake ☑
- ~~`scripts/sync-matrix-rtc-sdk.sh`, `src/matrix-rtc-sdk/generated/**`~~
(replaced by the `@element-hq/matrix-rtc` package on 2026-09-16, §5.1),
`src/matrix-rtc-sdk/index.ts` (loader + curated re-exports),
`src/matrix-rtc-sdk/index.test.ts` (boot, one join round-trip against the
TS mock driver).
- `package.json` (~~`@ubjs/core`~~ now `@element-hq/matrix-rtc`; the
`generated/**` carve-outs in `knip.ts`, `.oxlintrc.json`, `.oxfmtrc.json`,
`vitest.config.ts` and `.gitattributes` went with the vendored files),
`vite.config.ts` (nothing needed for `?url`; verified `vite-plugin-wasm`
ignores it), `tsconfig.json` untouched thanks to the `.d.ts`.
- Gate: `pnpm lint && pnpm format:check && pnpm test:unit`.
### S1a — driver interface, mock driver ☑
- `src/driver/RtcMatrixDriver.ts`, `src/driver/ElementCallMatrixClientDriver.ts`,
`src/driver/observe.ts`, `MockRtcMatrixDriver.ts` (port of
`web-test-app/src/mockDriver.ts`) and `MockElementCallMatrixClientDriver.ts`
(in-memory room info, members, timeline, profile), with a test each.
- `knip.ts` `ignore` for `src/driver/**` until consumed, with the reason.
### S1b — `JsSdkMatrixDriver` ☑
- `src/driver/jsSdk/JsSdkRtcMatrixDriver.ts` and
`JsSdkElementCallMatrixClientDriver.ts` (§4.6) + tests against **two**
fakes: a `MatrixClient`-shaped one (`mockMatrixRoom`) and a
`RoomWidgetClient`-shaped one (`ToDeviceEvent`, no crypto,
`_unstable_getRTCTransports`, sticky updates after `startClient`).
Asserts request shapes of `_unstable_sendStickyEvent`,
`_unstable_sendStickyDelayedEvent`, `_unstable_updateDelayedEvent`,
`encryptAndSendToDevice`, `/get_token` body with and without the delegation fields, `delegateDelayedLeaveViaHomeserver` on both clients,
sink emission and origin synthesis, room-info/member updates.
### S2 — `RtcParticipationManager` ☑
- `src/state/rtc/RtcParticipationManager.ts`, `joinParams.ts`, `transportIntent.ts`,
`errors.ts` (cause → `ElementCallError`).
- `RtcParticipationManager.test.ts` through the real wasm + `MockMatrixDriver`:
memberships follow a remote join/leave; `LeftWithKeys` filtered; join →
`Connected`; `connections$` carries the token; `keyChanges$` fires for a
peer key; `ownTransportIdentity$` set before the echo; leave →
`Disconnected{LeftByHost}`; join → leave → join; scope end destroys the
manager; fallback transport when the host throws or advertises none.
### S3 — view model, four slices ☑ (side by side with the js-sdk view model)
- **Shape (2026-09-15):** the existing factory is renamed
`createJsClientCallViewModel$` and the new `createCallViewModel$(scope,
participation, clientDriver, …)` sits next to it; both build a
`CallViewModelCore` and hand it to the shared `assembleCallViewModel`, so
the layout/tile half is one piece of code and the two Matrix sides can be
reviewed side by side. `MatrixLivekitMember.membership$` is a neutral
`CallMember` (`userId`, `deviceId`, `memberId`, `rtcBackendIdentity`) that
js-sdk's `CallMembership` satisfies; `CallNotificationLifecycle` takes a
neutral `DeclineEvent`. Nothing js-sdk is deleted yet (that is S6).
- **S3a** `remoteMembers/ParticipationConnections.ts`: one `Connection` per
service URL in `participation.connections$`, started with the crate's
token; a refreshed token keeps the connection (used on the next
(re)connect). `Connection`/`ECConnectionFactory` accept a `null` client.
Test fakes live in `src/utils/test-participation.ts`
(`FakeParticipation`, `fakeMembership`, `fakeConnection`, `fakeMediaKey`).
- **S3b** `remoteMembers/ParticipationMembers.ts` (remote members matched by
`transportIdentity`, keyed by `memberId`; `callMemberOf`) and
`e2ee/participationKeyProvider.ts` (`keyMap$` × `memberships$` ×
`ownTransportIdentity$` → `onSetEncryptionKey`, once per (member, index,
identity); a key that arrives before the identity waits for the roster).
- **S3c** `localMember/LocalMedia.ts` is the LiveKit half extracted from
`LocalMember.ts` (publisher, tracks, screen share, upstream pausing, host
notify) and shared; `localMember/ParticipationLocalMember.ts` joins and
leaves through the participation (slot policy from `roomInfo`, custom
LiveKit URL as a `Publish` intent, `updateApplication` on camera toggle),
derives connected / reconnecting / `probablyLeft` from `status$`
(`HomeserverUnreachable` → "sync", `RestartFailing`/`Expired` →
"probablyLeft") and the fatal error from `errorForStatus`. Deletions
(`LocalTransport.ts`, `RtcTransportAutoDiscovery.ts`,
`HomeserverConnected.ts`, `openIDSFU.ts`, `enterRTCSession`) wait for S6.
- **S3d** `ParticipationCallNotification.ts` sends `org.matrix.msc4075.rtc.notification`
after our own echo when nobody was in the session before us (resets on
leave) and reads declines from the `TimelineDriver`;
`remoteMembers/ParticipationMemberMetadata.ts` adapts the client driver's
roster to `RoomMemberMap` so `createMatrixMemberMetadata$` and the ringing
name work unchanged. Still open: `ReactionsReader` on `participation` +
`TimelineDriver` (the new factory takes the hands/reactions observables
as inputs like the old one; S4b), `keyRotationSuppressed$` is `constant(false)`,
and the js-sdk test kit stays until S6. Tests:
`CallViewModel.participation.test.ts` (real wasm, mock drivers, mocked
LiveKit: join → connection → own tile → peer → leave; transport-missing →
`fatalError$`), plus one file per module.
### S4 — React tree, two slices ☑ (2026-09-16)
- **S4a-0, the switch (§5.15) ☑:** `CallViewModelImplementation` enum,
`callViewModelImplementation` setting (`src/settings/settings.ts`),
`call_view_model_implementation` pin (`ConfigOptions.ts`, validated in
`Config.ts`), the radio group in `DeveloperSettingsTab.tsx`, the
implementation logged at join and sent as `call_view_model_implementation`
in rageshakes, `effectiveCallViewModelImplementation()` in
`src/state/rtc/implementation.ts`. `CallView` samples it once per mount;
with `matrix-rtc` and no drivers it warns and lets matrix-js-sdk carry the
call. Still open from this item: the Playwright helper and the second CI
run.
- **S4a done so far:** `MatrixDriverProvider` / `useMatrixDrivers()`
(`src/driver/MatrixDriverContext.tsx`), provided by the component from its
props and by `RoomPage` through `useJsSdkDrivers(client, room)`;
`useRtcParticipationManager(drivers, config)` owned by `CallView`, created only
when the crate path is selected; `ActiveCall` takes `participation` and
builds either view model; the lobby's member count, the big-call auto-mute
and the error boundary's "were we joined" read from whichever side carries
the call; `window.matrixRtc = { participation }` next to `window.rtcSession`.
- (The original S4a-0 description follows for reference.) `callViewModelImplementation` setting and
`config.json` pin (`ConfigOptions.ts`, validated at load like
`matrix_rtc_mode`); a radio group in `DeveloperSettingsTab.tsx` beside the
MatrixRTC mode; `ActiveCall` reads the sampled value and calls either
factory — for `"matrix-rtc"` it takes the drivers from the
`MatrixDriverProvider` and the `RtcParticipationManager` from `CallView`, for
`"matrix-js-sdk"` it keeps `rtcSession`/`matrixRoom` as today. The chosen
implementation is logged at join and added to the rageshake fields so a
report says which path it came from. Playwright gets a helper that sets
the setting (local storage) before the call so every call spec can run
under both values; CI runs the suite twice until the default flips.
Lands first in S4a, before anything else in the tree moves, so that every
later S4 change is verifiable against the old path.
- **S4a** views/hooks/settings on the driver: `CallView.tsx` (owns
`RtcParticipationManager` when the crate path is selected), `InCallView.tsx`, `LobbyView.tsx`, `CallEndedView.tsx`,
`VideoPreview.tsx`, `useRoomInfo()` (replaces `useRoomName/Avatar/JoinRule/State`),
`InviteModal.tsx`, `Avatar.tsx`, `useOwnProfile.ts`, `ProfileSettingsTab.tsx`,
`SettingsModal.tsx`, `DeveloperSettingsTab.tsx`, `submit-rageshake.ts`,
`analytics/PosthogEvents.ts`, `controls.ts`, and a
first `CallView.stories.tsx` (lobby, in call, ended) driven by
`MockMatrixDriver`.
- **S4b ☑ (2026-09-16):** `ReactionsSenderProvider` takes `ownIdentifier`,
`ownMembershipEventId` and a `ReactionsTimeline` (`jsSdkReactionsTimeline`
over a client, or the client driver); `ParticipationReactionsReader`
(`src/reactions/`) reads hands and reactions from the participation and
the timeline driver, keyed by the member's media id
(`memberMediaId`, `src/state/rtc/mediaId.ts`), and re-resolves a hand on a
re-sent membership instead of dropping it blindly. Notifications went
through the driver in S3d. Tests: `ParticipationReactionsReader.test.ts`,
`useRtcParticipationManager.test.tsx`, three `CallView.test.tsx` cases for the
switch.
- **S4a views on the drivers (2026-09-16):** `CallView` now requires the
drivers (`useMatrixDrivers()`; both hosts provide them) and reads the
room through `useRoomInfo()` (`src/room/useRoomInfo.ts`; `useRoomAvatar`,
`useJoinRule`, `useRoomState` deleted, `useRoomName` stays for `RoomPage`),
our own profile through `useOwnProfile()` (`src/profile/useOwnProfile.ts`)
and the encryption system through `useEncryptionSystemFor(roomId,
roomInfo.encrypted)` (`useRoomEncryptionSystem` keeps the client for the
home page). `MatrixInfo` is built from those. `InviteModal` takes
`roomId`/`roomName`/`e2eeSystem`; `CallEndedView` lost its `client` prop;
`Avatar` resolves thumbnails through `clientDriver.thumbnailUrl` when
drivers are provided (host `downloadMedia` first, client fallback for the
shell); rageshakes carry the driver's `getDiagnostics()` as `driver_*`
fields and the crate's `matrix_rtc_snapshot`.
- **The component is client-free (2026-09-16):** `CallView`'s `client` and
`rtcSession` are optional; without them the crate carries the call
whatever the setting says (`useMatrixRtc = setting || no session`), and
every js-sdk-only piece (the `MembershipManagerError` listener, the room
sanity check, `ReactionsReader`, `mediaKeyStatisticsOf`) is skipped.
`ActiveCall`/`InCallView` take `roomId` instead of `matrixRoom`, and
`client`/`rtcSession` optionally. `ProfileSettingsTab` edits through the
client driver's optional `setDisplayName`/`setAvatar(file | null)` and
shows read-only fields without them; `DeveloperSettingsTab` reads the
sticky probe from `getMatrixClientFeatures()`, the crypto version from
`getDiagnostics()` and validates a custom LiveKit URL through
`rtcDriver.getLivekitToken` when there is no client; the rageshake request
event goes through the client driver's timeline; reactions are supported
unless a client state forbids them. `ElementCall` renders no
`ClientProvider` and no shim — it hands its two drivers down and nothing
below asks for a client; `initializeElementCall(config, { matrixRtcWasm })`
can preload the wasm. `PosthogEvents.eventCallEnded.track` takes
`MediaKeyStatistics` (from the session on the js-sdk path, zero on the
crate path until the crate counts). `useTypedEventEmitter` and the js-sdk
client driver's public `client`/`room` are gone. **Consequence:** the
component and its dev harness (`ElementCallClientBased`) now run every
call on the crate.
- **Banner dropped (2026-09-17):** the disconnected banner, its
`useHomeserverConnected` hook and the client state's `disconnected` flag
are gone. The local membership already carries the connection state, so
the reconnecting overlay covers the in-call case where it matters; in the
lobby the banner added little. `HomeserverConnected` in
`state/CallViewModel/localMember/` remains the one consumer of
`rtcDriver.isHomeserverConnected()` / `subscribeConnectivity` and of
`sync_disconnect_grace_period_ms`. The mock RTC driver keeps several
connectivity sinks (the crate's and the UI's).
- **S4 closed (2026-09-16):** `CallView.stories.tsx` (Lobby with a peer,
NoTransport as the error path, Ended) over the mock drivers, no client;
`.storybook/preview.tsx` initialises the config; the Storybook vitest
project passes. The Playwright switch is `CALL_VIEW_MODEL_IMPLEMENTATION`
read by `playwright.config.ts` into `use.storageState` (the developer
setting in local storage for every context), and the CI Playwright job is
a matrix over both implementations (`matrix-rtc` non-blocking until it
has passed once). `RtcParticipationManager.mediaKeyStatistics()` counts sent
and received keys and their age from the crate's key changes for the
ended-call event. Found on the way: `CallView` rendered `ActiveCall`
before the participation existed (wasm loads on first use) — it now waits;
and `onLeft` had gained the member count as a dependency, which rebuilt
the view model on every roster change on both paths — read through
`useLatest` now. `create-call.spec.ts` passes in Chromium on both
implementations with two view model creations each (StrictMode): the
crate path's first full call in a browser.
### S5 — hosts ☐ (component props done 2026-09-15)
- **Done:** `ElementCallProps` takes `rtcDriver` + `clientDriver` (`roomId`
optional, asserted equal); `ElementCallClientBased` takes `client` +
`roomId` and builds the two js-sdk drivers (the dev harness uses it); the
driver types and the js-sdk drivers are exported from the component index.
**Temporary:** until S4 the tree under `CallView` still runs on the
client, so `ElementCall` requires the client driver to be
`JsSdkElementCallMatrixClientDriver` (its `client`/`room` are public for
this) and throws for any other driver.
- `component/matrix-js-sdk.ts`, `component/package.json`
(`exports`, peers), `vite-component.config.ts` (two entries, `fileName`
function, externals shrink, `?url&no-inline`), `component/tsconfig.build.json`,
`component/dev/Harness.tsx`, `README.md`.
- `src/room/useLoadGroupCall.ts`, `RoomPage.tsx`, `src/widget.ts`
(capabilities, §4.5), `sdk/main.ts`, `playwright/spa-helpers.ts`
(delegation route helper), `docs/` config notes.
- Gate: all four builds; Playwright standalone + widget + component against
`pnpm backend`; a widget media-key round trip and `reconnect.spec.ts`
re-checked under the new delegation path.
### S6 — fence and cleanup ☐
- `src/utils/logger.ts`; local `CallIntent` type for the six `RTCCallIntent`
users; `useLocalStorage.ts` on a local emitter; oxlint
`no-restricted-imports` scoped to the call tree (allow-list: `src/home`,
`src/auth`, `src/utils/spa.ts`, `src/utils/matrix.ts`, `src/driver/jsSdk/**`,
`src/ClientContext.tsx`, `src/widget.ts`, `src/initializer.tsx`,
`src/IndexedDBWorker.ts`, `src/room/KnockLobbyView.tsx`, `src/settings/rageshake.ts`)
banning `matrix-js-sdk` except `matrix-js-sdk/lib/logger`.
- `ServiceInterruptionsViewModel` fed from `status$.impairments`.
- `callViewModelImplementation` setting, its `config.json` pin and the
Developer Settings control removed with `createJsClientCallViewModel$`
(§5.15); Playwright runs the suite once again.
- `docs/agents/architecture.md`, `docs/matrix_rtc_modes.md` updated;
`src/@types/matrix-js-sdk.d.ts` removed if nothing merges into js-sdk types.
---
## 7. Risks and mitigations
| Risk | Mitigation |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| A user without the power level to open a slot, in a room that never had a call | `NoOpenSlotError` names the remedy; a room admin opens the slot by starting the first call |
| A client on the removed sticky dialect (none is deployed) | Not supported: `Off` and `StateEvents` are the only dialects (C9) |
| Component bundle size / wasm delivery | `?url&no-inline` + `exports` wildcard + `matrixRtcWasm` override; verified in S0b and S5 |
| Token refresh vs LiveKit reconnect | `Connection.token$`; full reconnect uses the latest token; `expiresAtTs` logged |
| Own identity before the echo | C6 export; JWT `sub` only as a logged cross-check |
| Keys before identity | key provider buffers per member id and replays |
| Widget mode regressions (origins, to-device event, capabilities) | S1b tests against a `RoomWidgetClient` fake; S5 widget e2e adds a media-key round trip |
| Delegation fails after the long leave was armed | Arm-after-confirm (C5): the short leave stays armed until delegation is confirmed |
| Behaviour drift in keep-alive (6 s vs 4 s restarts) and dropped config keys | documented in `docs/` |
| Test time: wasm per file | lazy boot in the suites that need it only |
---
## 8. Verification matrix
| Check | S0a | S0b | S1 | S2 | S3 | S4 | S5 | S6 |
| ---------------------------------------------------------------------------- | --- | --- | --- | --- | --- | --- | --- | --- |
| `cargo test/clippy`, `web-test-app` suites | ● | | | | | | | |
| `pnpm lint` + `format:check` | | ● | ● | ● | ● | ● | ● | ● |
| `pnpm test:unit` | | ● | ● | ● | ● | ● | ● | ● |
| `pnpm test:storybook` | | | | | | ● | ● | ● |
| four builds | | ● | | | | | ● | ● |
| Playwright standalone + widget + component | | | | | | | ● | ● |
| Manual: two harness panes hear each other, E2EE, hand raise, reaction, leave | | | | | | | ● | |
| Real backend (`RtcParticipationManager.backend.test.ts`, opt-in, both modes) | | | | ● | | | ● | |
---
## 9. Open questions for the user (answered by the assumptions in §5 until told otherwise)
1. Slot semantics, **answered**: no slot means no call; Element Call opens the
slot on the first call in a room, power level permitting.
2. Delegation, **answered**: CS API endpoint first, then Element Call's
OpenID → JWT → scheduled-event path, all inside the crate and invisible to
Element Call (C5).
3. Media-key type under the sticky compat, **answered**: there are no deployed
sticky-event clients, so the compat mode is removed (C9) and the question
with it.
4. MSC4153, **answered**: off everywhere for now, with a TODO to turn it on
and C10 so tiles show unverified senders in the meantime.
5. Reactions relation target: current membership event id (taken), stable join
event id, or `memberId`?
6. Committing the 6.5 MB wasm (taken) vs a build-time fetch.
7. `roomId` prop: optional for one release (taken) vs removed outright.
8. `updateCallIntent`, **answered**: added to the crate as `update_application`
(C11); Element Call keeps the behaviour.
---
## 10. Review log
Independent review findings incorporated in this revision: slot enforcement
blocker (C1); delegation protocol and 1 h arm (C5, §5.9; later redefined as crate-only with arm-after-confirm); `StickyEvents` key
type interop (C3, then made moot by removing the mode, C9); MSC4153 default (§5.8); widget-client differences for the
js-sdk drivers (§4.6) and missing widget capabilities (§4.5); own identity
export (C6); `RtcParticipationManager` lifetime at `CallView` (§4.2); notification
timing and content (§4.3); reactions/event-id semantics (§5.7); config mapping
defaults and dropped keys (§4.3); `bigint`/`ArrayBuffer` types; inventory
gaps (§2); knip `ignore` vs `ignoreFiles`, oxlint/oxfmt ignores, `.d.ts` for
the glue, lazy wasm boot, Storybook `beforeAll`; slice re-cut (S0a/b, S1a/b,
S3a–d, S4a/b, temporary `CallView` shim); `sdk/main.ts` status wiring and the
Playwright delegation helper.
**Real-backend check (2026-09-15), `pnpm backend` + `src/state/rtc/RtcParticipationManager.backend.test.ts`**
(`MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 pnpm vitest run --project unit src/state/rtc/RtcParticipationManager.backend.test.ts`;
two registered users with rust crypto in an encrypted room, `matrix_2_0` and
`compatibility`): passes end to end — transport discovery from
`/rtc/transports`, slot open + echo, sticky member event with `msc4354_sticky`,
delayed leave, delegation via the homeserver, roster with display names,
Olm-encrypted media keys both ways with `senderCrossSigned`, leave cancelling
the delayed event, and `HomeserverUnreachable` raised and cleared across a
simulated network outage. Fixed on the way: MSC4195 `member` claims and the
homeserver route's body (`url`, `delay_timeout`; C5), the missing session
listener (C13), the legacy slot id under compatibility mode (§5.13). Noted:
`vite-plugin-node-polyfills` shadows `process`, so tests read the environment
through `node:process`; Synapse develop already proxies the MSC4195 endpoint
to lk-jwt-service; member events are Megolm-encrypted in encrypted rooms
(§5.14). Not verified here: the widget host path (Element Web) and the
authorisation-service route (the homeserver route is taken first on this
stack; it is covered by unit tests only).
**S3 (2026-09-15):** the driver-based `createCallViewModel$` landed next to
the renamed `createJsClientCallViewModel$` (shared `assembleCallViewModel`,
neutral `CallMember`/`DeclineEvent`, `LocalMedia.ts` extracted); component
props moved to the two drivers with `ElementCallClientBased` on top. Gates:
`pnpm lint`, `format:check`, `test:unit` (803), `i18n:check`,
`build:component` green. Open from this slice: reactions reader on the
participation (S4b), the `?url&no-inline` wasm asset for the component build
(the wasm is loaded lazily and inlined into the component bundle today), and
the S6 deletions.
**S4 (2026-09-16):** the implementation switch (§5.15) is in with its
config pin, developer control and rageshake field; `CallView` owns a
`RtcParticipationManager` when the crate path is selected and `ActiveCall` builds
the matching view model; the reactions reader and sender have participation
and driver counterparts. Both paths run in the same build. Gates: `pnpm
lint`, `format:check`, `i18n:check`, `test:unit` (815), `build:component`
green. Not verified: a real call on the crate path through the UI (the
`pnpm backend` check covers the view model's Matrix side; the browser run is
the S5 gate).
**Logging (2026-09-16):** C14 — the crate's log lines reach the host through
a `LogSink`; Element Call routes them to its rageshake logger under
`[matrix-rtc]`, the web-test-app to the console. Verified by a unit test that
sees the crate's "session created" line through the sink.
**S4 views (2026-09-16):** room info, own profile, encryption system,
avatars, the invite and ended views and the rageshake fields read the
drivers; `CallView` requires drivers. Three room hooks deleted. Gates:
`pnpm lint`, `format:check`, `test:unit` (819), `build:component` green.
**Client-free component (2026-09-16):** `CallView` and everything under it
run without a matrix-js-sdk client on the crate path; the component passes
only its drivers. Gates: `pnpm lint`, `format:check`, `test:unit` (819),
`build:component` green. Not yet done: a browser run of the component on
the crate path (the dev harness now is that run).
**S4 done (2026-09-16):** stories, Playwright switch and CI matrix, key
statistics. `create-call.spec.ts` green on `matrix-js-sdk` and `matrix-rtc`
in Chromium against the dev server; the rest of the suite under
`matrix-rtc` is the S5 gate. Gates: `pnpm lint`, `format:check`,
`test:unit` (822), `test:storybook` (28), `build:component` green.
+6
View File
@@ -26,6 +26,12 @@ export default {
// This is a shell built-in.
"printf",
],
ignore: [
// The host-facing driver seam, landed ahead of its consumers (the
// `CallParticipation` layer and the hosts, see element-call-oxidation-plan.md)
"src/driver/**",
"src/state/rtc/**",
],
ignoreFiles: [
"scripts/.pnpmfile.cjs",
// Deliberately added prior to any component or business logic
+14 -1
View File
@@ -58,6 +58,18 @@
},
"developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"callViewModelImplementation": {
"forced": "Your deployment pins the call implementation.",
"matrix_js_sdk": {
"description": "The MatrixRTC session of matrix-js-sdk carries the call, as before",
"label": "matrix-js-sdk"
},
"matrix_rtc": {
"description": "The Rust matrix-rtc crate carries the call through the host's drivers",
"label": "matrix-rtc (Rust)"
},
"title": "Call implementation"
},
"crypto_version": "Crypto version: {{version}}",
"custom_livekit_url": {
"current_url": "Currently set to: ",
@@ -90,7 +102,6 @@
"show_connection_stats": "Show connection statistics",
"url_params": "URL parameters"
},
"disconnected_banner": "Connectivity to the server has been lost.",
"error": {
"call_is_not_supported": "Call is not supported",
"call_not_found": "Call not found",
@@ -110,6 +121,8 @@
"membership_manager": "Membership Manager Error",
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consecutive failed network requests.",
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
"no_open_slot": "No call to join here",
"no_open_slot_description": "Nobody has started a call in this room yet, and you do not have permission to start one. Ask a room admin to start the call, or to allow you to start calls.",
"open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"peer_connection_timeout": "Connection timeout",
+1
View File
@@ -48,6 +48,7 @@
},
"devDependencies": {
"@codecov/vite-plugin": "^1.3.0",
"@element-hq/matrix-rtc": "npm:@billcarsonfr/matrix-rtc@next",
"@fontsource/inconsolata": "^5.1.0",
"@fontsource/inter": "^5.1.0",
"@formatjs/intl-durationformat": "^0.10.0",
+27
View File
@@ -19,6 +19,32 @@ const baseURL = process.env.USE_DOCKER
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Which MatrixRTC implementation carries the calls under test, `matrix-js-sdk`
* or `matrix-rtc` (see `CallViewModelImplementation` in
* src/config/ConfigOptions.ts). Unset, Element Call's default applies. Set,
* every browser context starts with the developer setting in local storage,
* so the whole suite runs on that implementation.
*/
const callViewModelImplementation = process.env.CALL_VIEW_MODEL_IMPLEMENTATION;
const storageState =
callViewModelImplementation === undefined
? undefined
: {
cookies: [],
origins: [
{
origin: baseURL,
localStorage: [
{
name: "matrix-setting-call-view-model-implementation",
value: JSON.stringify(callViewModelImplementation),
},
],
},
],
};
// Needed by the synapse admin API called in fixtures
process.env.NODE_EXTRA_CA_CERTS = join(
__dirname,
@@ -44,6 +70,7 @@ export default defineConfig({
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL,
storageState,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
+20
View File
@@ -25,6 +25,9 @@ importers:
'@codecov/vite-plugin':
specifier: ^1.3.0
version: 1.9.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
'@element-hq/matrix-rtc':
specifier: npm:@billcarsonfr/matrix-rtc@next
version: '@billcarsonfr/matrix-rtc@0.1.0-next.7.c5a5a80(matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a)'
'@fontsource/inconsolata':
specifier: ^5.1.0
version: 5.3.0
@@ -409,6 +412,13 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@billcarsonfr/matrix-rtc@0.1.0-next.7.c5a5a80':
resolution: {integrity: sha512-6Ti4jQZahHPI2Y9F/6dUplSEvoDdWP0OTLJXFUe708YFa13nRwW3t7QoRRbsSB5O0eWQHwu9hHVJQHEjpx4EwA==, tarball: https://npm.pkg.github.com/download/@billcarsonfr/matrix-rtc/0.1.0-next.7.c5a5a80/55919d699c12752b2316ad6d97a00c4b78088057}
version: 0.1.0-next.7.c5a5a80
engines: {node: '>=20'}
peerDependencies:
matrix-js-sdk: '>=42'
'@blazediff/core@1.9.1':
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
@@ -3218,6 +3228,9 @@ packages:
resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ubjs/core@0.31.0-5':
resolution: {integrity: sha512-oRBRtyYOhaodiOY3rLMZFgAHGFMmD44XefMpsAx4Ja0/rO+45kVdQB1VZsblQMLYxP965i3IHm56eymgo3I5eQ==}
'@ungap/structured-clone@1.3.3':
resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
@@ -6359,6 +6372,11 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@billcarsonfr/matrix-rtc@0.1.0-next.7.c5a5a80(matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a)':
dependencies:
'@ubjs/core': 0.31.0-5
matrix-js-sdk: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
'@blazediff/core@1.9.1': {}
'@bufbuild/protobuf@1.10.1': {}
@@ -8686,6 +8704,8 @@ snapshots:
'@typescript-eslint/types': 8.69.0
eslint-visitor-keys: 5.0.1
'@ubjs/core@0.31.0-5': {}
'@ungap/structured-clone@1.3.3': {}
'@use-gesture/core@10.3.1': {}
+2
View File
@@ -3,6 +3,8 @@ supportedArchitectures:
cpu: [current, arm64]
libc: [current, glibc]
minimumReleaseAgeExclude:
# The MatrixRTC SDK is consumed as `next` pre-releases straight from its CI
- "@billcarsonfr/matrix-rtc"
- "@vector-im/compound-design-tokens"
- "@vector-im/compound-web"
- matrix-js-sdk
+5 -5
View File
@@ -31,7 +31,6 @@ import {
tap,
} from "rxjs";
import {
type CallMembership,
MatrixRTCSessionEvent,
MatrixRTCSessionManager,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -48,7 +47,7 @@ import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/roo
import { type Behavior, constant } from "../src/state/Behavior";
import {
callViewModelOptionsFromParams,
createCallViewModel$,
createJsClientCallViewModel$,
} from "../src/state/CallViewModel/CallViewModel";
import { ObservableScope } from "../src/state/ObservableScope";
import { getUrlParams } from "../src/UrlParams";
@@ -59,6 +58,7 @@ import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { initializeWidget } from "../src/widget";
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
import { type CallMember } from "../src/state/CallViewModel/remoteMembers/MatrixLivekitMembers";
import { createWidgetHostBridge } from "../src/HostBridge";
import { observeElementSize$ } from "../src/utils/elementSize";
@@ -83,7 +83,7 @@ interface MatrixRTCSdk {
remoteMembers$: Behavior<
{
connection: Connection | null;
membership: CallMembership;
membership: CallMember;
participant: LocalParticipant | RemoteParticipant | null;
}[]
>;
@@ -92,7 +92,7 @@ interface MatrixRTCSdk {
*/
localMember$: Behavior<{
connection: Connection | null;
membership: CallMembership;
membership: CallMember;
participant: LocalParticipant | null;
} | null>;
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
@@ -142,7 +142,7 @@ export async function createMatrixRTCSdk(
);
// call view model
const callViewModel = createCallViewModel$(
const callViewModel = createJsClientCallViewModel$(
scope,
rtcSession,
room,
-1
View File
@@ -29,7 +29,6 @@ const TestComponent: FC<
<ClientContextProvider
value={{
state: "valid",
disconnected: false,
supportedFeatures: {
reactions: true,
},
+28 -9
View File
@@ -16,6 +16,7 @@ import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { useClientState } from "./ClientContext";
import { useOptionalMatrixDrivers } from "./driver/MatrixDriverContext";
import { useHostBridge } from "./HostBridge";
export enum Size {
@@ -76,6 +77,9 @@ export const Avatar: FC<Props> = ({
}) => {
const clientState = useClientState();
const hostBridge = useHostBridge();
// Under a call the host's client driver resolves media; the shell outside
// a call still has only the client.
const drivers = useOptionalMatrixDrivers();
const sizePx = useMemo(
() =>
@@ -95,16 +99,30 @@ export const Avatar: FC<Props> = ({
return;
}
let blob: Promise<Blob>;
let url: Promise<string | null>;
if (hostBridge.downloadMedia) {
blob = hostBridge.downloadMedia(src);
url = hostBridge
.downloadMedia(src)
.then((blob) => URL.createObjectURL(blob));
} else if (drivers !== null) {
const px = Math.floor(sizePx * window.devicePixelRatio);
url = drivers.clientDriver.thumbnailUrl(
src,
px,
px,
sizePx <= 96 ? "crop" : "scale",
);
} else if (
clientState?.state === "valid" &&
clientState.authenticated?.client &&
sizePx
) {
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
url = getAvatarFromServer(
clientState.authenticated.client,
src,
sizePx,
).then((blob) => URL.createObjectURL(blob));
} else {
setAvatarUrl(undefined);
return;
@@ -112,13 +130,14 @@ export const Avatar: FC<Props> = ({
let objectUrl: string | undefined;
let stale = false;
blob
.then((blob) => {
if (stale) {
url
.then((resolved) => {
if (stale || resolved === null) {
return;
}
objectUrl = URL.createObjectURL(blob);
setAvatarUrl(objectUrl);
// Only what we created (or the driver created for us) is ours to revoke.
if (resolved.startsWith("blob:")) objectUrl = resolved;
setAvatarUrl(resolved);
})
.catch((ex) => {
if (stale) {
@@ -133,7 +152,7 @@ export const Avatar: FC<Props> = ({
URL.revokeObjectURL(objectUrl);
}
};
}, [clientState, hostBridge, src, sizePx]);
}, [clientState, hostBridge, drivers, src, sizePx]);
return (
<CompoundAvatar
+2 -32
View File
@@ -17,8 +17,7 @@ import {
type JSX,
} from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
import { type MatrixClient } from "matrix-js-sdk";
import { ErrorPage } from "./FullScreenView";
import { useHostBridge } from "./HostBridge";
@@ -42,9 +41,6 @@ export type ClientState = ValidClientState | ErrorState;
export type ValidClientState = {
state: "valid";
authenticated?: AuthenticatedClient;
// 'Disconnected' rather than 'connected' because it tracks specifically
// whether the client is supposed to be connected but is not
disconnected: boolean;
supportedFeatures: {
reactions: boolean;
};
@@ -276,7 +272,6 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
}, [initClientState?.client, setAlreadyOpenedErr]),
);
const [isDisconnected, setIsDisconnected] = useState(false);
const [supportsReactions, setSupportsReactions] = useState(false);
const state: ClientState | undefined = useMemo(() => {
@@ -300,7 +295,6 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
state: "valid",
authenticated,
setClient,
disconnected: isDisconnected,
supportedFeatures: {
reactions: supportsReactions,
},
@@ -311,17 +305,9 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
initClientState,
logout,
setClient,
isDisconnected,
supportsReactions,
]);
const onSync = useCallback(
(state: SyncState, _old: SyncState | null, data?: ISyncStateData) => {
setIsDisconnected(clientIsDisconnected(state, data));
},
[],
);
useEffect(() => {
if (!initClientState) {
return;
@@ -333,20 +319,10 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
if (PosthogAnalytics.hasInstance())
PosthogAnalytics.instance.onLoginStatusChanged();
if (initClientState.client) {
initClientState.client.on(ClientEvent.Sync, onSync);
}
if (!hostBridge.supportsReactions)
logger.warn("The host does not permit reactions");
setSupportsReactions(hostBridge.supportsReactions);
return (): void => {
if (initClientState.client) {
initClientState.client.removeListener(ClientEvent.Sync, onSync);
}
};
}, [initClientState, onSync, hostBridge]);
}, [initClientState, hostBridge]);
if (alreadyOpenedErr) {
return <ErrorPage error={alreadyOpenedErr} />;
@@ -388,9 +364,3 @@ const loadSession = (): Session | undefined => {
return JSON.parse(data);
};
const clientIsDisconnected = (
syncState: SyncState,
syncData?: ISyncStateData,
): boolean =>
syncState === "ERROR" && syncData?.error?.name === "ConnectionError";
-18
View File
@@ -1,18 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.banner {
position: absolute;
padding: 29px;
background-color: var(--cpd-color-bg-subtle-primary);
vertical-align: middle;
font-size: var(--font-size-body);
text-align: center;
z-index: 1;
top: 76px;
width: calc(100% - 58px);
}
-44
View File
@@ -1,44 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import { type FC, type HTMLAttributes, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import styles from "./DisconnectedBanner.module.css";
import { type ValidClientState, useClientState } from "./ClientContext";
interface Props extends HTMLAttributes<HTMLElement> {
children?: ReactNode;
className?: string;
}
export const DisconnectedBanner: FC<Props> = ({
children,
className,
...rest
}) => {
const { t } = useTranslation();
const clientState = useClientState();
let shouldShowBanner = false;
if (clientState?.state === "valid") {
const validClientState = clientState as ValidClientState;
shouldShowBanner = validClientState.disconnected;
}
return (
<>
{shouldShowBanner && (
<div className={classNames(styles.banner, className)} {...rest}>
{children}
{t("disconnected_banner")}
</div>
)}
</>
);
};
+7 -18
View File
@@ -23,38 +23,27 @@ import { Avatar, Size } from "./Avatar";
import { EncryptionLock } from "./room/EncryptionLock";
import { useRootSizeMatches } from "./useRootSize";
import { useLeaveToHome } from "./LeaveToHomeContext";
import { DisconnectedBanner } from "./DisconnectedBanner";
interface HeaderProps extends HTMLAttributes<HTMLElement> {
ref?: Ref<HTMLElement>;
children: ReactNode;
className?: string;
/**
* Whether the header should display an informational banner whenever the
* client is disconnected from the homeserver.
* @default true
*/
disconnectedBanner?: boolean;
}
export const Header: FC<HeaderProps> = ({
ref,
children,
className,
disconnectedBanner = true,
...rest
}) => {
return (
<>
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
{disconnectedBanner && <DisconnectedBanner />}
</>
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
);
};
+8 -7
View File
@@ -19,6 +19,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { PosthogAnalytics } from "./PosthogAnalytics";
import {
mediaKeyStatisticsOf,
CallEndedTracker,
CallReconnectingTracker,
type CallReconnectingReason,
@@ -67,7 +68,7 @@ describe("CallEnded", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.track("test-call-id", 2, false, mockSession);
tracker.track("test-call-id", 2, false, mediaKeyStatisticsOf(mockSession));
expect(warnSpy).toHaveBeenCalledWith(
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
@@ -81,7 +82,7 @@ describe("CallEnded", () => {
tracker.cacheStartCall(new Date(Date.now() - 60000));
tracker.cacheParticipantCountChanged(5);
tracker.track("test-call-id", 3, true, mockSession);
tracker.track("test-call-id", 3, true, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
{
@@ -111,7 +112,7 @@ describe("CallEnded", () => {
tracker.cacheParticipantCountChanged(3);
tracker.cacheParticipantCountChanged(7);
tracker.cacheParticipantCountChanged(2);
tracker.track("test-call-id", 1, false, mockSession);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -128,7 +129,7 @@ describe("CallEnded", () => {
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -146,7 +147,7 @@ describe("CallEnded", () => {
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -161,7 +162,7 @@ describe("CallEnded", () => {
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.anything(),
@@ -178,7 +179,7 @@ describe("CallEnded", () => {
tracker.cacheReconnecting("sync");
tracker.cacheReconnecting("livekit");
tracker.cacheReconnecting("membership");
tracker.track("test-call-id", 1, false, mockSession);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
+32 -10
View File
@@ -15,6 +15,34 @@ import {
RegistrationType,
} from "./PosthogAnalytics";
/** How many media keys went out and came in over a call, for the ended event. */
export interface MediaKeyStatistics {
sent: number;
received: number;
/** Sum of the ages of the received keys, in ms; averaged on send. */
receivedTotalAge: number;
}
/** No key statistics (the Rust crate does not count them yet). */
export const NO_MEDIA_KEY_STATISTICS: MediaKeyStatistics = {
sent: 0,
received: 0,
receivedTotalAge: 0,
};
/** {@link MediaKeyStatistics} from a matrix-js-sdk session. */
export function mediaKeyStatisticsOf(
rtcSession: MatrixRTCSession,
): MediaKeyStatistics {
const { counters, totals } = rtcSession.statistics;
return {
sent: counters.roomEventEncryptionKeysSent,
received: counters.roomEventEncryptionKeysReceived,
// Only meaningful with received keys; a mocked session may not carry it.
receivedTotalAge: totals?.roomEventEncryptionKeysReceivedTotalAge ?? 0,
};
}
interface CallEnded extends IPosthogEvent {
eventName: "CallEnded";
// the callId posthog key is essentially a Matrix roomId
@@ -80,7 +108,7 @@ export class CallEndedTracker {
callId: string,
callParticipantsNow: number,
sendInstantly: boolean,
rtcSession: MatrixRTCSession,
keys: MediaKeyStatistics,
): void {
if (this.cache.startTime) {
PosthogAnalytics.instance.trackEvent<CallEnded>(
@@ -90,16 +118,10 @@ export class CallEndedTracker {
callParticipantsMax: this.cache.maxParticipantsCount,
callParticipantsOnLeave: callParticipantsNow,
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
roomEventEncryptionKeysSent:
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
roomEventEncryptionKeysReceived:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
roomEventEncryptionKeysSent: keys.sent,
roomEventEncryptionKeysReceived: keys.received,
roomEventEncryptionKeysReceivedAverageAge:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
? rtcSession.statistics.totals
.roomEventEncryptionKeysReceivedTotalAge /
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
: 0,
keys.received > 0 ? keys.receivedTotalAge / keys.received : 0,
callReconnectingCount: this.cache.reconnectingCount,
callReconnectingCountSync: this.cache.reconnectingCountByReason.sync,
callReconnectingCountMembership:
+10 -2
View File
@@ -17,7 +17,10 @@ import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { type MockRTCSession } from "../utils/test";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import {
jsSdkReactionsTimeline,
ReactionsSenderProvider,
} from "../reactions/useReactionsSender";
import { initializeWidget } from "../widget";
initializeWidget();
vi.mock("livekit-client/e2ee-worker?worker");
@@ -35,7 +38,12 @@ function TestComponent({
<TooltipProvider>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession.asMockedSession()}
ownIdentifier={localIdent}
ownMembershipEventId={localRtcMember.eventId}
timeline={jsSdkReactionsTimeline(
rtcSession.room.client,
rtcSession.room.roomId,
)}
>
<ReactionToggleButton
reactionData={{
+23 -1
View File
@@ -9,7 +9,11 @@ import { describe, expect, it, vi, afterEach } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { Config, validateConfig } from "./Config";
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
import {
CallViewModelImplementation,
DEFAULT_CONFIG,
MatrixRTCMode,
} from "./ConfigOptions";
describe("validateConfig", () => {
afterEach(() => {
@@ -101,3 +105,21 @@ describe("Config.initWith", () => {
expect(Config.get().ssla).toBe("https://second.invalid/ssla");
});
});
describe("validateConfig call_view_model_implementation", () => {
it.each(Object.values(CallViewModelImplementation))(
"keeps a valid call_view_model_implementation value (%s)",
(value) => {
const result = validateConfig({ call_view_model_implementation: value });
expect(result.call_view_model_implementation).toBe(value);
},
);
it("drops an unknown call_view_model_implementation value", () => {
const result = validateConfig({
call_view_model_implementation:
"yes-please" as unknown as CallViewModelImplementation,
});
expect(result.call_view_model_implementation).toBeUndefined();
});
});
+14 -1
View File
@@ -15,11 +15,14 @@ import {
type ResolvedConfigOptions,
} from "./ConfigOptions";
import { isFailure } from "../utils/fetch";
import { MatrixRTCMode } from "./ConfigOptions";
import { CallViewModelImplementation, MatrixRTCMode } from "./ConfigOptions";
const VALID_MATRIX_RTC_MODES: ReadonlySet<string> = new Set(
Object.values(MatrixRTCMode),
);
const VALID_CALL_VIEW_MODEL_IMPLEMENTATIONS: ReadonlySet<string> = new Set(
Object.values(CallViewModelImplementation),
);
export class Config {
private static internalInstance: Config | undefined;
@@ -136,6 +139,16 @@ export function validateConfig(config: ConfigOptions): ConfigOptions {
);
delete config.matrix_rtc_mode;
}
const implementation = config.call_view_model_implementation;
if (
implementation !== undefined &&
!VALID_CALL_VIEW_MODEL_IMPLEMENTATIONS.has(implementation)
) {
logger.warn(
`Ignoring invalid call_view_model_implementation in config.json: ${String(implementation)}`,
);
delete config.call_view_model_implementation;
}
return config;
}
+20
View File
@@ -24,6 +24,18 @@ export enum MatrixRTCMode {
Matrix_2_0 = "matrix_2_0",
}
/**
* Which MatrixRTC implementation carries a call: matrix-js-sdk's
* `MatrixRTCSession`, or the Rust `matrix-rtc` crate through the host's
* drivers. Both are in the build while the crate path is being proven, so a
* broken call can be compared against the other path in the same session;
* the js-sdk path and this choice go away together.
*/
export enum CallViewModelImplementation {
MatrixJsSdk = "matrix-js-sdk",
MatrixRtc = "matrix-rtc",
}
export interface DelayedLeaveTimings {
/**
* The delay (in milliseconds) with which delayed leave events are sent.
@@ -202,6 +214,14 @@ export interface ConfigOptions {
*/
matrix_rtc_mode?: MatrixRTCMode;
/**
* Pins the {@link CallViewModelImplementation} for all clients on this
* deployment, overriding any per-user choice from the Developer Settings.
* If unset, the user's Developer Settings choice (or its default) wins.
* Temporary: goes away with the matrix-js-sdk implementation.
*/
call_view_model_implementation?: CallViewModelImplementation;
/**
* These are low level options that are used to configure the MatrixRTC session.
* Take care when changing these options.
+168
View File
@@ -0,0 +1,168 @@
/*
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.
*/
/**
* What Element Call needs from a Matrix client beyond MatrixRTC.
*
* A host implements this once per room next to its {@link RtcMatrixDriver}.
* It covers what the crate deliberately leaves to the application: who is
* in the room and what they are called, sending a reaction, showing an
* avatar, the user's own profile, and what the deployment can do. Homeserver
* connectivity is *not* here: the crate needs it too, so it is part of the
* RTC driver and reaches Element Call as a participation impairment.
*
* Deliberately framework-neutral: plain values, promises and
* `subscribeX(listener)` pairs that return an unsubscribe function, the
* same shape as the crate's sinks. No RxJS crosses this boundary; Element
* Call wraps subscriptions into behaviors itself (see `observe.ts`).
*/
/** Removes the listener it was returned for. */
export type Unsubscribe = () => void;
/**
* What this client and its homeserver can do, as far as Element Call cares —
* not to be confused with widget capabilities, which are what a host *permits*
* rather than what the stack supports.
*/
export interface MatrixClientFeatures {
/** The homeserver accepts sticky events (MSC4354). */
stickyEvents: boolean;
/**
* The events the RTC driver emits carry real decryption metadata. A
* widget client receives events already decrypted by its host and can
* only report the device the content *claims*.
*/
verifiedEventOrigins: boolean;
/** The RTC driver can say whether a sending device is cross-signed (MSC4153). */
crossSigningVerdicts: boolean;
}
export interface RoomInfo {
name: string;
canonicalAlias: string | null;
/** An `mxc://` URL. */
avatarUrl: string | null;
/** The `m.room.join_rules` value, `null` while unknown. */
joinRule: string | null;
/** Whether the room has an `m.room.encryption` state event. */
encrypted: boolean;
/**
* Whether this user may send the MatrixRTC slot state event
* (`org.matrix.msc4143.rtc.slot`). A call needs an open slot; the client
* that starts a call opens one, which takes the power level for it.
*/
canOpenSlot: boolean;
}
export interface RoomMemberProfile {
userId: string;
displayName: string | null;
/** An `mxc://` URL. */
avatarUrl: string | null;
membership: "join" | "invite";
}
/**
* Room metadata and the profiles of the people in it.
*
* Call members' names and avatars do not come from here: the crate reads
* `m.room.member` itself and puts them on each membership. This roster is
* for the people who are in the room but not (yet) in the call — the person
* being rung, and how many others there are.
*/
export interface RoomDriver {
getRoomInfo(): RoomInfo;
subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe;
/** Joined and invited members of the room, in or out of the call. */
getRoomMembers(): RoomMemberProfile[];
subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): Unsubscribe;
}
/** A decrypted room event, as far as a call needs to know it. */
export interface TimelineEvent {
eventId: string;
type: string;
sender: string;
content: Record<string, unknown>;
originServerTs: number;
/** Set on an `m.room.redaction`. */
redacts?: string;
}
/** Application events in the room: reactions, hand raises, notifications. */
export interface TimelineDriver {
sendRoomEvent(
eventType: string,
content: Record<string, unknown>,
): Promise<{ eventId: string }>;
redactEvent(eventId: string): Promise<void>;
/**
* Live room events (not sticky ones — those reach the crate through its
* own sink), decrypted, redactions included, local echoes excluded.
*/
subscribeTimeline(listener: (event: TimelineEvent) => void): Unsubscribe;
/**
* Events already known that relate to `eventId` with the given relation
* type and event type — how a late joiner learns of a raised hand.
*/
getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[];
}
export interface OwnProfile {
displayName: string | null;
/** An `mxc://` URL. */
avatarUrl: string | null;
}
/** The user's own profile. Editing is optional: a host may not allow it. */
export interface ProfileDriver {
getOwnProfile(): OwnProfile;
subscribeOwnProfile(listener: (profile: OwnProfile) => void): Unsubscribe;
setDisplayName?(name: string): Promise<void>;
/** Sets the avatar to `file`, or removes it with `null`. */
setAvatar?(file: Blob | null): Promise<void>;
}
export interface MediaDriver {
/**
* A URL an `<img>` can show for an `mxc://` thumbnail — possibly a `blob:`
* URL the driver fetched with its credentials — or null when the media
* cannot be resolved.
*/
thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
resizeMethod: "crop" | "scale",
): Promise<string | null>;
}
/**
* Everything Element Call asks of a Matrix client beyond MatrixRTC, bound to
* one room. One object, sliced into the driver interfaces above the way the crate
* slices its own driver, so a piece of Element Call can ask for no more than
* it needs.
*/
export interface ElementCallMatrixClientDriver
extends RoomDriver, TimelineDriver, ProfileDriver, MediaDriver {
/** Who we publish as. */
readonly userId: string;
readonly deviceId: string;
/** The room this driver is bound to. */
readonly roomId: string;
/** What this client and homeserver support; see {@link MatrixClientFeatures}. */
getMatrixClientFeatures(): Promise<MatrixClientFeatures>;
/** Free-form facts for a rageshake: crypto version, sync state, and so on. */
getDiagnostics?(): Promise<Record<string, string>>;
}
+41
View File
@@ -0,0 +1,41 @@
/*
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 { createContext, use } from "react";
import { type ElementCallMatrixClientDriver } from "./ElementCallMatrixClientDriver";
import { type RtcMatrixDriver } from "./RtcMatrixDriver";
/** The host's two drivers for the room a call is in. */
export interface MatrixDrivers {
rtcDriver: RtcMatrixDriver;
clientDriver: ElementCallMatrixClientDriver;
}
const MatrixDriverContext = createContext<MatrixDrivers | null>(null);
/**
* Makes the drivers available to the call tree. Every host that renders a
* `CallView` provides them: the component from its props, the standalone
* app and the widget from the matrix-js-sdk drivers over their client.
*/
export const MatrixDriverProvider = MatrixDriverContext.Provider;
/** The drivers, or null where no host provided any (a test rendering a leaf). */
export function useOptionalMatrixDrivers(): MatrixDrivers | null {
return use(MatrixDriverContext);
}
/** The drivers; throws where none were provided. */
export function useMatrixDrivers(): MatrixDrivers {
const drivers = useOptionalMatrixDrivers();
if (drivers === null)
throw new Error(
"No Matrix drivers were provided; wrap the call in a MatrixDriverProvider",
);
return drivers;
}
@@ -0,0 +1,89 @@
/*
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 { MockElementCallMatrixClientDriver } from "./MockElementCallMatrixClientDriver";
import { MOCK_ROOM_ID } from "./MockRtcMatrixDriver";
describe("MockElementCallMatrixClientDriver", () => {
it("notifies room info, member and profile subscribers until unsubscribed", () => {
const driver = new MockElementCallMatrixClientDriver({
roomInfo: { name: "Standup" },
});
expect(driver.getRoomInfo().name).toBe("Standup");
const onInfo = vi.fn();
const off = driver.subscribeRoomInfo(onInfo);
driver.setRoomInfo({ name: "Retro" });
expect(onInfo).toHaveBeenCalledWith(
expect.objectContaining({ name: "Retro" }),
);
off();
driver.setRoomInfo({ name: "Planning" });
expect(onInfo).toHaveBeenCalledTimes(1);
const onMembers = vi.fn();
driver.subscribeRoomMembers(onMembers);
const alice = {
userId: "@alice:example.org",
displayName: "Alice",
avatarUrl: null,
membership: "join" as const,
};
driver.setRoomMembers([alice]);
expect(onMembers).toHaveBeenCalledWith([alice]);
expect(driver.getRoomMembers()).toEqual([alice]);
const onProfile = vi.fn();
driver.subscribeOwnProfile(onProfile);
driver.setOwnProfile({ displayName: "Bob" });
expect(onProfile).toHaveBeenCalledWith({
displayName: "Bob",
avatarUrl: null,
});
});
it("records sent room events, echoes them, and resolves relations", async () => {
const driver = new MockElementCallMatrixClientDriver();
const seen: string[] = [];
driver.subscribeTimeline((e) => seen.push(e.type));
const { eventId } = await driver.sendRoomEvent("m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$membership",
key: "🖐️",
},
});
expect(driver.calls("sendRoomEvent")[0]).toMatchObject({
eventType: "m.reaction",
eventId,
});
expect(seen).toEqual(["m.reaction"]);
expect(
driver.getRelatedEvents("$membership", "m.annotation", "m.reaction"),
).toHaveLength(1);
await driver.redactEvent(eventId);
expect(seen).toEqual(["m.reaction", "m.room.redaction"]);
expect(
driver.getRelatedEvents("$membership", "m.annotation", "m.reaction"),
).toHaveLength(0);
});
it("answers thumbnails for mxc urls only", async () => {
const driver = new MockElementCallMatrixClientDriver();
await expect(
driver.thumbnailUrl("mxc://example.org/abc", 96, 96, "crop"),
).resolves.toContain("abc");
await expect(
driver.thumbnailUrl("https://not-mxc", 96, 96, "crop"),
).resolves.toBeNull();
expect(driver.roomId).toBe(MOCK_ROOM_ID);
await expect(driver.getMatrixClientFeatures()).resolves.toMatchObject({
stickyEvents: true,
});
});
});
@@ -0,0 +1,248 @@
/*
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.
*/
/**
* An {@link ElementCallMatrixClientDriver} whose room lives in memory, for
* tests and stories: room info, members, a timeline and a profile, with
* setters that notify subscribers, and a record of what was sent.
*/
import {
type MatrixClientFeatures,
type ElementCallMatrixClientDriver,
type OwnProfile,
type RoomInfo,
type RoomMemberProfile,
type TimelineEvent,
type Unsubscribe,
} from "./ElementCallMatrixClientDriver";
import {
MOCK_OWN_DEVICE_ID,
MOCK_OWN_USER_ID,
MOCK_ROOM_ID,
} from "./MockRtcMatrixDriver";
export type ClientCall =
| {
kind: "sendRoomEvent";
eventType: string;
content: Record<string, unknown>;
eventId: string;
}
| { kind: "redactEvent"; eventId: string };
export interface MockElementCallMatrixClientDriverOptions {
userId?: string;
deviceId?: string;
roomId?: string;
roomInfo?: Partial<RoomInfo>;
members?: RoomMemberProfile[];
ownProfile?: Partial<OwnProfile>;
features?: Partial<MatrixClientFeatures>;
}
export class MockElementCallMatrixClientDriver implements ElementCallMatrixClientDriver {
public readonly userId: string;
public readonly deviceId: string;
public readonly roomId: string;
public readonly outbound: ClientCall[] = [];
private features: MatrixClientFeatures;
private roomInfo: RoomInfo;
private members: RoomMemberProfile[];
private ownProfile: OwnProfile;
private readonly timeline: TimelineEvent[] = [];
private readonly roomInfoListeners = new Set<(info: RoomInfo) => void>();
private readonly memberListeners = new Set<
(members: RoomMemberProfile[]) => void
>();
private readonly timelineListeners = new Set<
(event: TimelineEvent) => void
>();
private readonly profileListeners = new Set<(profile: OwnProfile) => void>();
private nextEventId = 0;
public constructor(options: MockElementCallMatrixClientDriverOptions = {}) {
this.userId = options.userId ?? MOCK_OWN_USER_ID;
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
this.roomId = options.roomId ?? MOCK_ROOM_ID;
this.features = {
stickyEvents: true,
verifiedEventOrigins: true,
crossSigningVerdicts: true,
...options.features,
};
this.roomInfo = {
name: "Test room",
canonicalAlias: null,
avatarUrl: null,
joinRule: "public",
encrypted: false,
canOpenSlot: true,
...options.roomInfo,
};
this.members = options.members ?? [];
this.ownProfile = {
displayName: "Me",
avatarUrl: null,
...options.ownProfile,
};
}
public calls<K extends ClientCall["kind"]>(
kind: K,
): Extract<ClientCall, { kind: K }>[] {
return this.outbound.filter((c) => c.kind === kind) as Extract<
ClientCall,
{ kind: K }
>[];
}
// --- room ------------------------------------------------------------------
public getRoomInfo(): RoomInfo {
return this.roomInfo;
}
public subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe {
return listen(this.roomInfoListeners, listener);
}
public setRoomInfo(info: Partial<RoomInfo>): void {
this.roomInfo = { ...this.roomInfo, ...info };
for (const l of this.roomInfoListeners) l(this.roomInfo);
}
public getRoomMembers(): RoomMemberProfile[] {
return this.members;
}
public subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): Unsubscribe {
return listen(this.memberListeners, listener);
}
public setRoomMembers(members: RoomMemberProfile[]): void {
this.members = members;
for (const l of this.memberListeners) l(members);
}
// --- timeline --------------------------------------------------------------
public async sendRoomEvent(
eventType: string,
content: Record<string, unknown>,
): Promise<{ eventId: string }> {
const eventId = `$echo-${this.nextEventId++}`;
this.outbound.push({ kind: "sendRoomEvent", eventType, content, eventId });
// Like sync, the room sees our event once the server has it.
this.emitTimelineEvent({
eventId,
type: eventType,
sender: this.userId,
content,
originServerTs: Date.now(),
});
return Promise.resolve({ eventId });
}
public async redactEvent(eventId: string): Promise<void> {
this.outbound.push({ kind: "redactEvent", eventId });
this.emitTimelineEvent({
eventId: `$echo-${this.nextEventId++}`,
type: "m.room.redaction",
sender: this.userId,
content: { redacts: eventId },
originServerTs: Date.now(),
redacts: eventId,
});
return Promise.resolve();
}
public subscribeTimeline(
listener: (event: TimelineEvent) => void,
): Unsubscribe {
return listen(this.timelineListeners, listener);
}
/** A room event arrives from another user (or is echoed back). */
public emitTimelineEvent(event: TimelineEvent): void {
this.timeline.push(event);
for (const l of this.timelineListeners) l(event);
}
public getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[] {
const redacted = new Set(
this.timeline.flatMap((e) => (e.redacts ? [e.redacts] : [])),
);
return this.timeline.filter((e) => {
if (e.type !== eventType || redacted.has(e.eventId)) return false;
const relation = e.content["m.relates_to"] as
| { rel_type?: string; event_id?: string }
| undefined;
return relation?.rel_type === relType && relation.event_id === eventId;
});
}
// --- profile, media, features --------------------------------------------
public getOwnProfile(): OwnProfile {
return this.ownProfile;
}
public subscribeOwnProfile(
listener: (profile: OwnProfile) => void,
): Unsubscribe {
return listen(this.profileListeners, listener);
}
public setOwnProfile(profile: Partial<OwnProfile>): void {
this.ownProfile = { ...this.ownProfile, ...profile };
for (const l of this.profileListeners) l(this.ownProfile);
}
public async setDisplayName(name: string): Promise<void> {
this.setOwnProfile({ displayName: name });
return Promise.resolve();
}
public async thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
_resizeMethod: "crop" | "scale",
): Promise<string | null> {
return Promise.resolve(
mxcUrl.startsWith("mxc://")
? `https://media.example.org/thumbnail/${mxcUrl.slice("mxc://".length)}?width=${width}&height=${height}`
: null,
);
}
public async getMatrixClientFeatures(): Promise<MatrixClientFeatures> {
return Promise.resolve(this.features);
}
public setMatrixClientFeatures(
features: Partial<MatrixClientFeatures>,
): void {
this.features = { ...this.features, ...features };
}
}
function listen<T>(listeners: Set<T>, listener: T): Unsubscribe {
listeners.add(listener);
return (): void => {
listeners.delete(listener);
};
}
+177
View File
@@ -0,0 +1,177 @@
/*
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 { beforeAll, describe, expect, it } from "vitest";
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
import {
FfiElementCallCompat,
FfiMatrixDriver,
FfiMembershipState,
FfiParticipationManager,
FfiStatus,
FfiTransportIntent,
type FfiParticipationConfig,
} from "../matrix-rtc-sdk";
import {
MOCK_LK_SERVICE_URL,
MOCK_OWN_USER_ID,
MOCK_SLOT_ID,
MockRtcMatrixDriver,
roomEncryptionEvent,
slotEvent,
waitFor,
} from "./MockRtcMatrixDriver";
const config: FfiParticipationConfig = {
compat: FfiElementCallCompat.Off,
manageMediaKeys: true,
requireCrossSignedSender: false,
useKeyDelayMs: 50n,
};
const joinParams = {
applicationType: "m.call",
intent: undefined,
stickyDurationMs: 240_000n,
keepAliveTimeoutMs: 15_000n,
degradedLifetimeMs: undefined,
delegateDelayedLeave: false,
delegatedDelayMs: 3_600_000n,
};
const publish = (): FfiTransportIntent =>
new FfiTransportIntent.Publish({
transport: {
transportType: "livekit",
propertiesJson: JSON.stringify({
livekit_service_url: MOCK_LK_SERVICE_URL,
}),
},
});
function newManager(driver: MockRtcMatrixDriver): FfiParticipationManager {
return new FfiParticipationManager(
driver.roomId,
MOCK_SLOT_ID,
driver.userId,
driver.deviceId,
new FfiMatrixDriver(driver),
config,
);
}
describe("MockRtcMatrixDriver as the crate's driver", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
it("echoes our sticky join so our own membership reaches the roster", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
const manager = newManager(driver);
await manager.join(publish(), joinParams);
expect(FfiStatus.Connected.instanceOf(manager.status())).toBe(true);
const sticky = driver.calls("stickyEvent");
expect(sticky).toHaveLength(1);
expect(sticky[0].eventType).toBe("org.matrix.msc4143.rtc.member");
const me = manager
.memberships()
.find((m) => m.member.userId === MOCK_OWN_USER_ID);
expect(me?.state).toBe(FfiMembershipState.Joined);
expect(me?.connections).toEqual([MOCK_LK_SERVICE_URL]);
expect(me?.transportIdentity).toBe(manager.ownTransportIdentity());
// the token the mock minted is what the crate hands out
expect(manager.connections()[0].connection.jwtToken).toBe(
`jwt-for-${MOCK_LK_SERVICE_URL}`,
);
await manager.leave(undefined, undefined);
manager.uniffiDestroy();
});
it("hosts peers that join, answer our key and leave", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [
roomEncryptionEvent(),
slotEvent({ status: "open", encrypted: true }),
],
});
const manager = newManager(driver);
const peer = driver.addPeer({
userId: "@peer:example.org",
deviceId: "PEERDEV",
memberId: "m-peer",
});
await manager.join(
new FfiTransportIntent.ReceiveOnly({ canSubscribe: ["livekit"] }),
joinParams,
);
driver.peerJoins(peer);
expect(manager.memberships().map((m) => m.member.userId)).toContain(
peer.userId,
);
await waitFor("key exchange", () =>
manager.keyMap().some((k) => k.memberId === peer.memberId),
);
// spec MSC4143: the key message Element X's crates read too
expect(driver.calls("toDevice")[0].eventType).toBe(
"org.matrix.msc4143.rtc.encryption_key",
);
// the tile carries the sender's MSC4153 verdict even with the check off
expect(
manager.memberships().find((m) => m.member.memberId === peer.memberId)
?.mediaKey?.senderCrossSigned,
).toBe(true);
driver.peerLeaves(peer);
expect(
manager.memberships().find((m) => m.member.memberId === peer.memberId)
?.state,
).toBe(FfiMembershipState.LeftWithKeys);
await manager.leave(undefined, undefined);
manager.uniffiDestroy();
});
it("reports homeserver connectivity into the crate", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
const manager = newManager(driver);
expect(manager.isHomeserverConnected()).toBe(true);
await manager.join(publish(), joinParams);
driver.setHomeserverConnected(false);
await waitFor("outage reported", () => !manager.isHomeserverConnected());
await waitFor("impairment", () => {
const status = manager.status();
return (
FfiStatus.Connected.instanceOf(status) &&
status.inner.impairments[0]?.tag === "HomeserverUnreachable"
);
});
driver.setHomeserverConnected(true);
await waitFor("outage clears", () => {
const status = manager.status();
return (
FfiStatus.Connected.instanceOf(status) &&
status.inner.impairments.length === 0
);
});
await manager.leave(undefined, undefined);
manager.uniffiDestroy();
});
it("can refuse sticky and delayed events like an old homeserver", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
driver.refuseStickyEvents = true;
const manager = newManager(driver);
await expect(manager.join(publish(), joinParams)).rejects.toThrow();
expect(FfiStatus.Disconnected.instanceOf(manager.status())).toBe(true);
manager.uniffiDestroy();
});
});
+676
View File
@@ -0,0 +1,676 @@
/*
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.
*/
/**
* An {@link RtcMatrixDriver} with a homeserver made of arrays, for tests and
* stories.
*
* It models what sync would do: it records every outbound call, echoes
* accepted sticky and state events back through the room-event sink (so our
* own membership reaches the roster like anybody else's), answers `readState`
* from `roomState`, mints tokens, reports homeserver connectivity, and hosts
* simulated peers that answer our media key with theirs.
*
* A port of the crate's `web-test-app/src/mockDriver.ts`, which is the
* source of truth for the fabricated wire shapes below.
*/
import {
FfiEventOrigin,
RtcError,
type ConnectivitySinkLike,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type FfiHomeserverDelegationRequest,
type FfiTransportDelegationRequest,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../matrix-rtc-sdk";
import { type RtcMatrixDriver } from "./RtcMatrixDriver";
export const MOCK_LK_SERVICE_URL = "https://lk.example.org";
export const MOCK_ROOM_ID = "!room:example.org";
/** MSC4143: a slot id is `{application_type}#{id}`; Element Call's is this. */
export const MOCK_SLOT_ID = "m.call#ROOM";
export const MOCK_OWN_USER_ID = "@me:example.org";
export const MOCK_OWN_DEVICE_ID = "MYDEV";
/** Every call the crate made on the driver, in order. */
export type OutboundCall =
| {
kind: "stickyEvent";
roomId: string;
eventType: string;
content: Record<string, unknown>;
durationMs: bigint;
}
| {
kind: "stateEvent";
roomId: string;
eventType: string;
stateKey: string;
content: Record<string, unknown>;
}
| {
kind: "delayedEvent";
roomId: string;
eventType: string;
content: Record<string, unknown>;
delayMs: bigint;
stickyDurationMs: bigint | undefined;
delayId: string;
}
| {
kind: "delayedStateEvent";
roomId: string;
eventType: string;
stateKey: string;
content: Record<string, unknown>;
delayMs: bigint;
delayId: string;
}
| { kind: "restartDelayed"; roomId: string; delayId: string }
| { kind: "cancelDelayed"; roomId: string; delayId: string }
| { kind: "delegateViaHomeserver"; request: FfiHomeserverDelegationRequest }
| { kind: "delegateViaTransport"; request: FfiTransportDelegationRequest }
| {
kind: "toDevice";
recipients: FfiToDeviceRecipient[];
eventType: string;
content: Record<string, unknown>;
}
| { kind: "getRtcTransports" }
| {
kind: "getLivekitToken";
url: string;
roomId: string;
slotId: string;
member: Record<string, unknown>;
legacySfuGet: boolean;
};
/** A simulated remote participant. */
export interface RemotePeer {
userId: string;
deviceId: string;
memberId: string;
/** 32 key bytes; defaults to a constant pattern. */
key?: Uint8Array;
}
/** A raw Matrix event as the crate reads it. */
export type RawEvent = Record<string, unknown>;
export interface MockRtcMatrixDriverOptions {
userId?: string;
deviceId?: string;
roomId?: string;
/** Room state answered by `readState` (the crate's session seed). */
roomState?: RawEvent[];
/** Advertised by `getRtcTransports`; an empty list is "none". */
transports?: FfiRtcTransport[];
}
export class MockRtcMatrixDriver implements RtcMatrixDriver {
/** Who this driver publishes as — not part of the contract, handy in tests. */
public readonly userId: string;
public readonly deviceId: string;
public readonly roomId: string;
public readonly outbound: OutboundCall[] = [];
/** Refuse delayed events like a homeserver without MSC4140 (404). */
public refuseDelayedEvents = false;
/** Refuse sticky events like a homeserver without MSC4354 (404). */
public refuseStickyEvents = false;
/** Make `getRtcTransports` fail rather than answer. */
public failTransportDiscovery = false;
/** No MSC4195 endpoint on the homeserver: the crate falls back to the service. */
public refuseHomeserverDelegation = false;
/** The authorisation service refuses the delegation too. */
public refuseTransportDelegation = false;
public roomState: RawEvent[];
public transports: FfiRtcTransport[];
/** Simulated peers answer our media key with theirs (index 0). */
public readonly peers: RemotePeer[] = [];
private roomEventSink?: RoomEventSinkLike;
private toDeviceSink?: ToDeviceSinkLike;
private stateUpdateSink?: StateUpdateSinkLike;
private readonly connectivitySinks = new Set<ConnectivitySinkLike>();
private homeserverConnected = true;
private nextDelayId = 0;
private nextEventId = 0;
public constructor(options: MockRtcMatrixDriverOptions = {}) {
this.userId = options.userId ?? MOCK_OWN_USER_ID;
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
this.roomId = options.roomId ?? MOCK_ROOM_ID;
this.roomState = options.roomState ?? [];
this.transports = options.transports ?? [
{
transportType: "livekit",
propertiesJson: JSON.stringify({
livekit_service_url: MOCK_LK_SERVICE_URL,
}),
},
];
}
// --- assertions ----------------------------------------------------------
public calls<K extends OutboundCall["kind"]>(
kind: K,
): Extract<OutboundCall, { kind: K }>[] {
return this.outbound.filter((c) => c.kind === kind) as Extract<
OutboundCall,
{ kind: K }
>[];
}
// --- outbound --------------------------------------------------------------
public async sendStickyEvent(
roomId: string,
eventType: string,
contentJson: string,
durationMs: bigint,
): Promise<FfiSendEventResponse> {
const content = parse(contentJson);
this.record({
kind: "stickyEvent",
roomId,
eventType,
content,
durationMs,
});
if (this.refuseStickyEvents)
throw new RtcError.Unsupported(
"M_UNRECOGNIZED: sticky events are not supported",
);
const eventId = this.eventId();
// The homeserver echoes our event through sync.
this.echo(
{
type: eventType,
sender: this.userId,
event_id: eventId,
room_id: roomId,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: Number(durationMs) },
content,
},
new FfiEventOrigin.Encrypted({ senderDeviceId: this.deviceId }),
);
return Promise.resolve({ eventId, delayId: undefined });
}
public async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
): Promise<FfiSendEventResponse> {
const content = parse(contentJson);
this.record({ kind: "stateEvent", roomId, eventType, stateKey, content });
const eventId = this.eventId();
this.echo(
{
type: eventType,
sender: this.userId,
event_id: eventId,
room_id: roomId,
state_key: stateKey,
origin_server_ts: Date.now(),
content,
},
new FfiEventOrigin.Cleartext(),
);
return Promise.resolve({ eventId, delayId: undefined });
}
public async sendDelayedEvent(
roomId: string,
eventType: string,
contentJson: string,
delayMs: bigint,
stickyDurationMs: bigint | undefined,
): Promise<string> {
const delayId = `delay-${this.nextDelayId++}`;
this.record({
kind: "delayedEvent",
roomId,
eventType,
content: parse(contentJson),
delayMs,
stickyDurationMs,
delayId,
});
if (this.refuseDelayedEvents)
// 404 M_UNRECOGNIZED: "this homeserver will never do delayed events".
throw new RtcError.Unsupported(
"M_UNRECOGNIZED: delayed events are not supported",
);
return Promise.resolve(delayId);
}
public async sendDelayedStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
delayMs: bigint,
): Promise<string> {
const delayId = `delay-${this.nextDelayId++}`;
this.record({
kind: "delayedStateEvent",
roomId,
eventType,
stateKey,
content: parse(contentJson),
delayMs,
delayId,
});
return Promise.resolve(delayId);
}
public async restartDelayedEvent(
roomId: string,
delayId: string,
): Promise<void> {
this.record({ kind: "restartDelayed", roomId, delayId });
return Promise.resolve();
}
public async cancelDelayedEvent(
roomId: string,
delayId: string,
): Promise<void> {
this.record({ kind: "cancelDelayed", roomId, delayId });
return Promise.resolve();
}
public async delegateDelayedLeaveViaHomeserver(
request: FfiHomeserverDelegationRequest,
): Promise<void> {
this.record({ kind: "delegateViaHomeserver", request });
if (this.refuseHomeserverDelegation)
throw new RtcError.Unsupported("M_UNRECOGNIZED: no delegation endpoint");
return Promise.resolve();
}
public async delegateDelayedLeaveViaTransport(
request: FfiTransportDelegationRequest,
): Promise<void> {
this.record({ kind: "delegateViaTransport", request });
if (this.refuseTransportDelegation)
throw new RtcError.Http("503: the service refused");
return Promise.resolve();
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
eventType: string,
contentJson: string,
): Promise<FfiToDeviceDelivery[]> {
this.record({
kind: "toDevice",
recipients,
eventType,
content: parse(contentJson),
});
// Simulated peers answer with their own key.
for (const recipient of recipients) {
const peer = this.peers.find(
(p) =>
p.userId === recipient.userId && p.deviceId === recipient.deviceId,
);
if (peer) queueMicrotask(() => this.peerSendsKey(peer, 0));
}
// every recipient reachable
return Promise.resolve(
recipients.map((recipient) => ({ recipient, error: undefined })),
);
}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
this.record({ kind: "getRtcTransports" });
if (this.failTransportDiscovery)
throw new RtcError.Http("500: transports endpoint unavailable");
return Promise.resolve(this.transports);
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
this.record({
kind: "getLivekitToken",
url: request.url,
roomId: request.roomId,
slotId: request.slotId,
member: parse(request.memberJson),
legacySfuGet: request.legacySfuGet,
});
return Promise.resolve({
jwt: "jwt-for-" + request.url,
url: request.url.replace("https", "wss"),
});
}
public async readEvents(): Promise<string[]> {
return Promise.resolve([]);
}
public async readState(
eventType: string,
stateKey: string | undefined,
): Promise<string[]> {
return Promise.resolve(
this.roomState
.filter(
(e) =>
e.type === eventType &&
(stateKey === undefined || e.state_key === stateKey),
)
.map((e) => JSON.stringify(e)),
);
}
// --- inbound sinks -----------------------------------------------------------
// The crate subscribes exactly once, when its FfiMatrixDriver is built, and
// hands over sinks; a real driver hooks client listeners onto them. The
// mock stores them so tests can emit fabricated events.
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
this.roomEventSink = sink;
}
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
this.toDeviceSink = sink;
}
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
this.stateUpdateSink = sink;
}
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
this.connectivitySinks.add(sink);
}
public isHomeserverConnected(): boolean {
return this.homeserverConnected;
}
/** The homeserver comes or goes, as a syncing client would report it. */
public setHomeserverConnected(connected: boolean): void {
this.homeserverConnected = connected;
for (const sink of this.connectivitySinks)
if (!sink.emit(connected)) this.connectivitySinks.delete(sink);
}
/** Emit any room event — sticky or state; the crate dispatches on type. */
public emitRoomEvent(event: RawEvent, origin: FfiEventOrigin): boolean {
if (!this.roomEventSink)
throw new Error("The SDK has not subscribed to room events");
return this.roomEventSink.emit(JSON.stringify(event), origin);
}
/** `senderCrossSigned` is the MSC4153 verdict; peers are cross-signed by default. */
public emitToDevice(
eventType: string,
sender: string,
content: RawEvent,
origin: FfiEventOrigin,
senderCrossSigned: boolean | undefined = true,
): boolean {
if (!this.toDeviceSink)
throw new Error("The SDK has not subscribed to to-device events");
return this.toDeviceSink.emit(
eventType,
sender,
JSON.stringify(content),
origin,
senderCrossSigned,
);
}
public emitStateUpdate(events: RawEvent[]): boolean {
if (!this.stateUpdateSink)
throw new Error("The SDK has not subscribed to state updates");
return this.stateUpdateSink.emit(events.map((e) => JSON.stringify(e)));
}
// --- simulated peers --------------------------------------------------------
public addPeer(peer: RemotePeer): RemotePeer {
this.peers.push(peer);
return peer;
}
/** The peer publishes a join (on `MOCK_LK_SERVICE_URL` unless given). */
public peerJoins(
peer: RemotePeer,
opts: { lkServiceUrl?: string; durationMs?: number } = {},
): boolean {
return this.emitRoomEvent(
memberJoinEvent({
roomId: this.roomId,
userId: peer.userId,
memberId: peer.memberId,
...opts,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
public peerLeaves(peer: RemotePeer): boolean {
return this.emitRoomEvent(
memberLeaveEvent({
roomId: this.roomId,
userId: peer.userId,
memberId: peer.memberId,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
public peerSendsKey(peer: RemotePeer, index: number): boolean {
return this.emitToDevice(
"m.rtc.encryption_key",
peer.userId,
encryptionKeyContent({
roomId: this.roomId,
memberId: peer.memberId,
index,
key: peer.key,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
// --- internals -----------------------------------------------------------------
private record(call: OutboundCall): void {
this.outbound.push(call);
}
private eventId(): string {
return `$echo-${this.nextEventId++}`;
}
private echo(event: RawEvent, origin: FfiEventOrigin): void {
this.roomEventSink?.emit(JSON.stringify(event), origin);
}
}
function parse(json: string): Record<string, unknown> {
return JSON.parse(json) as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Inbound event fabrication — the MSC4143/MSC4354 wire shapes the crate's
// dispatch reads (see its src/session/dispatch.rs). Adjust here, not in
// every test.
// ---------------------------------------------------------------------------
let eventCounter = 0;
export function memberJoinEvent(opts: {
roomId?: string;
userId: string;
memberId: string;
lkServiceUrl?: string;
durationMs?: number;
}): RawEvent {
return {
type: "m.rtc.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: opts.durationMs ?? 240_000 },
content: {
slot_id: MOCK_SLOT_ID,
// MSC4354: the sticky key lives in the content and equals member.id.
msc4354_sticky_key: opts.memberId,
member: { id: opts.memberId, membership: "join" },
application: { type: "m.call" },
transports: {
published: [
{
type: "livekit",
livekit_service_url: opts.lkServiceUrl ?? MOCK_LK_SERVICE_URL,
},
],
can_subscribe: ["livekit"],
},
},
};
}
export function memberLeaveEvent(opts: {
roomId?: string;
userId: string;
memberId: string;
}): RawEvent {
return {
type: "m.rtc.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: 240_000 },
content: {
slot_id: MOCK_SLOT_ID,
msc4354_sticky_key: opts.memberId,
member: { id: opts.memberId, membership: "leave" },
leave_reason: { code: "leave" },
},
};
}
export function slotEvent(
opts: { roomId?: string; status: "open" | "closed"; encrypted?: boolean } = {
status: "open",
},
): RawEvent {
const content: Record<string, unknown> = {
status: opts.status,
application: { type: "m.call" },
};
if (opts.encrypted) content.encryption = { type: "m.per_member" };
return {
type: "m.rtc.slot",
sender: "@admin:example.org",
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
state_key: MOCK_SLOT_ID,
origin_server_ts: Date.now(),
content,
};
}
export function roomEncryptionEvent(roomId = MOCK_ROOM_ID): RawEvent {
return {
type: "m.room.encryption",
sender: "@admin:example.org",
event_id: `$ev-${eventCounter++}`,
room_id: roomId,
state_key: "",
origin_server_ts: Date.now(),
content: { algorithm: "m.megolm.v1.aes-sha2" },
};
}
/** `m.room.member` state with the profile fields the crate puts on a member. */
export function roomMemberEvent(opts: {
roomId?: string;
userId: string;
membership?: "join" | "invite" | "leave";
displayName?: string;
avatarUrl?: string;
}): RawEvent {
const content: Record<string, unknown> = {
membership: opts.membership ?? "join",
};
if (opts.displayName !== undefined) content.displayname = opts.displayName;
if (opts.avatarUrl !== undefined) content.avatar_url = opts.avatarUrl;
return {
type: "m.room.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
state_key: opts.userId,
origin_server_ts: Date.now(),
content,
};
}
const DEFAULT_KEY = new Uint8Array(32).fill(7);
function base64(bytes: Uint8Array): string {
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/=+$/, "");
}
/** MSC4143 `m.rtc.encryption_key` content. */
export function encryptionKeyContent(opts: {
roomId?: string;
memberId: string;
index: number;
key?: Uint8Array;
}): RawEvent {
return {
room_id: opts.roomId ?? MOCK_ROOM_ID,
member_id: opts.memberId,
media_key: { index: opts.index, key: base64(opts.key ?? DEFAULT_KEY) },
format: 0,
};
}
/** One timer tick: the crate's listener callbacks arrive after the emitting task yields. */
export const tick = async (): Promise<void> =>
new Promise<void>((resolve) => setTimeout(resolve, 0));
export async function waitFor(
what: string,
cond: () => boolean,
timeoutMs = 3000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!cond()) {
if (Date.now() > deadline)
throw new Error(`Timed out waiting for: ${what}`);
await new Promise((r) => setTimeout(r, 10));
}
}
+21
View File
@@ -0,0 +1,21 @@
/*
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.
*/
/**
* The MatrixRTC half of what a host supplies: the `matrix-rtc` crate's own
* driver contract, taken verbatim from its bindings. Everything MatrixRTC —
* sticky and delayed events, to-device key delivery, transport tokens, the
* inbound event sinks and homeserver connectivity — goes through this and
* is consumed by the crate, never by Element Call directly.
*
* What a call needs from a Matrix client beyond MatrixRTC is the separate
* {@link ElementCallMatrixClientDriver}.
*/
import { type MatrixDriverCallback } from "../matrix-rtc-sdk";
export type RtcMatrixDriver = MatrixDriverCallback;
@@ -0,0 +1,123 @@
/*
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 { MatrixEvent, RoomEvent, UserEvent } from "matrix-js-sdk";
import { describe, expect, it, vi } from "vitest";
import { JsSdkElementCallMatrixClientDriver } from "./JsSdkElementCallMatrixClientDriver";
import {
ME,
ROOM_ID,
asClient,
asRoom,
fakeClient,
fakeRoom,
} from "./jsSdkTestFakes";
describe("JsSdkElementCallMatrixClientDriver", () => {
it("serves room info, members, timeline and profile from a full client", async () => {
const client = fakeClient(false);
const room = fakeRoom();
const driver = new JsSdkElementCallMatrixClientDriver(
asClient(client),
asRoom(room),
);
expect(driver.userId).toBe(ME);
expect(driver.getRoomInfo()).toEqual({
name: "Standup",
canonicalAlias: "#standup:example.org",
avatarUrl: "mxc://example.org/room",
joinRule: "public",
encrypted: true,
canOpenSlot: true,
});
const onInfo = vi.fn();
const offInfo = driver.subscribeRoomInfo(onInfo);
room.name = "Retro";
room.emit(RoomEvent.Name, room);
expect(onInfo).toHaveBeenCalledWith(
expect.objectContaining({ name: "Retro" }),
);
offInfo();
expect(driver.getRoomMembers()).toEqual([
{
userId: "@a:example.org",
displayName: "Alice",
avatarUrl: "mxc://example.org/alice",
membership: "join",
},
{
userId: "@b:example.org",
displayName: null,
avatarUrl: null,
membership: "invite",
},
]);
const seen: string[] = [];
driver.subscribeTimeline((e) => seen.push(`${e.type}:${e.eventId}`));
const reaction = new MatrixEvent({
type: "m.reaction",
sender: "@a:example.org",
event_id: "$r1",
room_id: ROOM_ID,
origin_server_ts: 3,
content: { "m.relates_to": { rel_type: "m.annotation", key: "🖐️" } },
});
client.emit(RoomEvent.Timeline, reaction, room, false, false, {});
client.emit(RoomEvent.Timeline, reaction, room, false, false, {});
await vi.waitFor(() => expect(seen).toEqual(["m.reaction:$r1"]));
await driver.sendRoomEvent("io.element.call.reaction", { emoji: "🎉" });
expect(client.sendEvent).toHaveBeenCalledWith(
ROOM_ID,
"io.element.call.reaction",
{ emoji: "🎉" },
);
await driver.redactEvent("$r1");
expect(client.redactEvent).toHaveBeenCalledWith(ROOM_ID, "$r1");
expect(driver.getOwnProfile()).toEqual({
displayName: "Me",
avatarUrl: "mxc://example.org/me",
});
const onProfile = vi.fn();
driver.subscribeOwnProfile(onProfile);
const user = client.getUser(ME)!;
user.rawDisplayName = "Moi";
user.emit(UserEvent.DisplayName, undefined, user);
expect(onProfile).toHaveBeenCalledWith(
expect.objectContaining({ displayName: "Moi" }),
);
await expect(driver.getMatrixClientFeatures()).resolves.toEqual({
stickyEvents: true,
verifiedEventOrigins: true,
crossSigningVerdicts: true,
});
await expect(driver.getDiagnostics()).resolves.toMatchObject({
matrix_backend: "jssdk",
crypto_version: "fake 1.0",
});
});
it("says what a widget client cannot vouch for and leaves media to the host bridge", async () => {
const driver = new JsSdkElementCallMatrixClientDriver(
asClient(fakeClient(true)),
asRoom(fakeRoom()),
);
await expect(driver.getMatrixClientFeatures()).resolves.toMatchObject({
verifiedEventOrigins: false,
crossSigningVerdicts: false,
});
// no token of its own: media comes through the host bridge instead
await expect(
driver.thumbnailUrl("mxc://example.org/x", 96, 96, "crop"),
).resolves.toBeNull();
});
});
@@ -0,0 +1,333 @@
/*
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.
*/
/**
* An {@link ElementCallMatrixClientDriver} over a matrix-js-sdk client: room
* metadata and members, the room's timeline for reactions and notifications,
* the user's own profile, authenticated thumbnails and feature probes.
* Works on a full `MatrixClient` and on a `RoomWidgetClient`; the places
* they differ are marked "widget".
*/
import {
KnownMembership,
type MatrixClient,
type MatrixEvent,
type Room,
RoomEvent,
type RoomMember,
RoomStateEvent,
RoomWidgetClient,
UNSTABLE_MSC4354_STICKY_EVENTS,
type User,
UserEvent,
} from "matrix-js-sdk";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { ELEMENT_CALL_SLOT_EVENT_TYPE } from "../../state/rtc/slot";
import {
type MatrixClientFeatures,
type ElementCallMatrixClientDriver,
type OwnProfile,
type RoomInfo,
type RoomMemberProfile,
type TimelineEvent,
type Unsubscribe,
} from "../ElementCallMatrixClientDriver";
/** The state event types `getRoomInfo()` is computed from. */
const ROOM_INFO_EVENT_TYPES = new Set([
"m.room.name",
"m.room.avatar",
"m.room.canonical_alias",
"m.room.join_rules",
"m.room.encryption",
"m.room.power_levels",
]);
export interface JsSdkElementCallMatrixClientDriverOptions {
logger?: Logger;
}
export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClientDriver {
public readonly userId: string;
public readonly deviceId: string;
public readonly roomId: string;
private readonly logger: Logger;
/** Widget: no crypto backend, no access token, events without metadata. */
private readonly widget: boolean;
private features: Promise<MatrixClientFeatures> | null = null;
public constructor(
private readonly client: MatrixClient,
private readonly room: Room,
options: JsSdkElementCallMatrixClientDriverOptions = {},
) {
const userId = client.getUserId();
const deviceId = client.getDeviceId();
if (userId === null || deviceId === null)
throw new Error(
"The client must be logged in before it can drive a call",
);
this.userId = userId;
this.deviceId = deviceId;
this.roomId = room.roomId;
this.widget = client instanceof RoomWidgetClient;
this.logger = (options.logger ?? rootLogger).getChild(
`[JsSdkElementCallMatrixClientDriver ${room.roomId}]`,
);
}
// --- room ------------------------------------------------------------------
public getRoomInfo(): RoomInfo {
return {
name: this.room.name,
canonicalAlias: this.room.getCanonicalAlias(),
avatarUrl: this.room.getMxcAvatarUrl(),
joinRule: this.room.currentState.getJoinRule() ?? null,
encrypted: this.room.hasEncryptionStateEvent(),
canOpenSlot: this.room.currentState.maySendStateEvent(
ELEMENT_CALL_SLOT_EVENT_TYPE,
this.userId,
),
};
}
public subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe {
const notify = (): void => listener(this.getRoomInfo());
const onState = (event: MatrixEvent): void => {
if (
event.getRoomId() === this.roomId &&
ROOM_INFO_EVENT_TYPES.has(event.getType())
)
notify();
};
this.room.on(RoomEvent.Name, notify);
this.client.on(RoomStateEvent.Events, onState);
return (): void => {
this.room.off(RoomEvent.Name, notify);
this.client.off(RoomStateEvent.Events, onState);
};
}
public getRoomMembers(): RoomMemberProfile[] {
const profile =
(membership: "join" | "invite") =>
(member: RoomMember): RoomMemberProfile => ({
userId: member.userId,
displayName: member.rawDisplayName ?? null,
avatarUrl: member.getMxcAvatarUrl() ?? null,
membership,
});
return [
...this.room
.getMembersWithMembership(KnownMembership.Join)
.map(profile("join")),
...this.room
.getMembersWithMembership(KnownMembership.Invite)
.map(profile("invite")),
];
}
public subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): Unsubscribe {
const onMembers = (event: MatrixEvent): void => {
if (event.getRoomId() === this.roomId) listener(this.getRoomMembers());
};
this.client.on(RoomStateEvent.Members, onMembers);
return (): void => {
this.client.off(RoomStateEvent.Members, onMembers);
};
}
// --- timeline ----------------------------------------------------------------
public async sendRoomEvent(
eventType: string,
content: Record<string, unknown>,
): Promise<{ eventId: string }> {
const res = await this.client.sendEvent(
this.roomId,
eventType as never,
content as never,
);
return { eventId: res.event_id };
}
public async redactEvent(eventId: string): Promise<void> {
await this.client.redactEvent(this.roomId, eventId);
}
public subscribeTimeline(
listener: (event: TimelineEvent) => void,
): Unsubscribe {
// Our own events are seen twice — as the local echo, then as sent —
// and a redaction is both a timeline event and a Redaction signal.
const seen = new Set<string>();
const deliver = async (event: MatrixEvent): Promise<void> => {
if (event.getRoomId() !== this.roomId) return;
// Still sending: the LocalEchoUpdated listener gets the real id later.
if (event.status !== null) return;
if (event.unstableStickyInfo !== undefined || event.isState()) return;
const eventId = event.getId();
const sender = event.getSender();
if (!eventId || !sender || seen.has(eventId)) return;
try {
await this.client.decryptEventIfNeeded(event);
} catch (e) {
this.logger.warn(`Could not decrypt ${eventId}`, e);
}
if (event.isDecryptionFailure() || seen.has(eventId)) return;
seen.add(eventId);
if (seen.size > 1000) seen.delete(seen.values().next().value!);
listener({
eventId,
type: event.getType(),
sender,
content: event.getContent(),
originServerTs: event.getTs(),
redacts: event.event.redacts,
});
};
const onTimeline = (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
): void => {
if (room?.roomId === this.roomId && !toStartOfTimeline)
void deliver(event);
};
const onEcho = (event: MatrixEvent): void => void deliver(event);
this.client.on(RoomEvent.Timeline, onTimeline);
this.room.on(RoomEvent.LocalEchoUpdated, onEcho);
this.room.on(RoomEvent.Redaction, onEcho);
return (): void => {
this.client.off(RoomEvent.Timeline, onTimeline);
this.room.off(RoomEvent.LocalEchoUpdated, onEcho);
this.room.off(RoomEvent.Redaction, onEcho);
};
}
public getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[] {
const relations = this.room.relations.getChildEventsForEvent(
eventId,
relType as never,
eventType as never,
);
return (relations?.getRelations() ?? [])
.filter(
(event) => !event.isRedacted() && event.getId() && event.getSender(),
)
.map((event) => ({
eventId: event.getId()!,
type: event.getType(),
sender: event.getSender()!,
content: event.getContent(),
originServerTs: event.getTs(),
}));
}
// --- profile -------------------------------------------------------------------
public getOwnProfile(): OwnProfile {
const user = this.client.getUser(this.userId);
return {
displayName: user?.rawDisplayName ?? null,
avatarUrl: user?.avatarUrl ?? null,
};
}
public subscribeOwnProfile(
listener: (profile: OwnProfile) => void,
): Unsubscribe {
const user: User | null = this.client.getUser(this.userId);
if (user === null) return (): void => {};
const notify = (): void => listener(this.getOwnProfile());
user.on(UserEvent.DisplayName, notify);
user.on(UserEvent.AvatarUrl, notify);
return (): void => {
user.off(UserEvent.DisplayName, notify);
user.off(UserEvent.AvatarUrl, notify);
};
}
public async setDisplayName(name: string): Promise<void> {
await this.client.setDisplayName(name);
}
public async setAvatar(file: Blob | null): Promise<void> {
if (file === null) {
await this.client.setAvatarUrl("");
return;
}
const { content_uri: uri } = await this.client.uploadContent(file);
await this.client.setAvatarUrl(uri);
}
// --- media -----------------------------------------------------------------------
public async thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
resizeMethod: "crop" | "scale",
): Promise<string | null> {
const httpUrl = this.client.mxcUrlToHttp(
mxcUrl,
width,
height,
resizeMethod,
false,
true,
true,
);
// Widget: no token of our own; the host bridge downloads media instead.
const token = this.client.getAccessToken();
if (httpUrl === null || token === null) return null;
const response = await fetch(httpUrl, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) return null;
return URL.createObjectURL(await response.blob());
}
// --- features and diagnostics ------------------------------------------------
public async getMatrixClientFeatures(): Promise<MatrixClientFeatures> {
this.features ??= this.probeMatrixClientFeatures();
return this.features;
}
private async probeMatrixClientFeatures(): Promise<MatrixClientFeatures> {
const stickyEvents = await this.client
.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS)
.catch((e: unknown) => {
this.logger.warn("Could not probe sticky event support", e);
return false;
});
return {
stickyEvents,
verifiedEventOrigins: !this.widget,
crossSigningVerdicts: this.client.getCrypto() !== undefined,
};
}
public async getDiagnostics(): Promise<Record<string, string>> {
return Promise.resolve({
matrix_backend: this.widget ? "widget" : "jssdk",
crypto_version: this.client.getCrypto()?.getVersion() ?? "none",
sync_state: String(this.client.getSyncState()),
});
}
}
@@ -0,0 +1,518 @@
/*
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 {
ClientEvent,
MatrixError,
MatrixEvent,
RoomStateEvent,
RoomStickyEventsEvent,
SyncState,
UnsupportedStickyEventsEndpointError,
UpdateDelayedEventAction,
} from "matrix-js-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import {
FfiEventOrigin,
RtcError,
type ConnectivitySinkLike,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../../matrix-rtc-sdk";
import { JsSdkRtcMatrixDriver } from "./JsSdkRtcMatrixDriver";
import {
LK,
ME,
MY_DEVICE,
ROOM_ID,
asClient,
asRoom,
fakeClient,
fakeRoom,
jsonResponse,
openIdToken,
type FakeClient,
type FakeRoom,
} from "./jsSdkTestFakes";
const memberJson = JSON.stringify({
id: "m-1",
claimed_user_id: ME,
claimed_device_id: MY_DEVICE,
});
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
describe("JsSdkRtcMatrixDriver", () => {
beforeEach(async () => {
// The bindings define the RtcError classes the driver throws.
await initMatrixRtcSdkForTests();
fetchMock = vi.fn<typeof fetch>();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("over a full MatrixClient", () => {
it("sends sticky, delayed and state events through the unstable APIs", async () => {
const { client, driver } = fullClient();
await expect(
driver.sendStickyEvent(ROOM_ID, "m.rtc.member", '{"a":1}', 240_000n),
).resolves.toEqual({ eventId: "$sticky", delayId: undefined });
expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith(
ROOM_ID,
240_000,
null,
"m.rtc.member",
{ a: 1 },
);
await expect(
driver.sendDelayedEvent(
ROOM_ID,
"m.rtc.member",
"{}",
15_000n,
240_000n,
),
).resolves.toBe("delay-sticky");
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
ROOM_ID,
240_000,
{ delay: 15_000 },
null,
"m.rtc.member",
{},
);
await expect(
driver.sendDelayedEvent(
ROOM_ID,
"m.rtc.member",
"{}",
15_000n,
undefined,
),
).resolves.toBe("delay-plain");
expect(client._unstable_sendDelayedEvent).toHaveBeenCalledWith(
ROOM_ID,
{ delay: 15_000 },
null,
"m.rtc.member",
{},
);
await driver.sendDelayedStateEvent(ROOM_ID, "m.x", "key", "{}", 5_000n);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
ROOM_ID,
{ delay: 5_000 },
"m.x",
{},
"key",
);
await driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "m.call#ROOM", "{}");
expect(client.sendStateEvent).toHaveBeenCalledWith(
ROOM_ID,
"m.rtc.slot",
{},
"m.call#ROOM",
);
await driver.restartDelayedEvent(ROOM_ID, "d1");
await driver.cancelDelayedEvent(ROOM_ID, "d1");
expect(client._unstable_updateDelayedEvent).toHaveBeenNthCalledWith(
1,
"d1",
UpdateDelayedEventAction.Restart,
);
expect(client._unstable_updateDelayedEvent).toHaveBeenNthCalledWith(
2,
"d1",
UpdateDelayedEventAction.Cancel,
);
});
it("maps js-sdk failures onto the crate's error family", async () => {
const { client, driver } = fullClient();
client._unstable_sendStickyEvent.mockRejectedValueOnce(
new UnsupportedStickyEventsEndpointError("nope", "sendStickyEvent"),
);
await expect(
driver.sendStickyEvent(ROOM_ID, "m.rtc.member", "{}", 1n),
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
client.sendStateEvent.mockRejectedValueOnce(
new MatrixError({ errcode: "M_FORBIDDEN" }, 403),
);
await expect(
driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "k", "{}"),
).rejects.toSatisfy((e) => RtcError.Rejected.instanceOf(e));
client.sendStateEvent.mockRejectedValueOnce(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED", retry_after_ms: 1500 },
429,
),
);
await expect(
driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "k", "{}"),
).rejects.toSatisfy(
(e) =>
RtcError.RateLimited.instanceOf(e) && e.inner.retryAfterMs === 1500n,
);
});
it("sends to-device messages Olm-encrypted per device", async () => {
const { client, driver } = fullClient();
const recipients = [{ userId: "@a:example.org", deviceId: "ADEV" }];
await expect(
driver.sendToDevice(recipients, "m.rtc.encryption_key", '{"k":1}'),
).resolves.toEqual([{ recipient: recipients[0], error: undefined }]);
expect(client.encryptAndSendToDevice).toHaveBeenCalledWith(
"m.rtc.encryption_key",
recipients,
{ k: 1 },
);
});
it("discovers transports through the client and answers in the crate's shape", async () => {
const { client, driver } = fullClient();
client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: LK },
]);
await expect(driver.getRtcTransports()).resolves.toEqual([
{
transportType: "livekit",
propertiesJson: JSON.stringify({ livekit_service_url: LK }),
},
]);
});
it("exchanges an OpenID token for a LiveKit token, on either endpoint", async () => {
const { driver } = fullClient();
// a fresh Response per call: a body can be read once
fetchMock.mockImplementation(async () =>
Promise.resolve(jsonResponse({ jwt: "the-jwt", url: "wss://sfu" })),
);
await expect(
driver.getLivekitToken({
url: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
legacySfuGet: false,
}),
).resolves.toEqual({ jwt: "the-jwt", url: "wss://sfu" });
const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(endpoint).toBe(`${LK}/get_token`);
expect(JSON.parse(init.body as string)).toEqual({
room_id: ROOM_ID,
slot_id: "m.call#ROOM",
openid_token: openIdToken,
member: JSON.parse(memberJson),
});
await driver.getLivekitToken({
url: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
legacySfuGet: true,
});
const [legacyEndpoint, legacyInit] = fetchMock.mock
.calls[1] as unknown as [string, RequestInit];
expect(legacyEndpoint).toBe(`${LK}/sfu/get`);
expect(JSON.parse(legacyInit.body as string)).toEqual({
room: ROOM_ID,
openid_token: openIdToken,
device_id: MY_DEVICE,
});
fetchMock.mockResolvedValueOnce(new Response("gone", { status: 404 }));
await expect(
driver.getLivekitToken({
url: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
legacySfuGet: false,
}),
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
});
it("offers both delegation primitives: the homeserver endpoint and the token endpoint", async () => {
const { client, driver } = fullClient();
client.http = { authedRequest: vi.fn(async () => Promise.resolve({})) };
await driver.delegateDelayedLeaveViaHomeserver({
sfuUrl: "wss://sfu.example.org",
livekitServiceUrl: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
delayId: "delay-1",
delayTimeoutMs: 3_600_000n,
});
expect(client.http.authedRequest).toHaveBeenCalledWith(
"POST",
"/rtc/livekit/delegate_delayed_leave",
undefined,
{
url: "wss://sfu.example.org",
room_id: ROOM_ID,
slot_id: "m.call#ROOM",
member: JSON.parse(memberJson),
delay_id: "delay-1",
delay_timeout: 3_600_000,
},
{ prefix: "/_matrix/client/unstable/io.element.msc4195" },
);
fetchMock.mockImplementation(async () =>
Promise.resolve(jsonResponse({ jwt: "discarded" })),
);
await driver.delegateDelayedLeaveViaTransport({
livekitServiceUrl: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
delayId: "delay-1",
delayTimeoutMs: 3_600_000n,
legacySfuGet: false,
});
const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(endpoint).toBe(`${LK}/get_token`);
expect(JSON.parse(init.body as string)).toMatchObject({
delay_id: "delay-1",
delay_timeout: 3_600_000,
delay_cs_api_url: "https://hs.example.org",
});
});
it("feeds sticky events and state updates into the crate's sinks with their origin", async () => {
const { client, room, driver } = fullClient();
const roomSink = sink();
const stateSink = sink();
driver.subscribeRoomEvents(roomSink as unknown as RoomEventSinkLike);
driver.subscribeStateUpdates(stateSink as unknown as StateUpdateSinkLike);
const sticky = new MatrixEvent({
type: "m.rtc.member",
sender: "@a:example.org",
event_id: "$s1",
room_id: ROOM_ID,
origin_server_ts: 1,
content: { slot_id: "m.call#ROOM" },
});
room.emit(RoomStickyEventsEvent.Update, [sticky], [], []);
await vi.waitFor(() => {
expect(roomSink.emit).toHaveBeenCalledTimes(1);
const [json, origin] = roomSink.emit.mock.calls[0] as unknown as [
string,
FfiEventOrigin,
];
expect(JSON.parse(json)).toMatchObject({
type: "m.rtc.member",
event_id: "$s1",
content: { slot_id: "m.call#ROOM" },
});
// not encrypted on this fake: the origin says so honestly
expect(FfiEventOrigin.Cleartext.instanceOf(origin)).toBe(true);
});
const slot = new MatrixEvent({
type: "m.rtc.slot",
sender: "@admin:example.org",
event_id: "$slot",
room_id: ROOM_ID,
state_key: "m.call#ROOM",
origin_server_ts: 2,
content: { status: "open" },
});
client.emit(RoomStateEvent.Events, slot, null, null);
expect(stateSink.emit).toHaveBeenCalledTimes(1);
expect(
JSON.parse(
(stateSink.emit.mock.calls[0] as unknown as [string[]])[0][0],
),
).toMatchObject({ type: "m.rtc.slot", state_key: "m.call#ROOM" });
// another room's state is not ours
client.emit(
RoomStateEvent.Events,
new MatrixEvent({ ...slot.event, room_id: "!other:example.org" }),
null,
null,
);
expect(stateSink.emit).toHaveBeenCalledTimes(1);
});
it("reports to-device messages with their Olm sender device and cross-signing verdict", async () => {
const { client, driver } = fullClient();
const toDevice = sink();
driver.subscribeToDeviceEvents(toDevice as unknown as ToDeviceSinkLike);
client.emit(ClientEvent.ReceivedToDeviceMessage, {
message: {
type: "m.rtc.encryption_key",
sender: "@a:example.org",
content: { member_id: "m-a" },
},
encryptionInfo: {
sender: "@a:example.org",
senderDevice: "ADEV",
senderCurve25519KeyBase64: "k",
},
});
await vi.waitFor(() => expect(toDevice.emit).toHaveBeenCalledTimes(1));
const [type, sender, json, origin, crossSigned] = toDevice.emit.mock
.calls[0] as unknown as [
string,
string,
string,
FfiEventOrigin,
boolean,
];
expect(type).toBe("m.rtc.encryption_key");
expect(sender).toBe("@a:example.org");
expect(JSON.parse(json)).toEqual({ member_id: "m-a" });
expect(senderDeviceOf(origin)).toBe("ADEV");
expect(crossSigned).toBe(true);
});
it("reports homeserver connectivity from the sync state", () => {
const { client, driver } = fullClient();
expect(driver.isHomeserverConnected()).toBe(true);
const connectivity = sink();
driver.subscribeConnectivity(
connectivity as unknown as ConnectivitySinkLike,
);
client.getSyncState.mockReturnValue(SyncState.Error);
client.emit(ClientEvent.Sync, SyncState.Error, SyncState.Syncing);
expect(connectivity.emit).toHaveBeenCalledWith(false);
expect(driver.isHomeserverConnected()).toBe(false);
client.getSyncState.mockReturnValue(SyncState.Syncing);
client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Error);
expect(connectivity.emit).toHaveBeenLastCalledWith(true);
});
});
describe("over a RoomWidgetClient", () => {
it("listens for the legacy to-device event and reports the claimed device", async () => {
const { client, driver } = widgetClient();
const toDevice = sink();
driver.subscribeToDeviceEvents(toDevice as unknown as ToDeviceSinkLike);
const event = new MatrixEvent({
type: "io.element.call.encryption_keys",
sender: "@a:example.org",
content: { member: { id: "m-a", claimed_device_id: "ADEV" } },
});
event.makeEncrypted("m.room.encrypted", {}, "", "");
client.emit(ClientEvent.ToDeviceEvent, event);
await vi.waitFor(() => expect(toDevice.emit).toHaveBeenCalledTimes(1));
const [, , , origin, crossSigned] = toDevice.emit.mock
.calls[0] as unknown as [
string,
string,
string,
FfiEventOrigin,
boolean | undefined,
];
expect(senderDeviceOf(origin)).toBe("ADEV");
expect(crossSigned).toBeUndefined();
});
it("cannot delegate through the homeserver, so the crate falls back to the service", async () => {
const { driver } = widgetClient();
await expect(
driver.delegateDelayedLeaveViaHomeserver({
sfuUrl: "wss://sfu.example.org",
livekitServiceUrl: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
delayId: "delay-1",
delayTimeoutMs: 3_600_000n,
}),
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
});
it("treats member events in an encrypted room as encrypted by the claimed device", async () => {
const { room, driver } = widgetClient();
const roomSink = sink();
driver.subscribeRoomEvents(roomSink as unknown as RoomEventSinkLike);
room.emit(
RoomStickyEventsEvent.Update,
[
new MatrixEvent({
type: "m.rtc.member",
sender: "@a:example.org",
event_id: "$s1",
room_id: ROOM_ID,
origin_server_ts: 1,
content: { slot_id: "m.call#ROOM", member: { device_id: "ADEV" } },
}),
],
[],
[],
);
await vi.waitFor(() => expect(roomSink.emit).toHaveBeenCalledTimes(1));
const origin = (
roomSink.emit.mock.calls[0] as unknown as [string, FfiEventOrigin]
)[1];
expect(senderDeviceOf(origin)).toBe("ADEV");
});
});
});
function fullClient(): {
client: FakeClient;
room: FakeRoom;
driver: JsSdkRtcMatrixDriver;
} {
const client = fakeClient(false);
const room = fakeRoom();
return {
client,
room,
driver: new JsSdkRtcMatrixDriver(asClient(client), asRoom(room)),
};
}
function widgetClient(): {
client: FakeClient;
room: FakeRoom;
driver: JsSdkRtcMatrixDriver;
} {
const client = fakeClient(true);
const room = fakeRoom();
return {
client,
room,
driver: new JsSdkRtcMatrixDriver(asClient(client), asRoom(room)),
};
}
type SinkEmit = ReturnType<typeof vi.fn<(...args: unknown[]) => boolean>>;
function sink(): { emit: SinkEmit } {
return { emit: vi.fn<(...args: unknown[]) => boolean>(() => true) };
}
/** The sender device an origin carries, if it is an encrypted one. */
function senderDeviceOf(origin: FfiEventOrigin): string | undefined {
return FfiEventOrigin.Encrypted.instanceOf(origin)
? origin.inner.senderDeviceId
: undefined;
}
+654
View File
@@ -0,0 +1,654 @@
/*
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.
*/
/**
* An {@link RtcMatrixDriver} over a matrix-js-sdk client: the crate's
* MatrixRTC seam, nothing else. The standalone app, the widget and the SDK
* build hand this to Element Call, and a host that already runs matrix-js-sdk
* can use it as it is.
*
* It serves two kinds of client. A full `MatrixClient` syncs, decrypts and
* has an access token of its own. A `RoomWidgetClient` is a shell over the
* widget API: it never sees ciphertext (its host decrypts), has no crypto
* backend and no token, only emits the legacy to-device event, and answers
* transport discovery over the widget API. Every place the two differ is
* marked "widget".
*
* Adapted from the crate's `web-test-app/src/jsSdkDriver.ts`.
*/
import {
ClientEvent,
type IOpenIDToken,
type MatrixClient,
type MatrixEvent,
MatrixError,
Method,
type ReceivedToDeviceMessage,
type Room,
RoomEvent,
RoomStateEvent,
RoomStickyEventsEvent,
RoomWidgetClient,
SyncState,
UnsupportedDelayedEventsEndpointError,
UnsupportedStickyEventsEndpointError,
UpdateDelayedEventAction,
parseErrorResponse,
} from "matrix-js-sdk";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import {
FfiEventOrigin,
RtcError,
type ConnectivitySinkLike,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type FfiHomeserverDelegationRequest,
type FfiTransportDelegationRequest,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../../matrix-rtc-sdk";
import { doNetworkOperationWithRetry } from "../../utils/matrix";
import { type RtcMatrixDriver } from "../RtcMatrixDriver";
export interface JsSdkRtcMatrixDriverOptions {
logger?: Logger;
}
export class JsSdkRtcMatrixDriver implements RtcMatrixDriver {
private readonly roomId: string;
private readonly logger: Logger;
/** Widget: the host decrypts for us and there is no crypto backend. */
private readonly widget: boolean;
private readonly detachers: (() => void)[] = [];
/** curve25519 sender key → device id, per megolm-attributed sender. */
private readonly senderDeviceCache = new Map<string, string>();
public constructor(
private readonly client: MatrixClient,
private readonly room: Room,
options: JsSdkRtcMatrixDriverOptions = {},
) {
this.roomId = room.roomId;
this.widget = client instanceof RoomWidgetClient;
this.logger = (options.logger ?? rootLogger).getChild(
`[JsSdkRtcMatrixDriver ${room.roomId}]`,
);
}
/** Unhooks every client listener. The crate's sinks stop being fed. */
public detach(): void {
for (const detach of this.detachers.splice(0)) detach();
}
// --- outbound --------------------------------------------------------------
public async sendStickyEvent(
roomId: string,
eventType: string,
contentJson: string,
durationMs: bigint,
): Promise<FfiSendEventResponse> {
return guard(async () => {
const res = await this.client._unstable_sendStickyEvent(
roomId,
Number(durationMs),
null,
eventType as never,
JSON.parse(contentJson) as never,
);
return { eventId: res.event_id, delayId: undefined };
});
}
public async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
): Promise<FfiSendEventResponse> {
return guard(async () => {
const res = await this.client.sendStateEvent(
roomId,
eventType as never,
JSON.parse(contentJson) as never,
stateKey,
);
return { eventId: res.event_id, delayId: undefined };
});
}
public async sendDelayedEvent(
roomId: string,
eventType: string,
contentJson: string,
delayMs: bigint,
stickyDurationMs: bigint | undefined,
): Promise<string> {
return guard(async () => {
const content = JSON.parse(contentJson) as never;
const res =
stickyDurationMs === undefined
? await this.client._unstable_sendDelayedEvent(
roomId,
{ delay: Number(delayMs) },
null,
eventType as never,
content,
)
: await this.client._unstable_sendStickyDelayedEvent(
roomId,
Number(stickyDurationMs),
{ delay: Number(delayMs) },
null,
eventType as never,
content,
);
return res.delay_id;
});
}
public async sendDelayedStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
delayMs: bigint,
): Promise<string> {
return guard(async () => {
const res = await this.client._unstable_sendDelayedStateEvent(
roomId,
{ delay: Number(delayMs) },
eventType as never,
JSON.parse(contentJson) as never,
stateKey,
);
return res.delay_id;
});
}
public async restartDelayedEvent(
_roomId: string,
delayId: string,
): Promise<void> {
await guard(async () =>
this.client._unstable_updateDelayedEvent(
delayId,
UpdateDelayedEventAction.Restart,
),
);
}
public async cancelDelayedEvent(
_roomId: string,
delayId: string,
): Promise<void> {
await guard(async () =>
this.client._unstable_updateDelayedEvent(
delayId,
UpdateDelayedEventAction.Cancel,
),
);
}
/**
* MSC4195 through the homeserver's CS API (a homeserver proxying
* `rtc/livekit/*` to the authorisation service, MSC4512). One
* authenticated request with the body lk-jwt-service 0.7 accepts; the
* crate decides when to make it and what to try when it fails. Widget: the
* widget client has no access token, so this is `Unsupported` there.
*/
public async delegateDelayedLeaveViaHomeserver(
request: FfiHomeserverDelegationRequest,
): Promise<void> {
if (this.widget)
throw new RtcError.Unsupported(
"A widget client cannot make authenticated homeserver requests",
);
await guard(async () => {
await this.client.http.authedRequest(
Method.Post,
"/rtc/livekit/delegate_delayed_leave",
undefined,
{
// The SFU our token named; the service checks it is its own.
url: request.sfuUrl,
room_id: request.roomId,
slot_id: request.slotId,
member: JSON.parse(request.memberJson) as MemberClaims,
delay_id: request.delayId,
// Optional once the homeserver can be asked for the delay by id.
delay_timeout: Number(request.delayTimeoutMs),
},
{ prefix: "/_matrix/client/unstable/io.element.msc4195" },
);
});
}
/**
* MSC4195 through the authorisation service: the token request Element Call
* has always made, with `delay_id`, `delay_timeout` and this client's
* homeserver URL added. The token in the answer is discarded.
*/
public async delegateDelayedLeaveViaTransport(
request: FfiTransportDelegationRequest,
): Promise<void> {
await guard(async () => {
await this.requestToken(
request.livekitServiceUrl,
request.roomId,
request.slotId,
JSON.parse(request.memberJson) as MemberClaims,
request.legacySfuGet,
{
delay_id: request.delayId,
delay_timeout: Number(request.delayTimeoutMs),
delay_cs_api_url: this.client.baseUrl,
},
);
});
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
eventType: string,
contentJson: string,
): Promise<FfiToDeviceDelivery[]> {
return guard(async () => {
// Olm-encrypted, per specific device — never `*`. On a widget client
// this asks the host to encrypt; the plain `sendToDevice` there would
// go out in clear.
await this.client.encryptAndSendToDevice(
eventType,
recipients,
JSON.parse(contentJson) as Record<string, unknown>,
);
return recipients.map((recipient) => ({ recipient, error: undefined }));
});
}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
return guard(async () => {
// The homeserver endpoint (MSC4143), or the widget host's answer to
// the same question (MSC4515) — the client knows which.
const transports = await doNetworkOperationWithRetry(async () =>
this.client._unstable_getRTCTransports(),
);
return transports.map(({ type, ...properties }: Transport) => ({
transportType: String(type),
propertiesJson: JSON.stringify(properties),
}));
});
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
return guard(async () => {
const token = await this.requestToken(
request.url,
request.roomId,
request.slotId,
JSON.parse(request.memberJson) as MemberClaims,
request.legacySfuGet,
);
return { jwt: token.jwt, url: token.url };
});
}
private async requestToken(
serviceUrl: string,
roomId: string,
slotId: string,
member: MemberClaims,
legacySfuGet: boolean,
delegation: Record<string, unknown> = {},
): Promise<{ jwt: string; url?: string }> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
this.client.getOpenIdToken(),
);
} catch (e) {
throw new RtcError.Http(`Could not get an OpenID token: ${String(e)}`);
}
const base = serviceUrl.replace(/\/$/, "");
const [endpoint, body] = legacySfuGet
? [
`${base}/sfu/get`,
{
// The legacy endpoint derives the LiveKit room alias from the
// Matrix room id alone.
room: roomId,
openid_token: openIdToken,
device_id: member.claimed_device_id,
...delegation,
},
]
: [
`${base}/get_token`,
{
room_id: roomId,
slot_id: slotId,
openid_token: openIdToken,
member,
...delegation,
},
];
const response = await doNetworkOperationWithRetry(async () =>
fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
if (!response.ok) {
const text = await response.text();
if (response.status === 404)
throw new RtcError.Unsupported(`${endpoint}: ${response.status}`);
throw parseErrorResponse(response, text);
}
return (await response.json()) as { jwt: string; url?: string };
}
// --- reads (the crate's session seed) ---------------------------------------
public async readEvents(
eventType: string,
_stateKey: string | undefined,
limit: number,
): Promise<string[]> {
const out: string[] = [];
for (const event of this.room._unstable_getStickyEvents()) {
await this.client.decryptEventIfNeeded(event);
if (event.getType() !== eventType) continue;
out.push(JSON.stringify(rawEvent(event)));
if (out.length >= limit) break;
}
return out;
}
public async readState(
eventType: string,
stateKey: string | undefined,
): Promise<string[]> {
const events =
stateKey === undefined
? this.room.currentState.getStateEvents(eventType)
: [this.room.currentState.getStateEvents(eventType, stateKey)].filter(
(e): e is MatrixEvent => Boolean(e),
);
return Promise.resolve(
events.map((event) => JSON.stringify(rawEvent(event))),
);
}
// --- inbound sinks ----------------------------------------------------------
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
const emit = async (event: MatrixEvent): Promise<void> => {
await this.client.decryptEventIfNeeded(event);
if (event.isDecryptionFailure()) {
this.logger.warn(`Event ${event.getId()} failed to decrypt; skipped`);
return;
}
if (
!sink.emit(
JSON.stringify(rawEvent(event)),
await this.roomEventOrigin(event),
)
)
detach();
};
// Sticky events come from the room's sticky store: sync delivers them in
// the room's `msc4354_sticky` section, our own included.
const onSticky = (
added: MatrixEvent[],
updated: { current: MatrixEvent }[],
): void => {
for (const event of [...added, ...updated.map((u) => u.current)])
void emit(event);
};
// Everything else (state events in the timeline) comes from the timeline;
// local echoes and sticky events are skipped there.
const onTimeline = (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
): void => {
if (room?.roomId !== this.roomId || toStartOfTimeline) return;
if (event.status !== null || event.unstableStickyInfo !== undefined)
return;
void emit(event);
};
const detach = (): void => {
this.room.off(RoomStickyEventsEvent.Update, onSticky);
this.client.off(RoomEvent.Timeline, onTimeline);
};
this.room.on(RoomStickyEventsEvent.Update, onSticky);
this.client.on(RoomEvent.Timeline, onTimeline);
this.detachers.push(detach);
}
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
// Client-level listener: the room-level re-emit is unreliable under
// MSC4222 `state_after` churn.
const onState = (event: MatrixEvent): void => {
if (event.getRoomId() !== this.roomId) return;
if (!sink.emit([JSON.stringify(rawEvent(event))])) detach();
};
const detach = (): void => {
this.client.off(RoomStateEvent.Events, onState);
};
this.client.on(RoomStateEvent.Events, onState);
this.detachers.push(detach);
}
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
if (this.widget) {
// Widget: the legacy event is the only one the widget client emits.
// The host decrypted the message; it says whether it was encrypted but
// not by which device, so the device is the one the content claims.
const onToDevice = (event: MatrixEvent): void => {
const content = event.getContent() as KeyMessageContent;
const origin = event.isEncrypted()
? new FfiEventOrigin.Encrypted({
senderDeviceId: claimedKeyDevice(content),
})
: new FfiEventOrigin.Cleartext();
if (
!sink.emit(
event.getType(),
event.getSender() ?? "",
JSON.stringify(content),
origin,
undefined,
)
)
detach();
};
const detach = (): void => {
this.client.off(ClientEvent.ToDeviceEvent, onToDevice);
};
this.client.on(ClientEvent.ToDeviceEvent, onToDevice);
this.detachers.push(detach);
return;
}
const onToDevice = (received: ReceivedToDeviceMessage): void =>
void handleToDevice(received);
const handleToDevice = async ({
message,
encryptionInfo,
}: ReceivedToDeviceMessage): Promise<void> => {
const origin = encryptionInfo
? new FfiEventOrigin.Encrypted({
senderDeviceId: encryptionInfo.senderDevice,
})
: new FfiEventOrigin.Cleartext();
// MSC4153: is the sending device cross-signed by its owner?
let crossSigned: boolean | undefined;
const crypto = this.client.getCrypto();
if (crypto && encryptionInfo?.senderDevice) {
const status = await crypto.getDeviceVerificationStatus(
encryptionInfo.sender,
encryptionInfo.senderDevice,
);
crossSigned = status?.signedByOwner ?? false;
}
if (
!sink.emit(
message.type,
message.sender,
JSON.stringify(message.content ?? {}),
origin,
crossSigned,
)
)
detach();
};
const detach = (): void => {
this.client.off(ClientEvent.ReceivedToDeviceMessage, onToDevice);
};
this.client.on(ClientEvent.ReceivedToDeviceMessage, onToDevice);
this.detachers.push(detach);
}
// --- connectivity ------------------------------------------------------------
public isHomeserverConnected(): boolean {
// Widget: the widget client reports Syncing once it has seen an event
// and never anything else, so on a widget this is always true.
return this.client.getSyncState() === SyncState.Syncing;
}
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
const onSync = (): void => {
if (!sink.emit(this.isHomeserverConnected())) detach();
};
const detach = (): void => {
this.client.off(ClientEvent.Sync, onSync);
};
this.client.on(ClientEvent.Sync, onSync);
this.detachers.push(detach);
}
private async roomEventOrigin(event: MatrixEvent): Promise<FfiEventOrigin> {
if (this.widget) {
// Widget: events arrive decrypted with no metadata. In an encrypted
// room they were encrypted, by the device the content claims — the
// same trust matrix-js-sdk's own session extends.
if (event.isState() || !this.room.hasEncryptionStateEvent())
return new FfiEventOrigin.Cleartext();
return new FfiEventOrigin.Encrypted({
senderDeviceId: claimedMemberDevice(event.getContent()),
});
}
if (!event.isEncrypted()) return new FfiEventOrigin.Cleartext();
return new FfiEventOrigin.Encrypted({
senderDeviceId: await this.senderDeviceOf(event),
});
}
/** The device that megolm-encrypted `event` (sender key → device list). */
private async senderDeviceOf(
event: MatrixEvent,
): Promise<string | undefined> {
const senderKey = event.getSenderKey();
const sender = event.getSender();
const crypto = this.client.getCrypto();
if (!senderKey || !sender || !crypto) return undefined;
const cached = this.senderDeviceCache.get(senderKey);
if (cached) return cached;
const devices = await crypto.getUserDeviceInfo([sender], true);
for (const device of devices.get(sender)?.values() ?? []) {
if (device.getIdentityKey() === senderKey) {
this.senderDeviceCache.set(senderKey, device.deviceId);
return device.deviceId;
}
}
return undefined;
}
}
/** The full (decrypted) event object the crate's dispatch reads. */
function rawEvent(event: MatrixEvent): Record<string, unknown> {
return {
...(event.event as Record<string, unknown>),
type: event.getType(),
content: event.getContent(),
sender: event.getSender(),
event_id: event.getId(),
room_id: event.getRoomId(),
origin_server_ts: event.getTs(),
state_key: event.getStateKey(),
};
}
/** MSC4195 member claims, as the crate serialises them. */
interface MemberClaims {
id: string;
claimed_user_id: string;
claimed_device_id: string;
}
/** The device a media key message claims to come from, in either dialect. */
interface KeyMessageContent {
member?: { claimed_device_id?: string };
device_id?: string;
}
function claimedKeyDevice(content: KeyMessageContent): string | undefined {
return content.member?.claimed_device_id ?? content.device_id;
}
/** The device a 2025-dialect membership claims (`member.device_id`). */
function claimedMemberDevice(
content: Record<string, unknown>,
): string | undefined {
const member = content.member as { device_id?: unknown } | undefined;
return typeof member?.device_id === "string" ? member.device_id : undefined;
}
/** Map js-sdk / HTTP failures onto the error the crate reasons about. */
function toRtcError(error: unknown): Error {
if (RtcError.instanceOf(error)) return error;
if (
error instanceof UnsupportedDelayedEventsEndpointError ||
error instanceof UnsupportedStickyEventsEndpointError
)
return new RtcError.Unsupported(String(error));
if (error instanceof MatrixError) {
if (error.errcode === "M_LIMIT_EXCEEDED")
return new RtcError.RateLimited({
retryAfterMs:
typeof error.data.retry_after_ms === "number"
? BigInt(error.data.retry_after_ms)
: undefined,
});
if (error.httpStatus === 404 || error.errcode === "M_UNRECOGNIZED")
return new RtcError.Unsupported(String(error));
if (error.httpStatus === 403 || error.errcode === "M_FORBIDDEN")
return new RtcError.Rejected(String(error));
return new RtcError.Http(String(error));
}
return new RtcError.Driver(String(error));
}
async function guard<T>(f: () => Promise<T>): Promise<T> {
try {
return await f();
} catch (error) {
throw toRtcError(error);
}
}
+151
View File
@@ -0,0 +1,151 @@
/*
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.
*/
// Fakes shared by the js-sdk driver tests. The drivers tell the two client
// kinds apart by prototype, so each fake is created on the real prototype
// and given only the members it needs; EventEmitter sits at the bottom of
// that chain, so `on`/`emit` work.
import { EventEmitter } from "events";
import {
MatrixClient,
type Room,
RoomWidgetClient,
SyncState,
User,
} from "matrix-js-sdk";
import { vi } from "vitest";
export const ROOM_ID = "!room:example.org";
export const ME = "@me:example.org";
export const MY_DEVICE = "MYDEV";
export const LK = "https://lk.example.org";
export const openIdToken = {
access_token: "openid",
expires_in: 3600,
matrix_server_name: "example.org",
token_type: "Bearer",
};
type Fn = ReturnType<typeof vi.fn>;
export interface FakeClient extends EventEmitter {
_unstable_sendStickyEvent: Fn;
_unstable_sendDelayedEvent: Fn;
_unstable_sendStickyDelayedEvent: Fn;
_unstable_sendDelayedStateEvent: Fn;
_unstable_updateDelayedEvent: Fn;
_unstable_getRTCTransports: Fn;
sendStateEvent: Fn;
encryptAndSendToDevice: Fn;
sendEvent: Fn;
redactEvent: Fn;
getSyncState: Fn;
getUser: (userId: string) => User | null;
http: { authedRequest: Fn };
}
export interface FakeRoom extends EventEmitter {
name: string;
}
function clientMembers(widget: boolean): Record<string, unknown> {
const user = new User(ME);
user.rawDisplayName = "Me";
user.avatarUrl = "mxc://example.org/me";
const verification = { signedByOwner: true };
return {
baseUrl: "https://hs.example.org",
getUserId: () => ME,
getDeviceId: () => MY_DEVICE,
getAccessToken: () => (widget ? null : "token"),
getCrypto: () =>
widget
? undefined
: {
getVersion: () => "fake 1.0",
getDeviceVerificationStatus: async () =>
Promise.resolve(verification),
getUserDeviceInfo: async () => Promise.resolve(new Map()),
},
getSyncState: vi.fn(() => SyncState.Syncing),
getUser: () => user,
getOpenIdToken: async () => Promise.resolve(openIdToken),
decryptEventIfNeeded: async () => Promise.resolve(),
doesServerSupportUnstableFeature: async () => Promise.resolve(true),
mxcUrlToHttp: (mxc: string) => `https://hs.example.org/media/${mxc}`,
_unstable_sendStickyEvent: vi.fn(async () =>
Promise.resolve({ event_id: "$sticky" }),
),
_unstable_sendDelayedEvent: vi.fn(async () =>
Promise.resolve({ delay_id: "delay-plain" }),
),
_unstable_sendStickyDelayedEvent: vi.fn(async () =>
Promise.resolve({ delay_id: "delay-sticky" }),
),
_unstable_sendDelayedStateEvent: vi.fn(async () =>
Promise.resolve({ delay_id: "delay-state" }),
),
_unstable_updateDelayedEvent: vi.fn(async () => Promise.resolve({})),
_unstable_getRTCTransports: vi.fn(async () => Promise.resolve([])),
sendStateEvent: vi.fn(async () => Promise.resolve({ event_id: "$state" })),
encryptAndSendToDevice: vi.fn(async () => Promise.resolve()),
sendEvent: vi.fn(async () => Promise.resolve({ event_id: "$sent" })),
redactEvent: vi.fn(async () => Promise.resolve({ event_id: "$redaction" })),
};
}
export function fakeRoom(): FakeRoom {
const room = new EventEmitter() as FakeRoom;
const alice = {
userId: "@a:example.org",
rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://example.org/alice",
};
const bob = {
userId: "@b:example.org",
rawDisplayName: undefined,
getMxcAvatarUrl: () => undefined,
};
Object.assign(room, {
roomId: ROOM_ID,
name: "Standup",
getCanonicalAlias: () => "#standup:example.org",
getMxcAvatarUrl: () => "mxc://example.org/room",
hasEncryptionStateEvent: () => true,
currentState: {
getJoinRule: () => "public",
getStateEvents: () => [],
maySendStateEvent: () => true,
},
getMembersWithMembership: (membership: string) =>
membership === "join" ? [alice] : [bob],
_unstable_getStickyEvents: () => [],
relations: { getChildEventsForEvent: () => undefined },
});
return room;
}
export function fakeClient(widget: boolean): FakeClient {
const client = Object.create(
widget ? RoomWidgetClient.prototype : MatrixClient.prototype,
) as FakeClient;
Object.assign(client, clientMembers(widget));
return client;
}
export const asClient = (client: FakeClient): MatrixClient =>
client as unknown as MatrixClient;
export const asRoom = (room: FakeRoom): Room => room as unknown as Room;
export function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
+37
View File
@@ -0,0 +1,37 @@
/*
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 { type MatrixClient, type Room } from "matrix-js-sdk";
import { useEffect, useMemo } from "react";
import { type MatrixDrivers } from "../MatrixDriverContext";
import { JsSdkElementCallMatrixClientDriver } from "./JsSdkElementCallMatrixClientDriver";
import { JsSdkRtcMatrixDriver } from "./JsSdkRtcMatrixDriver";
/**
* The two matrix-js-sdk drivers for a room, for as long as the client and
* room stay the same. The RTC driver hooks listeners on the client for the
* crate's sinks; they are let go of when the drivers are replaced or the
* caller unmounts.
*/
export function useJsSdkDrivers(
client: MatrixClient,
room: Room,
): MatrixDrivers {
const drivers = useMemo(
(): MatrixDrivers => ({
rtcDriver: new JsSdkRtcMatrixDriver(client, room),
clientDriver: new JsSdkElementCallMatrixClientDriver(client, room),
}),
[client, room],
);
useEffect(
() => (): void => (drivers.rtcDriver as JsSdkRtcMatrixDriver).detach(),
[drivers],
);
return drivers;
}
+29
View File
@@ -0,0 +1,29 @@
/*
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 { Observable } from "rxjs";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
import { type Unsubscribe } from "./ElementCallMatrixClientDriver";
/**
* Turns one of a driver's `get` / `subscribe` pairs into a behavior owned by
* `scope`: the current value now, every change until the scope ends.
*/
export function observeDriver<T>(
scope: ObservableScope,
get: () => T,
subscribe: (listener: (value: T) => void) => Unsubscribe,
): Behavior<T> {
return scope.behavior(
new Observable<T>((subscriber) =>
subscribe((value) => subscriber.next(value)),
),
get(),
);
}
+77
View File
@@ -0,0 +1,77 @@
/*
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 { KeyProviderEvent } from "livekit-client";
import { testScope } from "../utils/test";
import {
FakeParticipation,
fakeMediaKey,
fakeMembership,
} from "../utils/test-participation";
import { waitFor } from "../driver/MockRtcMatrixDriver";
import { ParticipationKeyProvider } from "./participationKeyProvider";
interface SetKey {
participantIdentity: string | undefined;
keyIndex: number | undefined;
}
function attached(): {
rtcParticipationManager: FakeParticipation;
setKeys: SetKey[];
} {
const rtcParticipationManager = new FakeParticipation();
const provider = new ParticipationKeyProvider();
const setKeys: SetKey[] = [];
provider.on(KeyProviderEvent.SetKey, ({ participantIdentity, keyIndex }) =>
setKeys.push({ participantIdentity, keyIndex }),
);
provider.attach(testScope(), rtcParticipationManager);
return { rtcParticipationManager, setKeys };
}
describe("ParticipationKeyProvider", () => {
it("hands our own key to LiveKit under our transport identity", async () => {
const { rtcParticipationManager, setKeys } = attached();
rtcParticipationManager.ownMemberId$.next("m-me");
rtcParticipationManager.ownTransportIdentity$.next("lk-me");
rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-me", index: 0 }),
]);
await waitFor("own key set", () => setKeys.length === 1);
expect(setKeys).toEqual([{ participantIdentity: "lk-me", keyIndex: 0 }]);
});
it("waits for a peer's transport identity and sets each key once", async () => {
const { rtcParticipationManager, setKeys } = attached();
// The key arrives before the roster knows the member's identity.
rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-peer", index: 2 }),
]);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(setKeys).toEqual([]);
rtcParticipationManager.setMemberships([
fakeMembership({
member: { memberId: "m-peer" },
transportIdentity: "lk-peer",
}),
]);
await waitFor("peer key set", () => setKeys.length === 1);
expect(setKeys).toEqual([{ participantIdentity: "lk-peer", keyIndex: 2 }]);
// The map is re-emitted (a rotation elsewhere): no second delivery.
rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-peer", index: 2 }),
fakeMediaKey({ memberId: "m-peer", index: 3 }),
]);
await waitFor("next index set", () => setKeys.length === 2);
expect(setKeys[1]).toEqual({ participantIdentity: "lk-peer", keyIndex: 3 });
});
});
+92
View File
@@ -0,0 +1,92 @@
/*
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 { BaseKeyProvider } from "livekit-client";
import { combineLatest } from "rxjs";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../state/Behavior";
import { type Epoch, type ObservableScope } from "../state/ObservableScope";
import { type FfiMediaKey, type FfiMembership } from "../matrix-rtc-sdk";
/** What this provider needs from a {@link RtcParticipationManager}. */
export interface ParticipationKeys {
/** Every media key in use, ours and theirs, one per (member, index). */
keyMap$: Behavior<FfiMediaKey[]>;
memberships$: Behavior<Epoch<FfiMembership[]>>;
ownMemberId$: Behavior<string | null>;
ownTransportIdentity$: Behavior<string | null>;
}
/**
* Feeds the crate's media keys to livekit-client's E2EE workers.
*
* The crate exchanges keys per member id; LiveKit encrypts per participant
* identity. A key is handed over as soon as both are known — a key that
* arrives before its member's transport identity waits on the roster rather
* than being dropped — and once per (member, index, identity), so a
* re-emitted map does not churn the key ring.
*/
export class ParticipationKeyProvider extends BaseKeyProvider {
private readonly logger: Logger;
private readonly applied = new Set<string>();
public constructor() {
super({ ratchetWindowSize: 10, keyringSize: 256 });
this.logger = rootLogger.getChild("[ParticipationKeyProvider]");
}
/** Follow the participation's keys for as long as `scope` lives. */
public attach(
scope: ObservableScope,
rtcParticipationManager: ParticipationKeys,
): void {
combineLatest([
rtcParticipationManager.keyMap$,
rtcParticipationManager.memberships$,
rtcParticipationManager.ownMemberId$,
rtcParticipationManager.ownTransportIdentity$,
])
.pipe(scope.bind())
.subscribe(([keys, memberships, ownMemberId, ownIdentity]) => {
for (const key of keys) {
const identity =
key.memberId === ownMemberId
? ownIdentity
: memberships.value.find(
(m) => m.member.memberId === key.memberId,
)?.transportIdentity;
if (!identity) continue;
const tag = `${key.memberId}|${key.index}|${identity}`;
if (this.applied.has(tag)) continue;
this.applied.add(tag);
void this.setKey(key, identity);
}
});
}
private async setKey(key: FfiMediaKey, identity: string): Promise<void> {
try {
const material = await crypto.subtle.importKey(
"raw",
key.key,
"HKDF",
false,
["deriveBits", "deriveKey"],
);
this.onSetEncryptionKey(material, identity, key.index);
this.logger.debug(
`Set key for participant ${identity} (member ${key.memberId}) index ${key.index}`,
);
} catch (e) {
this.logger.error(
`Failed to import the key of member ${key.memberId} index ${key.index}`,
e,
);
}
}
}
+27 -8
View File
@@ -87,8 +87,16 @@ export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { client } = useClient();
/**
* The encryption system for a room whose `m.room.encryption` state the
* caller already knows: a shared secret from the URL or storage wins, else
* per-participant keys in an encrypted room, else nothing. The call tree
* reads `roomEncrypted` from the client driver's room info.
*/
export function useEncryptionSystemFor(
roomId: string,
roomEncrypted: boolean,
): EncryptionSystem {
const { roomId: paramsRoomId, password } = useUrlParams();
const [storedPassword] = useRoomSharedKey(
@@ -100,18 +108,29 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
);
const room = client?.getRoom(roomId);
const e2eeSystem = <EncryptionSystem>useMemo(() => {
if (!room) return { kind: E2eeType.NONE };
return <EncryptionSystem>useMemo(() => {
if (storedPassword)
return {
kind: E2eeType.SHARED_KEY,
secret: storedPassword,
};
if (room.hasEncryptionStateEvent()) {
if (roomEncrypted) {
return { kind: E2eeType.PER_PARTICIPANT };
}
return { kind: E2eeType.NONE };
}, [room, storedPassword]);
return e2eeSystem;
}, [roomEncrypted, storedPassword]);
}
/** {@link useEncryptionSystemFor} over the client's room; nothing for a room it does not know. */
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { client } = useClient();
const room = client?.getRoom(roomId);
const e2eeSystem = useEncryptionSystemFor(
roomId,
room?.hasEncryptionStateEvent() ?? false,
);
return useMemo(
() => (room ? e2eeSystem : { kind: E2eeType.NONE }),
[room, e2eeSystem],
);
}
+199
View File
@@ -0,0 +1,199 @@
/*
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 { beforeAll, describe, expect, it, onTestFinished, vi } from "vitest";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
import { installMatrixRtcLogSink, matrixRtcLogSink } from "./logSink";
import {
FfiLogLevel,
setLogSink,
FfiElementCallCompat,
FfiMatrixDriver,
FfiParticipationManager,
FfiStatus,
computeSessionsFromEvents,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type MatrixDriverCallback,
} from ".";
const ROOM_ID = "!room:example.org";
describe("matrix-rtc-sdk", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
it("computes a session from raw events without a manager", () => {
const join = JSON.stringify({
type: "m.rtc.member",
sender: "@alice:example.org",
event_id: "$1",
room_id: ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: 240_000 },
content: {
slot_id: "m.call#ROOM",
msc4354_sticky_key: "m-1",
member: { id: "m-1", membership: "join" },
application: { type: "m.call" },
transports: {
published: [
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
],
can_subscribe: ["livekit"],
},
},
});
const [session] = computeSessionsFromEvents(
[join],
FfiElementCallCompat.Off,
);
expect(session.memberCount).toBe(1);
expect(session.members[0].eventId).toBe("$1");
expect(session.isActive).toBe(true);
});
it("constructs a manager over a TypeScript driver and starts disconnected", () => {
const driver = new FfiMatrixDriver(new InertDriver());
const manager = new FfiParticipationManager(
ROOM_ID,
"m.call#ROOM",
"@me:example.org",
"MYDEV",
driver,
{
compat: FfiElementCallCompat.Off,
manageMediaKeys: false,
requireCrossSignedSender: false,
useKeyDelayMs: 1000n,
},
);
expect(FfiStatus.Disconnected.instanceOf(manager.status())).toBe(true);
expect(manager.memberships()).toEqual([]);
expect(manager.ownTransportIdentity()).toBeUndefined();
manager.uniffiDestroy();
});
it("routes the crate's log lines to the installed sink", () => {
const lines: { level: FfiLogLevel; target: string; message: string }[] = [];
setLogSink(
{
log: (level, target, message) => {
lines.push({ level, target, message });
},
},
FfiLogLevel.Debug,
);
// Put the default sink back for the other suites.
onTestFinished(() => installMatrixRtcLogSink());
const driver = new FfiMatrixDriver(new InertDriver());
const manager = new FfiParticipationManager(
ROOM_ID,
"m.call#ROOM",
"@me:example.org",
"MYDEV",
driver,
{
compat: FfiElementCallCompat.Off,
manageMediaKeys: false,
requireCrossSignedSender: false,
useKeyDelayMs: 1000n,
},
);
manager.uniffiDestroy();
const created = lines.find((l) => l.message.includes("session created"));
expect(created).toBeDefined();
expect(created?.level).toBe(FfiLogLevel.Info);
expect(created?.target).toMatch(/^matrix_rtc::/);
});
it("maps the crate's levels onto a matrix-js-sdk logger", () => {
const target = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
};
const sink = matrixRtcLogSink(target as unknown as Logger);
sink.log(FfiLogLevel.Error, "matrix_rtc::a", "boom");
sink.log(FfiLogLevel.Warn, "matrix_rtc::a", "hm");
sink.log(FfiLogLevel.Info, "matrix_rtc::a", "fyi");
sink.log(FfiLogLevel.Debug, "matrix_rtc::a", "dbg");
sink.log(FfiLogLevel.Trace, "matrix_rtc::a", "trc");
expect(target.error).toHaveBeenCalledWith("[matrix_rtc::a] boom");
expect(target.warn).toHaveBeenCalledWith("[matrix_rtc::a] hm");
expect(target.info).toHaveBeenCalledWith("[matrix_rtc::a] fyi");
expect(target.debug).toHaveBeenCalledWith("[matrix_rtc::a] dbg");
expect(target.trace).toHaveBeenCalledWith("[matrix_rtc::a] trc");
});
});
/** A driver that answers every read with nothing and never sends. */
class InertDriver implements MatrixDriverCallback {
public async sendStickyEvent(): Promise<FfiSendEventResponse> {
return Promise.resolve({ eventId: "$sticky", delayId: undefined });
}
public async sendStateEvent(): Promise<FfiSendEventResponse> {
return Promise.resolve({ eventId: "$state", delayId: undefined });
}
public async sendDelayedEvent(): Promise<string> {
return Promise.resolve("delay");
}
public async sendDelayedStateEvent(): Promise<string> {
return Promise.resolve("delay");
}
public async restartDelayedEvent(): Promise<void> {
return Promise.resolve();
}
public async cancelDelayedEvent(): Promise<void> {
return Promise.resolve();
}
public async delegateDelayedLeaveViaHomeserver(): Promise<void> {
return Promise.resolve();
}
public async delegateDelayedLeaveViaTransport(): Promise<void> {
return Promise.resolve();
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
): Promise<FfiToDeviceDelivery[]> {
return Promise.resolve(
recipients.map((recipient) => ({ recipient, error: undefined })),
);
}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
return Promise.resolve([]);
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
return Promise.resolve({ jwt: "jwt", url: request.url });
}
public async readEvents(): Promise<string[]> {
return Promise.resolve([]);
}
public async readState(): Promise<string[]> {
return Promise.resolve([]);
}
public subscribeRoomEvents(): void {}
public subscribeToDeviceEvents(): void {}
public subscribeStateUpdates(): void {}
public subscribeConnectivity(): void {}
public isHomeserverConnected(): boolean {
return true;
}
}
+112
View File
@@ -0,0 +1,112 @@
/*
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.
*/
/**
* The MatrixRTC SDK: the Rust `matrix-rtc` crate through its uniffi web
* bindings, shipped as the `@element-hq/matrix-rtc` npm package. Everything
* Element Call needs to *participate* in a MatrixRTC session — the session
* projection, our own membership with its keep-alive, transport tokens and
* the media key exchange — lives in the crate; this module loads it and
* re-exports the surface Element Call uses.
*
* Nothing else imports the package directly: the boot below is the one place
* the wasm is loaded and the crate's logging is wired up, so a caller cannot
* end up with a silent or half-initialised SDK.
*/
import { initAsync, type WasmSource } from "@element-hq/matrix-rtc";
import { installMatrixRtcLogSink } from "./logSink";
export {
FfiMatrixDriver,
FfiParticipationManager,
FfiElementCallCompat,
FfiEventOrigin,
FfiStatus,
FfiDelegationRoute,
FfiDeviceAttribution,
FfiDisconnectCause,
FfiImpairment,
FfiJoinError,
FfiKeepAlive,
FfiLogLevel,
setLogSink,
FfiMembershipState,
FfiTransportIntent,
RtcError,
computeSessionsFromEvents,
} from "@element-hq/matrix-rtc";
export type {
FfiConnectionData,
FfiConnectionWithMembers,
FfiHomeserverDelegationRequest,
FfiJoinParams,
FfiLivekitToken,
FfiLivekitTokenRequest,
FfiMediaKey,
FfiMember,
FfiMembership,
FfiParticipationConfig,
FfiRtcTransport,
FfiSendEventResponse,
FfiSessionSnapshot,
FfiToDeviceDelivery,
FfiToDeviceRecipient,
FfiTransportDelegationRequest,
ConnectivitySinkLike,
LogSink,
MatrixDriverCallback,
RoomEventSinkLike,
StateUpdateSinkLike,
ToDeviceSinkLike,
} from "@element-hq/matrix-rtc";
/**
* Where to load the wasm from. A URL (or anything `fetch` accepts) in a
* browser; bytes or a compiled module where there is nothing to fetch from,
* such as tests.
*/
export type MatrixRtcWasmSource = WasmSource;
let loading: Promise<void> | null = null;
/**
* Loads and initialises the SDK. Idempotent: the first call decides the
* source, later calls await the same load.
*
* Without a `source`, the wasm is the package's own copy as the bundler
* placed it (an asset URL in the app builds). A host that serves the file
* from somewhere else, or a test runner with no server, passes its own.
*/
export async function initMatrixRtcSdk(
source?: MatrixRtcWasmSource,
): Promise<void> {
loading ??= (async (): Promise<void> => {
await initAsync(source ?? (await bundledWasm()));
// The crate is silent until told where to log.
installMatrixRtcLogSink();
})();
await loading;
}
/**
* The wasm as the bundler placed it, resolved lazily so nothing is fetched
* until needed. Asked for explicitly, rather than leaving the package to its
* `import.meta.url` default, so that the dev server's dependency
* pre-bundling, which rewrites module URLs, cannot lose track of it.
*
* `no-inline` because the library build would otherwise embed the wasm as a
* base64 `data:` URL: a megabyte in the JavaScript, and one a host's Content
* Security Policy may well refuse to `fetch` (Element Web's does). As a file
* next to the bundle, referenced through `import.meta.url`, the host's own
* bundler copies it along with everything else.
*/
async function bundledWasm(): Promise<string> {
const { default: url } =
await import("@element-hq/matrix-rtc/wasm?url&no-inline");
return url;
}
+55
View File
@@ -0,0 +1,55 @@
/*
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 { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { FfiLogLevel, type LogSink, setLogSink } from "@element-hq/matrix-rtc";
/**
* A {@link LogSink} that writes the crate's lines to a matrix-js-sdk logger,
* so they land in the same console and rageshake as everything else, under
* the logger's own level filtering. Each line names the Rust module it came
* from.
*/
export function matrixRtcLogSink(target: Logger): LogSink {
return {
log(level, module, message): void {
const line = `[${module}] ${message}`;
switch (level) {
case FfiLogLevel.Error:
target.error(line);
break;
case FfiLogLevel.Warn:
target.warn(line);
break;
case FfiLogLevel.Info:
target.info(line);
break;
case FfiLogLevel.Debug:
target.debug(line);
break;
case FfiLogLevel.Trace:
target.trace(line);
break;
}
},
};
}
/**
* Routes the crate's log lines to `[matrix-rtc]` under Element Call's root
* logger. `maxLevel` is the crate-side cut-off: what it does not format it
* does not send, so trace stays off unless asked for.
*/
export function installMatrixRtcLogSink(
maxLevel: FfiLogLevel = FfiLogLevel.Debug,
): void {
setLogSink(
matrixRtcLogSink(rootLogger.getChild("[matrix-rust-rtc]")),
maxLevel,
);
}
+33
View File
@@ -0,0 +1,33 @@
/*
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 { useEffect, useState } from "react";
import {
type OwnProfile,
type ProfileDriver,
} from "../driver/ElementCallMatrixClientDriver";
import { useMatrixDrivers } from "../driver/MatrixDriverContext";
/** The user's own display name and avatar from a profile driver, kept current. */
export function useOwnProfileFrom(profile: ProfileDriver): OwnProfile {
const [own, setOwn] = useState(() => profile.getOwnProfile());
useEffect(() => {
setOwn(profile.getOwnProfile());
return profile.subscribeOwnProfile(setOwn);
}, [profile]);
return own;
}
/**
* {@link useOwnProfileFrom} over the client driver the host provided. The
* read-only counterpart of `useProfile(client)`, for the call tree; editing
* stays with the profile settings, which a host may not offer.
*/
export function useOwnProfile(): OwnProfile {
return useOwnProfileFrom(useMatrixDrivers().clientDriver);
}
@@ -0,0 +1,158 @@
/*
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 { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import { testScope } from "../utils/test";
import { FakeParticipation, fakeMembership } from "../utils/test-participation";
import { ElementCallReactionEventType, type RaisedHandInfo } from ".";
import { ParticipationReactionsReader } from "./ParticipationReactionsReader";
const alice = fakeMembership({
member: {
memberId: "m-alice",
userId: "@alice:example.org",
deviceId: "ALICE",
eventId: "$alice-join",
},
});
const aliceId = "@alice:example.org:ALICE";
function raisedHand(
timeline: MockElementCallMatrixClientDriver,
member = alice,
eventId = "$hand",
): void {
timeline.emitTimelineEvent({
eventId,
type: "m.reaction",
sender: member.member.userId,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: member.member.eventId,
key: "🖐️",
},
},
originServerTs: 1000,
});
}
function setUp(): {
rtcParticipationManager: FakeParticipation;
timeline: MockElementCallMatrixClientDriver;
hands: () => Record<string, RaisedHandInfo>;
reactions: () => string[];
} {
const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver();
const reader = new ParticipationReactionsReader(
testScope(),
rtcParticipationManager,
timeline,
);
let hands: Record<string, RaisedHandInfo> = {};
reader.raisedHands$.subscribe((h) => (hands = h));
let reactions: string[] = [];
reader.reactions$.subscribe(
(r) => (reactions = Object.values(r).map((v) => v.reactionOption.emoji)),
);
return {
rtcParticipationManager,
timeline,
hands: () => hands,
reactions: () => reactions,
};
}
describe("ParticipationReactionsReader", () => {
it("raises and lowers a hand with the member's reaction and its redaction", () => {
const { rtcParticipationManager, timeline, hands } = setUp();
rtcParticipationManager.setMemberships([alice]);
raisedHand(timeline);
expect(hands()).toEqual({
[aliceId]: {
membershipEventId: "$alice-join",
reactionEventId: "$hand",
time: new Date(1000),
},
});
timeline.emitTimelineEvent({
eventId: "$redaction",
type: "m.room.redaction",
sender: alice.member.userId,
content: {},
originServerTs: 2000,
redacts: "$hand",
});
expect(hands()).toEqual({});
});
it("ignores a reaction that does not relate to the sender's own membership", () => {
const { rtcParticipationManager, timeline, hands } = setUp();
rtcParticipationManager.setMemberships([alice]);
timeline.emitTimelineEvent({
eventId: "$forged",
type: "m.reaction",
sender: "@mallory:example.org",
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$alice-join",
key: "🖐️",
},
},
originServerTs: 1000,
});
expect(hands()).toEqual({});
});
it("picks up a hand raised before we looked, and drops it when the member leaves", () => {
const { rtcParticipationManager, timeline, hands } = setUp();
// The reaction is already in the room when the roster arrives.
raisedHand(timeline);
expect(hands()).toEqual({});
rtcParticipationManager.setMemberships([alice]);
expect(Object.keys(hands())).toEqual([aliceId]);
rtcParticipationManager.setMemberships([]);
expect(hands()).toEqual({});
});
it("re-resolves a hand when the member re-sends their membership", () => {
const { rtcParticipationManager, timeline, hands } = setUp();
rtcParticipationManager.setMemberships([alice]);
raisedHand(timeline);
expect(Object.keys(hands())).toEqual([aliceId]);
// A new membership event without a hand on it: the hand goes.
const resent = fakeMembership({
member: { ...alice.member, eventId: "$alice-join-2" },
});
rtcParticipationManager.setMemberships([resent]);
expect(hands()).toEqual({});
// Raised again on the new event: back.
raisedHand(timeline, resent, "$hand-2");
expect(hands()[aliceId]?.membershipEventId).toBe("$alice-join-2");
});
it("shows a reaction keyed by the member's media id", () => {
const { rtcParticipationManager, timeline, reactions } = setUp();
rtcParticipationManager.setMemberships([alice]);
timeline.emitTimelineEvent({
eventId: "$reaction",
type: ElementCallReactionEventType,
sender: alice.member.userId,
content: {
"m.relates_to": { rel_type: "m.reference", event_id: "$alice-join" },
emoji: "🎉",
name: "party",
},
originServerTs: 1000,
});
expect(reactions()).toEqual(["🎉"]);
});
});
@@ -0,0 +1,250 @@
/*
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 { BehaviorSubject, delay } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import {
ElementCallReactionEventType,
GenericReaction,
type RaisedHandInfo,
type ReactionInfo,
ReactionSet,
} from ".";
import { REACTION_ACTIVE_TIME_MS } from "./ReactionsReader";
import { type Behavior } from "../state/Behavior";
import { type Epoch, type ObservableScope } from "../state/ObservableScope";
import { type FfiMembership } from "../matrix-rtc-sdk";
import {
type TimelineDriver,
type TimelineEvent,
} from "../driver/ElementCallMatrixClientDriver";
import { memberMediaId } from "../state/rtc/mediaId";
const RAISED_HAND_KEY = "🖐️";
const REACTION_EVENT_TYPE = "m.reaction";
const REDACTION_EVENT_TYPE = "m.room.redaction";
/** What the reader needs from a {@link RtcParticipationManager}. */
export interface ParticipationReactionsSource {
memberships$: Behavior<Epoch<FfiMembership[]>>;
}
interface Relation {
rel_type?: string;
event_id?: string;
key?: string;
}
function relationOf(content: Record<string, unknown>): Relation | undefined {
return content["m.relates_to"] as Relation | undefined;
}
/**
* Raised hands and reactions over a {@link RtcParticipationManager} and the client
* driver's timeline: the counterpart of {@link ReactionsReader}, which reads
* the same from a matrix-js-sdk session.
*
* Hands and reactions relate to the sender's current membership event, as
* they always have. Both outputs are keyed by the member's media id
* (`${userId}:${deviceId}`), which is what the tiles are keyed by.
*/
export class ParticipationReactionsReader {
private readonly raisedHandsSubject$ = new BehaviorSubject<
Record<string, RaisedHandInfo>
>({});
public readonly raisedHands$ = this.raisedHandsSubject$.asObservable();
private readonly reactionsSubject$ = new BehaviorSubject<
Record<string, ReactionInfo>
>({});
public readonly reactions$ = this.reactionsSubject$.asObservable();
private memberships: FfiMembership[] = [];
public constructor(
scope: ObservableScope,
rtcParticipationManager: ParticipationReactionsSource,
private readonly timeline: Pick<
TimelineDriver,
"subscribeTimeline" | "getRelatedEvents"
>,
) {
// Hide reactions after a given time.
this.reactionsSubject$
.pipe(delay(REACTION_ACTIVE_TIME_MS), scope.bind())
.subscribe((reactions) => {
const date = new Date();
const nextEntries = Object.fromEntries(
Object.entries(reactions).filter(([_, hr]) => hr.expireAfter > date),
);
if (Object.keys(reactions).length === Object.keys(nextEntries).length)
return;
this.reactionsSubject$.next(nextEntries);
});
scope.onEnd(timeline.subscribeTimeline(this.handleEvent));
rtcParticipationManager.memberships$
.pipe(scope.bind())
.subscribe((memberships) => this.onMembershipsChanged(memberships.value));
}
/** The member whose current membership event is `eventId`, sent by `sender`. */
private memberFor(
eventId: string | undefined,
sender: string,
): FfiMembership | undefined {
if (eventId === undefined) return undefined;
return this.memberships.find(
(m) => m.member.eventId === eventId && m.member.userId === sender,
);
}
/** The hand `member` has raised on their current membership event, if any. */
private findRaisedHand(member: FfiMembership): RaisedHandInfo | undefined {
const eventId = member.member.eventId;
if (eventId === undefined) return undefined;
const reaction = this.timeline
.getRelatedEvents(eventId, "m.annotation", REACTION_EVENT_TYPE)
.find(
(e) =>
e.sender === member.member.userId &&
relationOf(e.content)?.key === RAISED_HAND_KEY,
);
if (reaction === undefined) return undefined;
return {
membershipEventId: eventId,
reactionEventId: reaction.eventId,
time: new Date(reaction.originServerTs),
};
}
/**
* Drops the hands of members who left, keeps a hand across a re-sent
* membership when it was raised again on the new event, and picks up hands
* that were raised before we looked.
*/
private onMembershipsChanged(memberships: FfiMembership[]): void {
this.memberships = memberships;
const present = new Map(
memberships.map((m) => [memberMediaId(m.member), m]),
);
const hands = { ...this.raisedHandsSubject$.value };
let changed = false;
for (const id of Object.keys(hands)) {
const member = present.get(id);
if (member === undefined) {
delete hands[id];
changed = true;
} else if (hands[id].membershipEventId !== member.member.eventId) {
// The member re-sent their membership: the hand stands only if it
// was raised on the new event too.
const hand = this.findRaisedHand(member);
if (hand) hands[id] = hand;
else delete hands[id];
changed = true;
}
}
for (const [id, member] of present) {
if (id in hands) continue;
const hand = this.findRaisedHand(member);
if (hand) {
hands[id] = hand;
changed = true;
}
}
if (changed) this.raisedHandsSubject$.next(hands);
}
private addRaisedHand(identifier: string, info: RaisedHandInfo): void {
this.raisedHandsSubject$.next({
...this.raisedHandsSubject$.value,
[identifier]: info,
});
}
private removeRaisedHand(identifier: string): void {
this.raisedHandsSubject$.next(
Object.fromEntries(
Object.entries(this.raisedHandsSubject$.value).filter(
([id]) => id !== identifier,
),
),
);
}
private handleEvent = (event: TimelineEvent): void => {
if (event.type === ElementCallReactionEventType) {
const relation = relationOf(event.content);
const member = this.memberFor(relation?.event_id, event.sender);
if (member === undefined) {
logger.warn(
`Reaction target was not a membership event for ${event.sender}, ignoring`,
);
return;
}
const identifier = memberMediaId(member.member);
const rawEmoji = event.content.emoji;
if (typeof rawEmoji !== "string" || rawEmoji === "") {
logger.warn(`Reaction had no emoji from ${event.eventId}`);
return;
}
const emoji = new Intl.Segmenter(undefined, { granularity: "grapheme" })
.segment(rawEmoji)
[Symbol.iterator]()
.next().value?.segment;
if (!emoji?.trim()) {
logger.warn(
`Reaction had no emoji from ${event.eventId} after splitting`,
);
return;
}
const reaction = {
...GenericReaction,
emoji,
// If we don't find a reaction, we can fallback to the generic sound.
...ReactionSet.find((r) => r.name === event.content.name),
};
const current = this.reactionsSubject$.value;
if (current[identifier]) {
// We've still got a reaction from this user, ignore it to prevent spamming
logger.warn(`Got reaction from ${identifier} but one is still playing`);
return;
}
this.reactionsSubject$.next({
...current,
[identifier]: {
reactionOption: reaction,
expireAfter: new Date(Date.now() + REACTION_ACTIVE_TIME_MS),
},
});
} else if (event.type === REACTION_EVENT_TYPE) {
const relation = relationOf(event.content);
const member = this.memberFor(relation?.event_id, event.sender);
if (member === undefined) {
logger.warn(
`Reaction target was not a membership event for ${event.sender}, ignoring`,
);
return;
}
if (relation?.key === RAISED_HAND_KEY)
this.addRaisedHand(memberMediaId(member.member), {
reactionEventId: event.eventId,
membershipEventId: relation.event_id!,
time: new Date(event.originServerTs),
});
} else if (event.type === REDACTION_EVENT_TYPE) {
const redacts =
event.redacts ?? (event.content.redacts as string | undefined);
const target = Object.entries(this.raisedHandsSubject$.value).find(
([, hand]) => hand.reactionEventId === redacts,
)?.[0];
if (target !== undefined) this.removeRaisedHand(target);
}
};
}
+62 -72
View File
@@ -5,20 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventType, RelationType } from "matrix-js-sdk";
import { EventType, type MatrixClient, RelationType } from "matrix-js-sdk";
import {
createContext,
use,
type ReactNode,
useCallback,
useMemo,
type JSX,
} from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext";
import { type TimelineDriver } from "../driver/ElementCallMatrixClientDriver";
import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
@@ -44,116 +42,108 @@ export const useReactionsSender = (): ReactionsSenderContextType => {
/**
* Provider that handles sending a reaction or hand raised event to a call.
*/
/** How to send and take back a reaction: a room event, and a redaction. */
export type ReactionsTimeline = Pick<
TimelineDriver,
"sendRoomEvent" | "redactEvent"
>;
/** {@link ReactionsTimeline} over a matrix-js-sdk client. */
export function jsSdkReactionsTimeline(
client: Pick<MatrixClient, "sendEvent" | "redactEvent">,
roomId: string,
): ReactionsTimeline {
return {
sendRoomEvent: async (eventType, content) => {
const { event_id: eventId } = await client.sendEvent(
roomId,
eventType as never,
content as never,
);
return { eventId };
},
redactEvent: async (eventId) => {
await client.redactEvent(roomId, eventId);
},
};
}
export const ReactionsSenderProvider = ({
children,
rtcSession,
vm,
ownIdentifier,
ownMembershipEventId,
timeline,
}: {
children: ReactNode;
rtcSession: MatrixRTCSession;
vm: CallViewModel;
/** Our key in `vm.reactions$` / `vm.handsRaised$` (`${userId}:${deviceId}`). */
ownIdentifier: string;
/** The event id of our current membership, which reactions relate to. */
ownMembershipEventId: string | undefined;
timeline: ReactionsTimeline;
}): JSX.Element => {
const memberships = useMatrixRTCSessionMemberships(rtcSession);
// A widget host may forbid reactions; without a client state (the
// component, the crate path) there is nobody to forbid them.
const clientState = useClientState();
const supportsReactions =
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
const room = rtcSession.room;
const myUserId = room.client.getUserId();
const myDeviceId = room.client.getDeviceId();
const myMembershipIdentifier = `${myUserId}:${myDeviceId}`;
const myMembershipEvent = useMemo(
() =>
memberships.find(
(m) => m.userId === myUserId && m.deviceId === myDeviceId,
)?.eventId,
[memberships, myUserId, myDeviceId],
);
clientState === undefined ||
(clientState.state === "valid" && clientState.supportedFeatures.reactions);
const reactions = useBehavior(vm.reactions$);
const myReaction = useMemo(
() =>
myMembershipIdentifier !== undefined
? reactions[myMembershipIdentifier]
: undefined,
[myMembershipIdentifier, reactions],
);
const myReaction = reactions[ownIdentifier];
const handsRaised = useBehavior(vm.handsRaised$);
const myRaisedHand = useMemo(
() =>
myMembershipIdentifier !== undefined
? handsRaised[myMembershipIdentifier]
: undefined,
[myMembershipIdentifier, handsRaised],
);
const myRaisedHand = handsRaised[ownIdentifier];
const toggleRaisedHand = useCallback(async () => {
if (!myMembershipIdentifier) {
return;
}
const myReactionId = myRaisedHand?.reactionEventId;
if (!myReactionId) {
try {
if (!myMembershipEvent) {
if (!ownMembershipEventId) {
throw new Error("Cannot find own membership event");
}
const reaction = await room.client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: myMembershipEvent,
key: "🖐️",
},
const { eventId } = await timeline.sendRoomEvent(EventType.Reaction, {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: ownMembershipEventId,
key: "🖐️",
},
);
logger.debug("Sent raise hand event", reaction.event_id);
});
logger.debug("Sent raise hand event", eventId);
} catch (ex) {
logger.error("Failed to send raised hand", ex);
}
} else {
try {
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
await timeline.redactEvent(myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
throw ex;
}
}
}, [
myMembershipEvent,
myMembershipIdentifier,
myRaisedHand,
rtcSession,
room,
]);
}, [ownMembershipEventId, myRaisedHand, timeline]);
const sendReaction = useCallback(
async (reaction: ReactionOption) => {
if (!myMembershipIdentifier || myReaction) {
// We're still reacting
if (myReaction) {
return;
}
if (!myMembershipEvent) {
if (!ownMembershipEventId) {
throw new Error("Cannot find own membership event");
}
await room.client.sendEvent(
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: myMembershipEvent,
},
emoji: reaction.emoji,
name: reaction.name,
await timeline.sendRoomEvent(ElementCallReactionEventType, {
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: ownMembershipEventId,
},
);
emoji: reaction.emoji,
name: reaction.name,
});
},
[myMembershipEvent, myReaction, room, myMembershipIdentifier, rtcSession],
[ownMembershipEventId, myReaction, timeline],
);
return (
+2 -5
View File
@@ -6,13 +6,12 @@ Please see LICENSE in the repository root for full details.
*/
import { type FC, type FormEventHandler, useCallback, useState } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { Trans, useTranslation } from "react-i18next";
import { Button, Heading, Text } from "@vector-im/compound-web";
import styles from "./CallEndedView.module.css";
import feedbackStyle from "../input/FeedbackInput.module.css";
import { useProfile } from "../profile/useProfile";
import { useOwnProfile } from "../profile/useOwnProfile";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input";
@@ -22,7 +21,6 @@ import { LeaveToHomeLink } from "../button/LeaveToHomeLink";
import { useLeaveToHome } from "../LeaveToHomeContext";
interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
hideHeader: boolean;
confineToRoom: boolean;
@@ -30,7 +28,6 @@ interface Props {
}
export const CallEndedView: FC<Props> = ({
client,
isPasswordlessUser,
hideHeader,
confineToRoom,
@@ -39,7 +36,7 @@ export const CallEndedView: FC<Props> = ({
const { t } = useTranslation();
const leaveToHome = useLeaveToHome();
const { displayName } = useProfile(client);
const { displayName } = useOwnProfile();
const [surveySubmitted, setSurveySubmitted] = useState(false);
const [starRating, setStarRating] = useState(0);
const [submitting, setSubmitting] = useState(false);
+225
View File
@@ -0,0 +1,225 @@
/*
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.
*/
/**
* `CallView` over the mock drivers: no matrix-js-sdk client, the Rust crate
* carrying the call. The lobby and the ended screen render as they would for
* a host; the in-call screen needs a LiveKit SFU, which no mock provides yet,
* so its story shows the path a call takes when the homeserver advertises no
* transport instead.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, type ReactNode, useEffect, useMemo, useState } from "react";
import { BrowserRouter } from "react-router-dom";
import { CallView } from "./CallView";
import { CallEndedView } from "./CallEndedView";
import {
type MatrixDrivers,
MatrixDriverProvider,
} from "../driver/MatrixDriverContext";
import { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import {
MockRtcMatrixDriver,
type MockRtcMatrixDriverOptions,
slotEvent,
} from "../driver/MockRtcMatrixDriver";
import { HostBridgeProvider, nullHostBridge } from "../HostBridge";
import { RootElementProvider } from "../RootElementContext";
import {
componentProperties,
configurationForIntent,
type UrlParams,
UrlParamsProvider,
UserIntent,
} from "../UrlParams";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { ObservableScope } from "../state/ObservableScope";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
const ROOM_ID = "!story:example.org";
const USER_ID = "@me:example.org";
const DEVICE_ID = "STORYDEV";
const alice = {
userId: "@alice:example.org",
deviceId: "ALICEDEV",
memberId: "m-alice",
};
interface HostProps {
intent: UserIntent;
/** Members already in the call when the view mounts. */
peers?: (typeof alice)[];
/** The RTC driver's options, e.g. no transports for the error path. */
rtc?: MockRtcMatrixDriverOptions;
children: ReactNode;
}
/**
* What a host provides around a call: the drivers, the host bridge, the
* call's parameters, the media devices and the root element.
*/
const Host: FC<HostProps> = ({ intent, peers = [], rtc, children }) => {
const drivers = useMemo((): MatrixDrivers => {
const rtcDriver = new MockRtcMatrixDriver({
userId: USER_ID,
deviceId: DEVICE_ID,
roomId: ROOM_ID,
roomState: [slotEvent({ roomId: ROOM_ID, status: "open" })],
...rtc,
});
for (const peer of peers) rtcDriver.addPeer(peer);
return {
rtcDriver,
clientDriver: new MockElementCallMatrixClientDriver({
userId: USER_ID,
deviceId: DEVICE_ID,
roomId: ROOM_ID,
roomInfo: { name: "Weekly sync", joinRule: "public" },
ownProfile: { displayName: "Me", avatarUrl: null },
members: [
{
userId: USER_ID,
displayName: "Me",
avatarUrl: null,
membership: "join",
},
...peers.map((p) => ({
userId: p.userId,
displayName: p.userId.slice(1).split(":")[0],
avatarUrl: null,
membership: "join" as const,
})),
],
}),
};
}, [peers, rtc]);
const params = useMemo(
(): UrlParams => ({
...componentProperties,
roomId: ROOM_ID,
...configurationForIntent(intent),
}),
[intent],
);
// The peers join once the crate listens to the room: the participation is
// created after the wasm has loaded, so poll for its sink.
useEffect(() => {
if (peers.length === 0) return;
const rtcDriver = drivers.rtcDriver as MockRtcMatrixDriver;
let tries = 0;
const timer = setInterval(() => {
tries++;
try {
for (const peer of peers) rtcDriver.peerJoins(peer);
clearInterval(timer);
} catch {
if (tries > 200) clearInterval(timer);
}
}, 50);
return (): void => clearInterval(timer);
}, [drivers, peers]);
const [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setMediaDevices(new MediaDevices(scope, { controlledAudioDevices: false }));
return (): void => {
setMediaDevices(null);
scope.end();
};
}, []);
const [root, setRoot] = useState<HTMLDivElement | null>(null);
return (
<BrowserRouter>
<HostBridgeProvider value={nullHostBridge}>
<UrlParamsProvider value={params}>
<MatrixDriverProvider value={drivers}>
<div
ref={setRoot}
style={{ width: "80vw", height: "80vh", position: "relative" }}
>
{root !== null && mediaDevices !== null && (
<RootElementProvider value={root}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<>{children}</>
</ProcessorProvider>
</MediaDevicesContext>
</RootElementProvider>
)}
</div>
</MatrixDriverProvider>
</UrlParamsProvider>
</HostBridgeProvider>
</BrowserRouter>
);
};
const meta: Meta<typeof CallView> = {
title: "Room/CallView",
component: CallView,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof CallView>;
/** The lobby of a call that Alice is already in. */
export const Lobby: Story = {
args: {
isPasswordlessUser: false,
confineToRoom: true,
preload: false,
skipLobby: false,
},
render: (args) => (
<Host intent={UserIntent.JoinExistingCall} peers={[alice]}>
<CallView {...args} />
</Host>
),
};
/**
* Straight into the call, with a homeserver that advertises no transport:
* the crate fails the join and the view shows the error it turns into.
*/
export const NoTransport: Story = {
args: {
isPasswordlessUser: false,
confineToRoom: true,
preload: false,
skipLobby: true,
},
render: (args) => (
<Host intent={UserIntent.StartNewCall} rtc={{ transports: [] }}>
<CallView {...args} />
</Host>
),
};
/** The screen after leaving, as a passwordless user sees it. */
export const Ended: Story = {
args: {
isPasswordlessUser: true,
confineToRoom: false,
preload: false,
skipLobby: false,
},
render: (args) => (
<Host intent={UserIntent.JoinExistingCall}>
<CallEndedView
endedCallId={ROOM_ID}
isPasswordlessUser={args.isPasswordlessUser}
hideHeader={false}
confineToRoom={args.confineToRoom}
/>
</Host>
),
};
+85 -7
View File
@@ -11,6 +11,7 @@ Please see LICENSE in the repository root for full details.
import {
beforeEach,
describe,
expect,
type MockedFunction,
onTestFinished,
@@ -64,6 +65,15 @@ import { MatrixRTCTransportMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { constant } from "../state/Behavior";
import {
type MatrixDrivers,
MatrixDriverProvider,
} from "../driver/MatrixDriverContext";
import { MockRtcMatrixDriver } from "../driver/MockRtcMatrixDriver";
import { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
import { callViewModelImplementation } from "../settings/settings";
import { CallViewModelImplementation } from "../config/ConfigOptions";
vi.mock("../soundUtils");
vi.mock("../useAudioContext");
@@ -138,6 +148,23 @@ beforeEach(() => {
);
});
/** Mock drivers over the same room and identity as the mocked session. */
function defaultDrivers(): MatrixDrivers {
return {
rtcDriver: new MockRtcMatrixDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
}),
clientDriver: new MockElementCallMatrixClientDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
roomInfo: { joinRule: "invite" },
}),
};
}
function createCallView(
hostBridge: HostBridge,
joined = true,
@@ -145,6 +172,8 @@ function createCallView(
withErrorBoundary?: boolean;
/** Wait for the host to say when to join, rather than joining at once. */
preload?: boolean;
/** The host's drivers, for the matrix-rtc implementation. */
drivers?: MatrixDrivers;
} = {},
): {
rtcSession: MatrixRTCSession;
@@ -194,13 +223,15 @@ function createCallView(
<TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
{callView}
</GroupCallErrorBoundary>
) : (
callView
)}
<MatrixDriverProvider value={options.drivers ?? defaultDrivers()}>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
{callView}
</GroupCallErrorBoundary>
) : (
callView
)}
</MatrixDriverProvider>
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider>
@@ -424,3 +455,50 @@ test("user can reconnect after a membership manager error", async () => {
// In-call controls should be visible again
await waitFor(() => screen.getByRole("button", { name: "Leave" }));
});
describe("the call implementation switch", () => {
test("matrix-js-sdk carries the call by default: no participation", async () => {
createCallView(nullHostBridge);
await waitFor(() => expect(ActiveCall).toHaveBeenCalled());
expect(
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].rtcParticipationManager,
).toBeNull();
expect(window.matrixRtc).toBeUndefined();
});
test("with matrix-rtc selected and drivers provided, the crate carries the call", async () => {
await initMatrixRtcSdkForTests();
callViewModelImplementation.setValue(CallViewModelImplementation.MatrixRtc);
onTestFinished(() =>
callViewModelImplementation.setValue(
CallViewModelImplementation.MatrixJsSdk,
),
);
const drivers: MatrixDrivers = {
rtcDriver: new MockRtcMatrixDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
}),
clientDriver: new MockElementCallMatrixClientDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
}),
};
createCallView(nullHostBridge, true, { drivers });
await waitFor(() =>
expect(
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].rtcParticipationManager,
).not.toBeNull(),
);
const { rtcParticipationManager } = vi
.mocked(ActiveCall)
.mock.calls.at(-1)![0];
expect(window.matrixRtc?.rtcParticipationManager).toBe(
rtcParticipationManager,
);
// The participation is bound to the drivers' room and identity.
expect(rtcParticipationManager?.session$.value.roomId).toBe(roomId);
});
});
+189 -72
View File
@@ -12,11 +12,11 @@ import {
useEffect,
useMemo,
useState,
useRef,
} from "react";
import {
type MatrixClient,
JoinRule,
type Room,
UnsupportedStickyEventsEndpointError,
} from "matrix-js-sdk";
import {
@@ -34,7 +34,11 @@ import { LobbyView } from "./LobbyView";
import { type MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile";
import {
mediaKeyStatisticsOf,
NO_MEDIA_KEY_STATISTICS,
} from "../analytics/PosthogEvents";
import { useOwnProfile } from "../profile/useOwnProfile";
import { findDeviceByName } from "../utils/media";
import { ActiveCall } from "./InCallView";
import { type MuteStates } from "../state/MuteStates";
@@ -42,11 +46,9 @@ import { useMediaDevices } from "../MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import {
saveKeyForRoom,
useRoomEncryptionSystem,
useEncryptionSystemFor,
} from "../e2ee/sharedKeyManagement";
import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { useRoomInfo } from "./useRoomInfo";
import { InviteModal } from "./InviteModal";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
@@ -64,7 +66,6 @@ import {
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
@@ -72,6 +73,20 @@ import { useRootElement } from "../RootElementContext.ts";
import { useHostBridge } from "../HostBridge.ts";
import { useMuteStates } from "../state/useMuteStates.ts";
import { useLeaveToHome } from "../LeaveToHomeContext.ts";
import { useMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
import { useRtcParticipationManager } from "../state/rtc/useRtcParticipationManager.ts";
import { type RtcParticipationManager } from "../state/rtc/RtcParticipationManager.ts";
import { participationConfig } from "../state/rtc/joinParams.ts";
import { effectiveCallViewModelImplementation } from "../state/rtc/implementation.ts";
import {
CallViewModelImplementation,
type MatrixRTCMode,
} from "../config/ConfigOptions.ts";
import { Config } from "../config/Config.ts";
import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings.ts";
import { constant } from "../state/Behavior.ts";
import { Epoch } from "../state/ObservableScope.ts";
import { FfiStatus, type FfiMembership } from "../matrix-rtc-sdk";
/**
* If there already are this many participants in the call, we automatically mute
@@ -82,14 +97,26 @@ export const MUTE_PARTICIPANT_COUNT = 8;
declare global {
interface Window {
rtcSession?: MatrixRTCSession;
/** The crate's participation manager, when the Rust implementation carries the call. */
matrixRtc?: { rtcParticipationManager: RtcParticipationManager };
}
}
/** What the crate's roster reads as while matrix-js-sdk carries the call. */
const NO_PARTICIPATION_MEMBERSHIPS = constant(
new Epoch<FfiMembership[]>([], 0),
);
interface Props {
/** The client to place the call with. */
client: MatrixClient;
/**
* The matrix-js-sdk client and session, for the matrix-js-sdk
* implementation of the call. Without them the Rust crate carries the call
* through the drivers whatever the developer setting says (the component
* has no client to offer).
*/
client?: MatrixClient;
/** The call to join. */
rtcSession: MatrixRTCSession;
rtcSession?: MatrixRTCSession;
/**
* Whether the user is signed in as a guest, and so should be offered the
* chance to create an account when the call ends.
@@ -161,7 +188,60 @@ const LoadedCallView: FC<LoadedProps> = ({
const [externalError, setExternalError] = useState<ElementCallError | null>(
null,
);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession);
// The host's drivers: what the room is called and looks like, who we are,
// and (on the crate path) the session itself come from them.
const drivers = useMatrixDrivers();
const { roomId } = drivers.clientDriver;
const roomInfo = useRoomInfo();
const e2eeSystem = useEncryptionSystemFor(roomId, roomInfo.encrypted);
// Which implementation carries this call, sampled once for the view's
// lifetime (§5.15 of the oxidation plan): the Rust crate through the
// host's drivers, or matrix-js-sdk's session — which needs a session.
const [implementation] = useState(() =>
effectiveCallViewModelImplementation(),
);
// this is the rustsdkmatrix rtc. So we should call it useRustRtcSdk
const useRustRtcSdk =
implementation === CallViewModelImplementation.MatrixRtc ||
rtcSession === undefined;
// Sampled once, like the implementation: the participation is the call,
// and rebuilding it for a later change of these would leave and rejoin.
const [participationConfigValue] = useState(() =>
useRustRtcSdk
? participationConfig({
// matrix_rtc_mode in config.json overrides the user's choice.
mode:
(Config.get().matrix_rtc_mode as MatrixRTCMode | undefined) ??
matrixRTCModeSetting.value$.value,
manageMediaKeys: e2eeSystem.kind === E2eeType.PER_PARTICIPANT,
session: Config.get().matrix_rtc_session,
})
: null,
);
const rtcParticipationManager = useRtcParticipationManager(
useRustRtcSdk ? drivers : null,
participationConfigValue,
{
// Element Call's own `config.json` LiveKit service, for a homeserver
// that advertises no transport: the precedence Element Call has always
// had, and the route by which a host (Element Web) hands over what it
// found in `.well-known`.
transportFallbackUrl: Config.get().livekit?.livekit_service_url,
},
);
const participationMemberships = useBehavior(
rtcParticipationManager?.memberships$ ?? NO_PARTICIPATION_MEMBERSHIPS,
);
// The call's members, whichever side lists them; only who they are matters here.
const memberUserIds = useMemo(
() =>
useRustRtcSdk
? participationMemberships.value.map((m) => m.member.userId)
: jsSdkMemberships.map((m) => m.userId!),
[useRustRtcSdk, participationMemberships, jsSdkMemberships],
);
const rootElement = useRootElement();
const hostBridge = useHostBridge();
// A host that can close us is a host that decides when we stop existing, so
@@ -178,12 +258,16 @@ const LoadedCallView: FC<LoadedProps> = ({
muted: muteAllAudio,
}),
);
// This should use `useEffectEvent` (only available in experimental versions)
// Joining a big call starts muted. Decided once, the first time the roster
// is known: at mount for matrix-js-sdk, after the seed for the crate.
const mutedForCallSize = useRef(false);
useEffect(() => {
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
if (mutedForCallSize.current) return;
if (useRustRtcSdk && !participationMemberships.value.length) return;
mutedForCallSize.current = true;
if (memberUserIds.length >= MUTE_PARTICIPANT_COUNT)
muteStates.audio.setEnabled$.value?.(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [useRustRtcSdk, participationMemberships, memberUserIds, muteStates]);
useEffect(() => {
logger.info("[Lifecycle] CallView Component mounted");
@@ -203,18 +287,22 @@ const LoadedCallView: FC<LoadedProps> = ({
}, [rootElement]);
useEffect(() => {
window.rtcSession = rtcSession;
// Storing in the window to access it for the rageshake summary.
if (rtcSession !== undefined) window.rtcSession = rtcSession;
if (rtcParticipationManager !== null)
window.matrixRtc = { rtcParticipationManager };
return (): void => {
delete window.rtcSession;
delete window.matrixRtc;
};
}, [rtcSession]);
}, [rtcSession, rtcParticipationManager]);
// TODO move this into the callViewModel LocalMembership.ts
// We might actually not need this at all. Since we get into fatalError on those errors already?
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => {
// matrix-js-sdk only; the crate reports these through `fatalError$`.
useEffect(() => {
if (rtcSession === undefined) return;
const onError = (error: unknown): void => {
// When matrix_rtc_mode=matrix_2_0 is in effect but the homeserver does
// not advertise MSC4354 (sticky events), the SDK throws an
// `UnsupportedStickyEventsEndpointError`. The MembershipManager
@@ -227,79 +315,93 @@ const LoadedCallView: FC<LoadedProps> = ({
} else {
setExternalError(new ConnectionLostError());
}
},
);
};
rtcSession.on(MatrixRTCSessionEvent.MembershipManagerError, onError);
return (): void => {
rtcSession.off(MatrixRTCSessionEvent.MembershipManagerError, onError);
};
}, [rtcSession]);
useEffect(() => {
if (client === undefined || rtcSession === undefined) return;
// Sanity check the room object
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
logger.warn(
`We've ended up with multiple rooms for the same ID (${rtcSession.room.roomId}). This indicates a bug in the group call loading code, and may lead to incomplete room state.`,
);
}, [client, rtcSession.room]);
}, [client, rtcSession]);
const room = rtcSession.room as Room;
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const { displayName, avatarUrl } = useOwnProfile();
const roomName = roomInfo.name;
const roomAvatar = roomInfo.avatarUrl;
const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
header,
} = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
// Save the password once we start the groupCallView
useEffect(() => {
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
}, [passwordFromUrl, room.roomId]);
if (passwordFromUrl) saveKeyForRoom(roomId, passwordFromUrl);
}, [passwordFromUrl, roomId]);
useAppBarTitle(roomName);
const { userId: ownUserId } = drivers.clientDriver;
const roomAlias = roomInfo.canonicalAlias;
const matrixInfo = useMemo((): MatrixInfo => {
return {
userId: client.getUserId()!,
displayName: displayName!,
avatarUrl: avatarUrl!,
roomId: room.roomId,
userId: ownUserId,
displayName: displayName ?? ownUserId,
avatarUrl: avatarUrl ?? "",
roomId,
roomName,
roomAlias: room.getCanonicalAlias(),
roomAlias,
roomAvatar,
e2eeSystem,
};
}, [client, displayName, avatarUrl, roomName, room, roomAvatar, e2eeSystem]);
}, [
ownUserId,
displayName,
avatarUrl,
roomName,
roomId,
roomAlias,
roomAvatar,
e2eeSystem,
]);
// Count each member only once, regardless of how many devices they use
const participantCount = useMemo(
() => new Set<string>(memberships.map((m) => m.userId!)).size,
[memberships],
() => new Set<string>(memberUserIds).size,
[memberUserIds],
);
const mediaDevices = useMediaDevices();
const latestMuteStates = useLatest(muteStates);
// Read at leave time, not a dependency: `onLeft` feeds the in-call view's
// effect, and a roster change must not rebuild the call.
const latestMemberUserIds = useLatest(memberUserIds);
const enterRTCSessionOrError = useCallback(
async (rtcSession: MatrixRTCSession): Promise<void> => {
try {
setJoined(true);
// TODO-MULTI-SFU what to do with error handling now that we don't use this function?
// @BillCarsonFr
} catch (e) {
if (e instanceof ElementCallError) {
setExternalError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
setExternalError(error);
}
const enterRTCSessionOrError = useCallback(async (): Promise<void> => {
try {
setJoined(true);
// TODO-MULTI-SFU what to do with error handling now that we don't use this function?
// @BillCarsonFr
} catch (e) {
if (e instanceof ElementCallError) {
setExternalError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
setExternalError(error);
}
return Promise.resolve();
},
[setJoined],
);
}
return Promise.resolve();
}, [setJoined]);
useEffect(() => {
const defaultDeviceSetup = async ({
@@ -409,10 +511,14 @@ const LoadedCallView: FC<LoadedProps> = ({
// queuing/batching of requests.
const sendInstantly = hostControlsLifetime;
PosthogAnalytics.instance.eventCallEnded.track(
room.roomId,
rtcSession.memberships.length,
roomId,
latestMemberUserIds.current.length,
sendInstantly,
rtcSession,
rtcParticipationManager !== null
? rtcParticipationManager.mediaKeyStatistics()
: rtcSession === undefined
? NO_MEDIA_KEY_STATISTICS
: mediaKeyStatisticsOf(rtcSession),
);
// Unfortunately the PostHog library provides no way to await the
// tracking of an event, but we don't really want it to hold up our
@@ -457,8 +563,10 @@ const LoadedCallView: FC<LoadedProps> = ({
leaveSoundContext,
hostBridge,
hostControlsLifetime,
room.roomId,
roomId,
latestMemberUserIds,
rtcSession,
rtcParticipationManager,
isPasswordlessUser,
confineToRoom,
returnToLobby,
@@ -474,7 +582,7 @@ const LoadedCallView: FC<LoadedProps> = ({
});
}, [hostBridge, joined, rtcSession]);
const joinRule = useJoinRule(room);
const joinRule = roomInfo.joinRule;
const [shareModalOpen, setInviteModalOpen] = useState(false);
const onDismissInviteModal = useCallback(
@@ -495,7 +603,9 @@ const LoadedCallView: FC<LoadedProps> = ({
const shareModal = (
<InviteModal
room={room}
roomId={roomId}
roomName={roomName}
e2eeSystem={e2eeSystem}
open={shareModalOpen}
onDismiss={onDismissInviteModal}
/>
@@ -504,7 +614,7 @@ const LoadedCallView: FC<LoadedProps> = ({
<>
{shareModal}
<LobbyView
client={client}
developerSettingsClient={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => setJoined(true)}
@@ -525,6 +635,10 @@ const LoadedCallView: FC<LoadedProps> = ({
throw externalError;
};
body = <ErrorComponent />;
} else if (joined && useRustRtcSdk && rtcParticipationManager === null) {
// Joined before the crate is ready (its wasm loads on first use): the
// call appears with the participation manager, a render later.
body = null;
} else if (joined) {
body = (
<>
@@ -532,8 +646,9 @@ const LoadedCallView: FC<LoadedProps> = ({
<ActiveCall
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room}
rtcSession={rtcSession}
rtcParticipationManager={rtcParticipationManager}
roomId={roomId}
onLeft={onLeft}
muteStates={muteStates}
e2eeSystem={e2eeSystem}
@@ -554,8 +669,7 @@ const LoadedCallView: FC<LoadedProps> = ({
if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) {
body = (
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
endedCallId={roomId}
isPasswordlessUser={isPasswordlessUser}
hideHeader={header === HeaderStyle.None}
confineToRoom={confineToRoom}
@@ -583,13 +697,16 @@ const LoadedCallView: FC<LoadedProps> = ({
setExternalError(null);
if (action == "reconnect") {
setLeft(false);
await enterRTCSessionOrError(rtcSession).catch((e) => {
await enterRTCSessionOrError().catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}
}}
onError={(_error) => {
if (rtcSession.isJoined()) onLeft("error");
const joinedViaCrate =
rtcParticipationManager !== null &&
FfiStatus.Connected.instanceOf(rtcParticipationManager.status$.value);
if (rtcSession?.isJoined() === true || joinedViaCrate) onLeft("error");
// If there is an error we need to be dismissible again. This is done in
// `onLeft` as well; we need it here explicitly in case
// rtcSession.isJoined is false.
+12 -5
View File
@@ -42,7 +42,10 @@ import {
type CallViewModelOptions,
} from "../state/CallViewModel/CallViewModel";
import { alice, local } from "../utils/test-fixtures";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import {
jsSdkReactionsTimeline,
ReactionsSenderProvider,
} from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
@@ -160,7 +163,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
footerVm={footerVm}
developerSettingsVm={developerSettingsVm}
matrixInfo={matrixInfo}
matrixRoom={room}
roomId={room.roomId}
onShareClick={null}
/>
);
@@ -172,7 +175,9 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
<MediaDevicesContext value={mediaDevices}>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession.asMockedSession()}
ownIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
ownMembershipEventId={undefined}
timeline={jsSdkReactionsTimeline(client, room.roomId)}
>
<TooltipProvider>
<RoomContext value={livekitRoom}>{content}</RoomContext>
@@ -248,7 +253,8 @@ describe("ActiveCall", () => {
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
matrixRoom={matrixRoom}
rtcParticipationManager={null}
roomId={matrixRoom.roomId}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
onShareClick={null}
@@ -298,7 +304,8 @@ describe("ActiveCall", () => {
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
matrixRoom={matrixRoom}
rtcParticipationManager={null}
roomId={matrixRoom.roomId}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
onShareClick={null}
+132 -40
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
import { type MatrixClient } from "matrix-js-sdk";
import {
type FC,
type PointerEvent as ReactPointerEvent,
@@ -45,6 +45,7 @@ import {
type CallViewModel,
callViewModelOptionsFromParams,
createCallViewModel$,
createJsClientCallViewModel$,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
import { SpotlightTile } from "../tile/SpotlightTile";
@@ -86,12 +87,21 @@ 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 { type RtcParticipationManager } from "../state/rtc/RtcParticipationManager.ts";
import { useOptionalMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
import { ParticipationReactionsReader } from "../reactions/ParticipationReactionsReader.ts";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
import { jsSdkReactionsTimeline } from "../reactions/useReactionsSender.tsx";
import { CallViewModelImplementation } from "../config/ConfigOptions.ts";
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";
/** What `ownMembership$` reads as when matrix-js-sdk carries the call. */
const NO_OWN_MEMBERSHIP = constant(null);
declare module "react" {
interface CSSProperties {
"--call-view-safe-area-inset-top"?: string;
@@ -104,6 +114,12 @@ export interface ActiveCallProps extends Omit<
"vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm"
> {
e2eeSystem: EncryptionSystem;
/**
* The crate's participation manager for the session when the Rust
* implementation carries this call (see `CallViewModelImplementation`); null when
* matrix-js-sdk's `rtcSession` does.
*/
rtcParticipationManager: RtcParticipationManager | null;
// TODO refactor those reasons into an enum
onLeft: (
reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error",
@@ -125,34 +141,77 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
// The element we have to draw the call in: the page, or the container a host
// gave us. Its size, not the window's, decides how the call is laid out.
const rootElement = useRootElement();
// The drivers, where a host provided them; required with a participation manager.
const drivers = useOptionalMatrixDrivers();
const { rtcParticipationManager, rtcSession, client, roomId } = props;
if (rtcParticipationManager !== null && drivers === null)
throw new Error(
"A call over the matrix-rtc crate needs the Matrix drivers to be provided",
);
if (
rtcParticipationManager === null &&
(rtcSession === undefined || client === undefined)
)
throw new Error(
"A call needs either a participation (the crate) or a matrix-js-sdk client and session",
);
useEffect(() => {
rootLogger.info("START CALL VIEW SCOPE");
const scope = new ObservableScope();
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams;
const options = {
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
// We merely sample the current mode here, so the user would need to
// manually rejoin to switch to a different one.
matrixRTCMode: matrixRTCModeSetting.value$.value,
windowSize$: scope.behavior(observeElementSize$(rootElement)),
};
const vm = createCallViewModel$(
scope,
props.rtcSession,
props.matrixRoom,
mediaDevices,
props.muteStates,
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
// We merely sample the current mode here, so the user would need to
// manually rejoin to switch to a different one.
matrixRTCMode: matrixRTCModeSetting.value$.value,
windowSize$: scope.behavior(observeElementSize$(rootElement)),
},
reactionsReader.raisedHands$,
reactionsReader.reactions$,
scope.behavior(trackProcessorState$),
);
let vm: CallViewModel;
if (rtcParticipationManager !== null && drivers !== null) {
rootLogger.info(
`Call view model implementation: ${CallViewModelImplementation.MatrixRtc}`,
);
const reactionsReader = new ParticipationReactionsReader(
scope,
rtcParticipationManager,
drivers.clientDriver,
);
vm = createCallViewModel$(
scope,
rtcParticipationManager,
drivers.clientDriver,
mediaDevices,
props.muteStates,
options,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
scope.behavior(trackProcessorState$),
);
} else if (rtcSession !== undefined) {
rootLogger.info(
`Call view model implementation: ${CallViewModelImplementation.MatrixJsSdk}`,
);
const reactionsReader = new ReactionsReader(scope, rtcSession);
vm = createJsClientCallViewModel$(
scope,
rtcSession,
rtcSession.room,
mediaDevices,
props.muteStates,
options,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
scope.behavior(trackProcessorState$),
);
} else {
return; // unreachable: checked above
}
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
vm.join();
setVm(vm);
@@ -163,8 +222,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
scope.end();
};
}, [
props.rtcSession,
props.matrixRoom,
rtcSession,
client,
props.muteStates,
props.e2eeSystem,
props.onLeft,
@@ -172,10 +231,39 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
hostBridge,
mediaDevices,
trackProcessorState$,
props.client,
rootElement,
rtcParticipationManager,
drivers,
]);
// Who we are, as the tiles, hands and reactions are keyed. Both drivers
// and client name the same identity; the drivers are always there.
const ownIdentifier =
drivers !== null
? `${drivers.clientDriver.userId}:${drivers.clientDriver.deviceId}`
: `${client?.getUserId()}:${client?.getDeviceId()}`;
// Reactions relate to our current membership event, wherever that lives.
const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession);
const ownParticipationMembership = useBehavior(
rtcParticipationManager?.ownMembership$ ?? NO_OWN_MEMBERSHIP,
);
const ownMembershipEventId =
rtcParticipationManager !== null
? ownParticipationMembership?.member.eventId
: jsSdkMemberships.find(
(m) =>
m.userId === client?.getUserId() &&
m.deviceId === client?.getDeviceId(),
)?.eventId;
const reactionsTimeline = useMemo(
() =>
rtcParticipationManager === null && client !== undefined
? jsSdkReactionsTimeline(client, roomId)
: drivers!.clientDriver,
[rtcParticipationManager, drivers, client, roomId],
);
useEffect(() => {
if (vm === null) return;
@@ -185,7 +273,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
vm,
props.muteStates,
mediaDevices,
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
ownIdentifier,
{ showControls: urlParams.showControls, header: urlParams.header },
);
setFooterVm(footerVm);
@@ -195,15 +283,15 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
scope.end();
};
}, [
props.rtcSession,
props.matrixRoom,
rtcSession,
client,
props.muteStates,
props.e2eeSystem,
props.onLeft,
urlParams,
mediaDevices,
trackProcessorState$,
props.client,
ownIdentifier,
vm,
]);
@@ -212,7 +300,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
if (developerSettingsVm === null) return null;
return (
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<ReactionsSenderProvider
vm={vm}
ownIdentifier={ownIdentifier}
ownMembershipEventId={ownMembershipEventId}
timeline={reactionsTimeline}
>
<InCallView
{...props}
vm={vm}
@@ -224,13 +317,15 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
export interface InCallViewProps {
client: MatrixClient;
/** The matrix-js-sdk client, when that implementation carries the call. */
client?: MatrixClient;
vm: CallViewModel;
footerVm: ViewModel<FooterSnapshot>;
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
/** The matrix-js-sdk session, when that implementation carries the call. */
rtcSession?: MatrixRTCSession;
roomId: string;
muteStates: MuteStates;
onShareClick: (() => void) | null;
}
@@ -241,7 +336,7 @@ export const InCallView: FC<InCallViewProps> = ({
footerVm,
developerSettingsVm,
matrixInfo,
matrixRoom,
roomId,
muteStates,
onShareClick,
}) => {
@@ -410,7 +505,6 @@ export const InCallView: FC<InCallViewProps> = ({
[styles.hidden]: !showHeader,
})}
ref={headerRef}
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
>
<LeftNav>
<RoomHeaderInfo
@@ -634,9 +728,7 @@ export const InCallView: FC<InCallViewProps> = ({
}
};
const rageshakeRequestModalProps = useRageshakeRequestModal(
matrixRoom.roomId,
);
const rageshakeRequestModalProps = useRageshakeRequestModal(roomId);
useAppBarSecondaryButton(
<SettingsIconButton
@@ -692,7 +784,7 @@ export const InCallView: FC<InCallViewProps> = ({
<RageshakeRequestModal {...rageshakeRequestModalProps} />
<SettingsModal
client={client}
roomId={matrixRoom.roomId}
roomId={roomId}
open={settingsOpen}
onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab}
+8 -6
View File
@@ -7,25 +7,27 @@ Please see LICENSE in the repository root for full details.
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { type Room } from "matrix-js-sdk";
import { axe } from "vitest-axe";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { InviteModal } from "./InviteModal";
import { E2eeType } from "../e2ee/e2eeType";
// Used by copy-to-clipboard
window.prompt = (): null => null;
test("InviteModal is accessible", async () => {
const user = userEvent.setup();
const room = {
roomId: "!a:example.org",
name: "Mission Control",
} as unknown as Room;
const onDismiss = vi.fn();
const { container } = render(
<InviteModal room={room} open={true} onDismiss={onDismiss} />,
<InviteModal
roomId="!a:example.org"
roomName="Mission Control"
e2eeSystem={{ kind: E2eeType.NONE }}
open={true}
onDismiss={onDismiss}
/>,
{ wrapper: BrowserRouter },
);
+13 -7
View File
@@ -13,7 +13,6 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { type Room } from "matrix-js-sdk";
import { Button, Text } from "@vector-im/compound-web";
import {
LinkIcon,
@@ -25,22 +24,29 @@ import { Modal } from "../Modal";
import { getAbsoluteRoomUrl } from "../utils/matrix";
import styles from "./InviteModal.module.css";
import { Toast } from "../Toast";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { QrCode } from "../QrCode";
interface Props {
room: Room;
roomId: string;
roomName: string;
e2eeSystem: EncryptionSystem;
open: boolean;
onDismiss: () => void;
}
export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
export const InviteModal: FC<Props> = ({
roomId,
roomName,
e2eeSystem,
open,
onDismiss,
}) => {
const { t } = useTranslation();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const url = useMemo(
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
[e2eeSystem, room.name, room.roomId],
() => getAbsoluteRoomUrl(roomId, e2eeSystem, roomName),
[e2eeSystem, roomName, roomId],
);
const [toastOpen, setToastOpen] = useState(false);
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
+1 -1
View File
@@ -64,7 +64,7 @@ export const KnockLobbyView: FC<Props> = ({
return (
<LobbyView
client={client}
developerSettingsClient={client}
matrixInfo={{
userId: client.getUserId() ?? "",
displayName: profile.displayName,
+1 -1
View File
@@ -84,7 +84,7 @@ function renderLobbyView(
const hideHeader = withAppBar ? true : false;
const lobbyView = (
<LobbyView
client={mockClient}
developerSettingsClient={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
+10 -11
View File
@@ -53,7 +53,8 @@ import { type ViewModel } from "../state/ViewModel";
import { useAppBarPrimaryButtonIconKind } from "../AppBar";
interface Props {
client: MatrixClient;
/** The matrix-js-sdk client, for what the developer settings still read from it. */
developerSettingsClient?: MatrixClient;
matrixInfo: MatrixInfo;
muteStates: MuteStates;
onEnter: () => void;
@@ -66,7 +67,7 @@ interface Props {
}
export const LobbyView: FC<Props> = ({
client,
developerSettingsClient,
matrixInfo,
muteStates,
onEnter,
@@ -258,15 +259,13 @@ export const LobbyView: FC<Props> = ({
</CallFooter>
)}
</div>
{client && (
<SettingsModal
client={client}
open={settingsModalOpen}
onDismiss={closeSettings}
tab={settingsTab}
onTabChange={setSettingsTab}
/>
)}
<SettingsModal
client={developerSettingsClient}
open={settingsModalOpen}
onDismiss={closeSettings}
tab={settingsTab}
onTabChange={setSettingsTab}
/>
</>
);
};
+31 -2
View File
@@ -6,7 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useEffect, useState, type ReactNode, useRef } from "react";
import {
type ComponentProps,
type FC,
useEffect,
useState,
type ReactNode,
useRef,
} from "react";
import { type MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { Trans, useTranslation } from "react-i18next";
@@ -16,6 +23,10 @@ import { useClientLegacy } from "../ClientContext";
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView";
import { CallView } from "./CallView";
import { type MatrixClient } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { MatrixDriverProvider } from "../driver/MatrixDriverContext";
import { useJsSdkDrivers } from "../driver/jsSdk/useJsSdkDrivers";
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { HomePage } from "../home/HomePage";
@@ -102,7 +113,7 @@ export const RoomPage: FC = (): ReactNode => {
switch (groupCallState.kind) {
case "loaded":
return (
<CallView
<LoadedCall
client={client!}
rtcSession={groupCallState.rtcSession}
isPasswordlessUser={passwordlessUser}
@@ -190,3 +201,21 @@ export const RoomPage: FC = (): ReactNode => {
if (!roomIdOrAlias) return <HomePage />;
return groupCallView();
};
/**
* The call, with the matrix-js-sdk drivers over this page's client provided
* for the Rust implementation (see `CallViewModelImplementation`).
*/
const LoadedCall: FC<
ComponentProps<typeof CallView> & {
client: MatrixClient;
rtcSession: MatrixRTCSession;
}
> = (props) => {
const drivers = useJsSdkDrivers(props.client, props.rtcSession.room);
return (
<MatrixDriverProvider value={drivers}>
<CallView {...props} />
</MatrixDriverProvider>
);
};
-18
View File
@@ -1,18 +0,0 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useCallback } from "react";
import type { JoinRule, Room } from "matrix-js-sdk";
import { useRoomState } from "./useRoomState";
export function useJoinRule(room: Room): JoinRule {
return useRoomState(
room,
useCallback((state) => state.getJoinRule(), []),
);
}
-18
View File
@@ -1,18 +0,0 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useCallback } from "react";
import { type Room } from "matrix-js-sdk";
import { useRoomState } from "./useRoomState";
export function useRoomAvatar(room: Room): string | null {
return useRoomState(
room,
useCallback(() => room.getMxcAvatarUrl(), [room]),
);
}
+64
View File
@@ -0,0 +1,64 @@
/*
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 { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { type ReactNode } from "react";
import { MatrixDriverProvider } from "../driver/MatrixDriverContext";
import { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import { MockRtcMatrixDriver } from "../driver/MockRtcMatrixDriver";
import { useOwnProfile } from "../profile/useOwnProfile";
import { useRoomInfo } from "./useRoomInfo";
function providing(clientDriver: MockElementCallMatrixClientDriver) {
return ({ children }: { children: ReactNode }): ReactNode => (
<MatrixDriverProvider
value={{ rtcDriver: new MockRtcMatrixDriver(), clientDriver }}
>
{children}
</MatrixDriverProvider>
);
}
describe("useRoomInfo", () => {
it("reads the room from the client driver and follows its changes", () => {
const clientDriver = new MockElementCallMatrixClientDriver({
roomInfo: { name: "Mission Control", joinRule: "invite" },
});
const { result } = renderHook(() => useRoomInfo(), {
wrapper: providing(clientDriver),
});
expect(result.current).toMatchObject({
name: "Mission Control",
joinRule: "invite",
});
act(() => clientDriver.setRoomInfo({ name: "Launch Control" }));
expect(result.current.name).toBe("Launch Control");
});
it("throws without drivers", () => {
expect(() => renderHook(() => useRoomInfo())).toThrow(/No Matrix drivers/);
});
});
describe("useOwnProfile", () => {
it("reads our own profile and follows its changes", () => {
const clientDriver = new MockElementCallMatrixClientDriver({
ownProfile: { displayName: "Me", avatarUrl: "mxc://x/me" },
});
const { result } = renderHook(() => useOwnProfile(), {
wrapper: providing(clientDriver),
});
expect(result.current).toEqual({
displayName: "Me",
avatarUrl: "mxc://x/me",
});
act(() => clientDriver.setOwnProfile({ displayName: "Myself" }));
expect(result.current.displayName).toBe("Myself");
});
});
+36
View File
@@ -0,0 +1,36 @@
/*
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 { useEffect, useState } from "react";
import {
type RoomDriver,
type RoomInfo,
} from "../driver/ElementCallMatrixClientDriver";
import { useMatrixDrivers } from "../driver/MatrixDriverContext";
/**
* The room's name, alias, avatar, join rule, encryption and whether we may
* open its slot, from the given room driver, kept current.
*/
export function useRoomInfoFrom(room: RoomDriver): RoomInfo {
const [info, setInfo] = useState(() => room.getRoomInfo());
useEffect(() => {
setInfo(room.getRoomInfo());
return room.subscribeRoomInfo(setInfo);
}, [room]);
return info;
}
/**
* {@link useRoomInfoFrom} for the call's room: the client driver the host
* provided. Replaces `useRoomName`, `useRoomAvatar` and `useJoinRule` under
* `CallView`.
*/
export function useRoomInfo(): RoomInfo {
return useRoomInfoFrom(useMatrixDrivers().clientDriver);
}
-37
View File
@@ -1,37 +0,0 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useCallback } from "react";
import {
type RoomState,
RoomStateEvent,
type Room,
RoomEvent,
} from "matrix-js-sdk";
import { useTypedEventEmitterState } from "../useEvents";
/**
* A React hook for values computed from room state.
* @param room The room.
* @param f A mapping from the current room state to the computed value.
* @returns The computed value.
*/
export function useRoomState<T>(room: Room, f: (state: RoomState) => T): T {
// TODO: matrix-js-sdk says that Room.currentState is deprecated, but it's not
// clear how to reactively track the current state of the room without it
const currentState = useTypedEventEmitterState(
room,
RoomEvent.CurrentStateUpdated,
useCallback(() => room.currentState, [room]),
);
return useTypedEventEmitterState(
currentState,
RoomStateEvent.Update,
useCallback(() => f(currentState), [f, currentState]),
);
}
+143 -19
View File
@@ -45,6 +45,7 @@ import {
muteAllAudio as muteAllAudioSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting,
callViewModelImplementation as callViewModelImplementationSetting,
customLivekitUrl as customLivekitUrlSetting,
advancedScreenShare as advancedScreenShareSetting,
screenShareResolution as screenShareResolutionSetting,
@@ -62,12 +63,17 @@ import {
type VideoCodec,
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
} from "./settings";
import { MatrixRTCMode } from "../config/ConfigOptions";
import {
CallViewModelImplementation,
MatrixRTCMode,
} from "../config/ConfigOptions";
import styles from "./DeveloperSettingsTab.module.css";
import settingsStyles from "./SettingsModal.module.css";
import { Slider } from "../Slider";
import { useUrlParams } from "../UrlParams";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
import { useOptionalMatrixDrivers } from "../driver/MatrixDriverContext";
import { ELEMENT_CALL_SLOT_ID } from "../state/rtc/slot";
import { useBehavior } from "../useBehavior";
import { type ViewModel } from "../state/ViewModel.ts";
@@ -103,7 +109,8 @@ const KeyRotationStatus: FC<{ info: KeyRotationInfo }> = ({ info }) => (
);
interface Props {
client: MatrixClient;
/** The matrix-js-sdk client, when the host has one; the drivers otherwise. */
client?: MatrixClient;
roomId?: string;
livekitRooms?: {
room: LivekitRoom;
@@ -129,17 +136,40 @@ export const DeveloperSettingsTab: FC<Props> = ({
debugTileLayoutSetting,
);
const drivers = useOptionalMatrixDrivers();
const [stickyEventsSupported, setStickyEventsSupported] = useState(false);
useEffect(() => {
client
.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS)
const probe =
client !== undefined
? client.doesServerSupportUnstableFeature(
UNSTABLE_MSC4354_STICKY_EVENTS,
)
: drivers !== null
? drivers.clientDriver
.getMatrixClientFeatures()
.then((features) => features.stickyEvents)
: Promise.resolve(false);
probe
.then((result) => {
setStickyEventsSupported(result);
})
.catch((ex) => {
logger.warn("Failed to check if sticky events are supported", ex);
});
}, [client]);
}, [client, drivers]);
// What the host knows about itself, for the facts below.
const [diagnostics, setDiagnostics] = useState<Record<string, string>>({});
useEffect(() => {
drivers?.clientDriver
.getDiagnostics?.()
.then(setDiagnostics)
.catch((ex) =>
logger.warn("Could not read the driver's diagnostics", ex),
);
}, [drivers]);
const ownUserId = client?.getUserId() ?? drivers?.clientDriver.userId ?? null;
const ownDeviceId =
client?.getDeviceId() ?? drivers?.clientDriver.deviceId ?? null;
const [matrixRTCMode, setMatrixRTCMode] = useSetting(matrixRTCModeSetting);
const matrixRTCModeRadioGroup = useId();
@@ -155,6 +185,20 @@ export const DeveloperSettingsTab: FC<Props> = ({
const matrixRTCModeForced = configMatrixRTCMode !== undefined;
const effectiveMatrixRTCMode = configMatrixRTCMode ?? matrixRTCMode;
const [implementation, setImplementation] = useSetting(
callViewModelImplementationSetting,
);
const implementationRadioGroup = useId();
const onImplementationChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setImplementation(e.target.value as CallViewModelImplementation);
},
[setImplementation],
);
const configImplementation = Config.get().call_view_model_implementation;
const implementationForced = configImplementation !== undefined;
const effectiveImplementation = configImplementation ?? implementation;
const [showConnectionStats, setShowConnectionStats] = useSetting(
showConnectionStatsSetting,
);
@@ -387,17 +431,20 @@ export const DeveloperSettingsTab: FC<Props> = ({
</p>
<p>
{t("developer_mode.crypto_version", {
version: client.getCrypto()?.getVersion() || "unknown",
version:
client?.getCrypto()?.getVersion() ||
diagnostics.crypto_version ||
"unknown",
})}
</p>
<p>
{t("developer_mode.matrix_id", {
id: client.getUserId() || "unknown",
id: ownUserId || "unknown",
})}
</p>
<p>
{t("developer_mode.device_id", {
id: client.getDeviceId() || "unknown",
id: ownDeviceId || "unknown",
})}
</p>
{keyRotation !== null && <KeyRotationStatus info={keyRotation} />}
@@ -512,25 +559,45 @@ export const DeveloperSettingsTab: FC<Props> = ({
}
try {
const userId = client.getUserId();
const deviceId = client.getDeviceId();
if (userId === null || deviceId === null) {
if (ownUserId === null || ownDeviceId === null) {
throw new Error("Invalid user or device ID");
}
await getSFUConfigWithOpenID(
client,
{ userId, deviceId, memberId: "" },
customLivekitUrlTextBuffer,
roomId,
);
if (client !== undefined) {
await getSFUConfigWithOpenID(
client,
{ userId: ownUserId, deviceId: ownDeviceId, memberId: "" },
customLivekitUrlTextBuffer,
roomId,
);
} else if (drivers !== null) {
// Ask the service for a token the way the crate would.
await drivers.rtcDriver.getLivekitToken({
url: customLivekitUrlTextBuffer,
roomId,
slotId: ELEMENT_CALL_SLOT_ID,
memberJson: JSON.stringify({
id: "",
claimed_user_id: ownUserId,
claimed_device_id: ownDeviceId,
}),
legacySfuGet: false,
});
}
setCustomLivekitUrlUpdateError(null);
setCustomLivekitUrl(customLivekitUrlTextBuffer);
} catch {
setCustomLivekitUrlUpdateError("invalid URL (did not update)");
}
},
[customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId],
[
customLivekitUrlTextBuffer,
setCustomLivekitUrl,
client,
drivers,
roomId,
ownUserId,
ownDeviceId,
],
)}
value={customLivekitUrlTextBuffer ?? ""}
onChange={useCallback(
@@ -590,6 +657,63 @@ export const DeveloperSettingsTab: FC<Props> = ({
</HelpMessage>
</InlineField>
</Form>
<Separator />
<Heading as="h3" type="body" weight="semibold" size="lg">
{t("developer_mode.callViewModelImplementation.title")}
</Heading>
{implementationForced && (
<p>{t("developer_mode.callViewModelImplementation.forced")}</p>
)}
<Form>
<InlineField
name={implementationRadioGroup}
control={
<RadioControl
checked={
effectiveImplementation ===
CallViewModelImplementation.MatrixJsSdk
}
value={CallViewModelImplementation.MatrixJsSdk}
disabled={implementationForced}
onChange={onImplementationChange}
/>
}
>
<Label>
{t(
"developer_mode.callViewModelImplementation.matrix_js_sdk.label",
)}
</Label>
<HelpMessage>
{t(
"developer_mode.callViewModelImplementation.matrix_js_sdk.description",
)}
</HelpMessage>
</InlineField>
<InlineField
name={implementationRadioGroup}
control={
<RadioControl
checked={
effectiveImplementation ===
CallViewModelImplementation.MatrixRtc
}
value={CallViewModelImplementation.MatrixRtc}
disabled={implementationForced}
onChange={onImplementationChange}
/>
}
>
<Label>
{t("developer_mode.callViewModelImplementation.matrix_rtc.label")}
</Label>
<HelpMessage>
{t(
"developer_mode.callViewModelImplementation.matrix_rtc.description",
)}
</HelpMessage>
</InlineField>
</Form>
{livekitRooms?.map((livekitRoom) => (
<div className={styles.livekit_room_box}>
<h4>
+41 -16
View File
@@ -5,23 +5,48 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useEffect, useMemo, useRef } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import { useProfile } from "../profile/useProfile";
import { useOwnProfile } from "../profile/useOwnProfile";
import { useMatrixDrivers } from "../driver/MatrixDriverContext";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { AvatarInputField } from "../input/AvatarInputField";
import styles from "./ProfileSettingsTab.module.css";
interface Props {
client: MatrixClient;
}
export const ProfileSettingsTab: FC<Props> = ({ client }) => {
export const ProfileSettingsTab: FC = () => {
const { t } = useTranslation();
const { error, displayName, avatarUrl, saveProfile } = useProfile(client);
const userId = useMemo(() => client.getUserId(), [client]);
const { clientDriver } = useMatrixDrivers();
const { displayName, avatarUrl } = useOwnProfile();
const userId = clientDriver.userId;
const [error, setError] = useState<Error | undefined>(undefined);
// The driver may offer no way to edit the profile; then it is shown as is.
const canEdit =
clientDriver.setDisplayName !== undefined &&
clientDriver.setAvatar !== undefined;
const saveProfile = useCallback(
async ({
displayName,
avatar,
removeAvatar,
}: {
displayName: string;
avatar: Blob | undefined;
removeAvatar: boolean;
}): Promise<void> => {
try {
await clientDriver.setDisplayName?.(displayName);
if (removeAvatar) await clientDriver.setAvatar?.(null);
else if (avatar) await clientDriver.setAvatar?.(avatar);
setError(undefined);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
throw e;
}
},
[clientDriver],
);
const formRef = useRef<HTMLFormElement | null>(null);
@@ -58,9 +83,7 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
saveProfile({
displayName,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
avatar: avatar && avatarSize > 0 ? avatar : undefined,
avatar: avatar instanceof Blob && avatarSize > 0 ? avatar : undefined,
removeAvatar: removeAvatar.current && (!avatar || avatarSize === 0),
}).catch((e) => {
logger.error("Failed to save profile", e);
@@ -72,15 +95,16 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
return (
<form onChange={onFormChange} ref={formRef} className={styles.content}>
<FieldRow className={styles.avatarFieldRow}>
{userId && displayName && (
{displayName && (
<AvatarInputField
id="avatar"
name="avatar"
label={t("common.avatar")}
avatarUrl={avatarUrl}
avatarUrl={avatarUrl ?? undefined}
userId={userId}
displayName={displayName}
onRemoveAvatar={onRemoveAvatar}
disabled={!canEdit}
/>
)}
</FieldRow>
@@ -91,7 +115,7 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
label={t("common.username")}
type="text"
disabled
value={client.getUserId()!}
value={userId}
/>
</FieldRow>
<FieldRow>
@@ -101,9 +125,10 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
label={t("common.display_name")}
type="text"
required
disabled={!canEdit}
autoComplete="off"
placeholder={t("common.display_name")}
defaultValue={displayName}
defaultValue={displayName ?? undefined}
data-testid="profile_displayname"
/>
</FieldRow>
+3 -2
View File
@@ -54,7 +54,8 @@ interface Props {
onDismiss: () => void;
tab: SettingsTab;
onTabChange: (tab: SettingsTab) => void;
client: MatrixClient;
/** The matrix-js-sdk client, for what the developer tab still reads from it. */
client?: MatrixClient;
roomId?: string;
livekitRooms?: {
room: LivekitRoom;
@@ -211,7 +212,7 @@ export const SettingsModal: FC<Props> = ({
const profileTab: Tab<SettingsTab> = {
key: "profile",
name: t("common.profile"),
content: <ProfileSettingsTab client={client} />,
content: <ProfileSettingsTab />,
};
const feedbackTab: Tab<SettingsTab> = {
@@ -30,7 +30,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _inputField_1bd8c0"
>
<input
aria-describedby="_r_1_"
aria-describedby="_r_2_"
id="duplicateTiles"
min="0"
type="number"
@@ -50,7 +50,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_2_"
aria-describedby="_r_3_"
id="debugTileLayout"
type="checkbox"
/>
@@ -87,7 +87,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_3_"
aria-describedby="_r_4_"
id="showConnectionStats"
type="checkbox"
/>
@@ -124,7 +124,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_4_"
aria-describedby="_r_5_"
id="muteAllAudio"
type="checkbox"
/>
@@ -162,7 +162,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_5_"
aria-describedby="_r_6_"
id="alwaysShowIphoneEarpiece"
type="checkbox"
/>
@@ -199,7 +199,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_6_"
aria-describedby="_r_7_"
id="enableLivekitExtendedLogs"
type="checkbox"
/>
@@ -237,7 +237,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_7_"
for="radix-_r_8_"
>
Custom Livekit-url
</label>
@@ -245,9 +245,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_controls_17lij_8"
>
<input
aria-describedby="radix-_r_8_"
aria-describedby="radix-_r_9_"
class="_control_d83jn_10"
id="radix-_r_7_"
id="radix-_r_8_"
name="input"
title=""
value=""
@@ -255,7 +255,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_8_"
id="radix-_r_9_"
>
Currently, no overwrite is set. Url from config is used.
</span>
@@ -285,10 +285,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_a_ radix-_r_c_"
aria-describedby="radix-_r_b_ radix-_r_d_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_9_"
id="radix-_r_a_"
name="_r_0_"
title=""
type="radio"
@@ -304,13 +304,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_9_"
for="radix-_r_a_"
>
Compatibility: state events & multi SFU
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_a_"
id="radix-_r_b_"
>
Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)
</span>
@@ -326,9 +326,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_a_ radix-_r_c_"
aria-describedby="radix-_r_b_ radix-_r_d_"
class="_input_1ug7n_18"
id="radix-_r_b_"
id="radix-_r_c_"
name="_r_0_"
title=""
type="radio"
@@ -344,19 +344,115 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_b_"
for="radix-_r_c_"
>
Matrix 2.0: sticky events & multi SFU
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_c_"
id="radix-_r_d_"
>
Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later
</span>
</div>
</div>
</form>
<div
class="_separator_13qwf_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<h3
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74"
>
Call implementation
</h3>
<form
class="_root_1o4d9_17"
>
<div
class="_inline-field_1o4d9_33"
>
<div
class="_inline-field-control_1o4d9_45"
>
<div
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_f_ radix-_r_h_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_e_"
name="_r_1_"
title=""
type="radio"
value="matrix-js-sdk"
/>
<div
class="_ui_1ug7n_19"
/>
</div>
</div>
<div
class="_inline-field-body_1o4d9_39"
>
<label
class="_label_1o4d9_60"
for="radix-_r_e_"
>
matrix-js-sdk
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_f_"
>
The MatrixRTC session of matrix-js-sdk carries the call, as before
</span>
</div>
</div>
<div
class="_inline-field_1o4d9_33"
>
<div
class="_inline-field-control_1o4d9_45"
>
<div
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_f_ radix-_r_h_"
class="_input_1ug7n_18"
id="radix-_r_g_"
name="_r_1_"
title=""
type="radio"
value="matrix-rtc"
/>
<div
class="_ui_1ug7n_19"
/>
</div>
</div>
<div
class="_inline-field-body_1o4d9_39"
>
<label
class="_label_1o4d9_60"
for="radix-_r_g_"
>
matrix-rtc (Rust)
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_h_"
>
The Rust matrix-rtc crate carries the call through the host's drivers
</span>
</div>
</div>
</form>
<div
class="_livekit_room_box_2ddec4"
>
@@ -459,7 +555,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_i_"
aria-describedby="_r_n_"
id="cameraToggle"
type="checkbox"
/>
@@ -489,7 +585,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</label>
<p
class="_description_1bd8c0"
id="_r_i_"
id="_r_n_"
>
Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.
</p>
@@ -511,7 +607,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_j_"
aria-describedby="_r_o_"
id="screenShareToggle"
type="checkbox"
/>
@@ -541,7 +637,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</label>
<p
class="_description_1bd8c0"
id="_r_j_"
id="_r_o_"
>
Configure resolution, framerate, bitrate, and codec for screen sharing
</p>
@@ -566,7 +662,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_k_"
aria-describedby="_r_p_"
checked=""
id="echoCancellation"
type="checkbox"
@@ -604,7 +700,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_l_"
aria-describedby="_r_q_"
checked=""
id="noiseSuppression"
type="checkbox"
@@ -642,7 +738,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_m_"
aria-describedby="_r_r_"
checked=""
id="autoGainControl"
type="checkbox"
+16 -1
View File
@@ -11,7 +11,10 @@ import { BehaviorSubject } from "rxjs";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { type Behavior } from "../state/Behavior";
import { useBehavior } from "../useBehavior";
import { MatrixRTCMode } from "../config/ConfigOptions";
import {
CallViewModelImplementation,
MatrixRTCMode,
} from "../config/ConfigOptions";
export class Setting<T> {
public constructor(
@@ -153,6 +156,18 @@ export const matrixRTCMode = new Setting<MatrixRTCMode>(
MatrixRTCMode.Compatibility,
);
/**
* Which implementation carries the call (see
* {@link CallViewModelImplementation}). Sampled when the call view mounts;
* `config.json` may pin it. Defaults to matrix-js-sdk until the crate path
* has passed its gate.
*/
export const callViewModelImplementation =
new Setting<CallViewModelImplementation>(
"call-view-model-implementation",
CallViewModelImplementation.MatrixJsSdk,
);
export const customLivekitUrl = new Setting<string | null>(
"custom-livekit-url",
null,
+55 -11
View File
@@ -23,6 +23,8 @@ import { deepCompare } from "matrix-js-sdk/lib/utils";
import { advancedCamera as advancedCameraSetting } from "./settings";
import { advancedScreenShare as advancedScreenShareSetting } from "./settings";
import { DEFAULT_CONFIG } from "../config/ConfigOptions";
import { effectiveCallViewModelImplementation } from "../state/rtc/implementation";
import { useOptionalMatrixDrivers } from "../driver/MatrixDriverContext";
const gzip = async (text: string): Promise<Blob> => {
// pako is relatively large (200KB), so we only import it when needed
const { gzip: pakoGzip } = await import("pako");
@@ -143,6 +145,7 @@ export function useSubmitRageshake(
available: boolean;
} {
const { client } = useClient();
const drivers = useOptionalMatrixDrivers();
const [{ sending, sent, error }, setState] = useState<{
sending: boolean;
@@ -202,6 +205,29 @@ export function useSubmitRageshake(
body.append("installed_pwa", "false");
body.append("touch_input", touchInput);
body.append("call_backend", "livekit");
body.append(
"call_view_model_implementation",
effectiveCallViewModelImplementation(),
);
// What the host's drivers know, and the crate's own view of the
// call when it carries it.
if (drivers?.clientDriver.getDiagnostics) {
try {
for (const [key, value] of Object.entries(
await drivers.clientDriver.getDiagnostics(),
))
body.append(`driver_${key}`, value);
} catch (e) {
logger.warn("Could not collect the driver's diagnostics", e);
}
}
const rtcParticipationManager =
window.matrixRtc?.rtcParticipationManager;
if (rtcParticipationManager)
body.append(
"matrix_rtc_snapshot",
rtcParticipationManager.debugSnapshot(),
);
body.append("hostname", window.location.hostname);
if (client) {
@@ -316,7 +342,7 @@ export function useSubmitRageshake(
logger.error(error);
}
},
[client, sending, injectedGetRageshakeSubmitUrl],
[client, drivers, sending, injectedGetRageshakeSubmitUrl],
);
return {
@@ -333,19 +359,24 @@ export function useRageshakeRequest(): (
rageshakeRequestId: string,
) => void {
const { client } = useClient();
const drivers = useOptionalMatrixDrivers();
const sendRageshakeRequest = useCallback(
(roomId: string, rageshakeRequestId: string) => {
client!
// @ts-expect-error - org.matrix.rageshake_request is not part of `keyof TimelineEvents` but it is okay to sent a custom event.
.sendEvent(roomId, "org.matrix.rageshake_request", {
request_id: rageshakeRequestId,
})
.catch((e) => {
logger.error("Failed to send org.matrix.rageshake_request event", e);
});
const content = { request_id: rageshakeRequestId };
const sent: Promise<unknown> =
drivers !== null
? drivers.clientDriver.sendRoomEvent(
"org.matrix.rageshake_request",
content,
)
: // @ts-expect-error - org.matrix.rageshake_request is not part of `keyof TimelineEvents` but it is okay to sent a custom event.
client!.sendEvent(roomId, "org.matrix.rageshake_request", content);
sent.catch((e: unknown) => {
logger.error("Failed to send org.matrix.rageshake_request event", e);
});
},
[client],
[client, drivers],
);
return sendRageshakeRequest;
}
@@ -356,9 +387,22 @@ export function useRageshakeRequestModal(
const [open, setOpen] = useState(false);
const onDismiss = useCallback(() => setOpen(false), [setOpen]);
const { client } = useClient();
const drivers = useOptionalMatrixDrivers();
const [rageshakeRequestId, setRageshakeRequestId] = useState<string>();
useEffect(() => {
if (drivers !== null) {
const { clientDriver } = drivers;
return clientDriver.subscribeTimeline((event) => {
if (
event.type === "org.matrix.rageshake_request" &&
event.sender !== clientDriver.userId
) {
setRageshakeRequestId(event.content.request_id as string);
setOpen(true);
}
});
}
if (!client) return;
const onEvent = (event: MatrixEvent): void => {
@@ -379,7 +423,7 @@ export function useRageshakeRequestModal(
return (): void => {
client.removeListener(ClientEvent.Event, onEvent);
};
}, [setOpen, roomId, client]);
}, [setOpen, roomId, client, drivers]);
return {
rageshakeRequestId: rageshakeRequestId ?? "",
-1
View File
@@ -77,7 +77,6 @@ function renderWithMockClient(
<ClientContextProvider
value={{
state: "valid",
disconnected: false,
supportedFeatures: {
reactions: true,
},
@@ -6,12 +6,6 @@ Please see LICENSE in the repository root for full details.
*/
import { test } from "vitest";
import {
EventType,
type IRoomTimelineData,
MatrixEvent,
type Room,
} from "matrix-js-sdk";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
@@ -182,22 +176,10 @@ test("ring attempt can be declined", () => {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("--d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: alice.userId,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$notif1",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
d: {
sender: alice.userId,
relatesTo: { relType: "m.reference", eventId: "$notif1" },
},
}),
});
@@ -217,22 +199,10 @@ test("ring attempt times out if recipient declines too late", () => {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("100ms d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: alice.userId,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$notif1",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
d: {
sender: alice.userId,
relatesTo: { relType: "m.reference", eventId: "$notif1" },
},
}),
});
@@ -252,22 +222,13 @@ test("decline event relating to wrong event is ignored (times out)", () => {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("--d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: alice.userId,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$other", // <---- WRONG
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
d: {
sender: alice.userId,
relatesTo: {
relType: "m.reference",
eventId: "$other", // <---- WRONG
},
},
}),
});
@@ -287,22 +248,13 @@ test("decline event from wrong sender is ignored (times out)", () => {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("--d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: local.userId, // <---- WRONG
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$notif1",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
d: {
sender: local.userId, // <---- WRONG
relatesTo: {
relType: "m.reference",
eventId: "$notif1",
},
},
}),
});
@@ -37,7 +37,6 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior";
import { type Epoch, type ObservableScope } from "../ObservableScope";
import { type RoomMemberMap } from "./remoteMembers/MatrixMemberMetadata";
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
@@ -69,24 +68,42 @@ export function createSentCallNotification$(
return sentCallNotification$;
}
/** A received `org.matrix.msc4310.rtc.decline`, as far as ringing cares. */
export interface DeclineEvent {
sender: string;
/** The `m.relates_to` of the event, when it has one. */
relatesTo: { relType?: string; eventId?: string } | undefined;
}
export function createReceivedDecline$(
matrixRoom: MatrixRoom,
): Observable<Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>> {
): Observable<DeclineEvent> {
return (
fromEvent(matrixRoom, RoomEvent.Timeline) as Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>
).pipe(filter(([event]) => event.getType() === EventType.RTCDecline));
).pipe(
filter(([event]) => event.getType() === EventType.RTCDecline),
map(([event]) => {
const relation = event.getRelation();
return {
sender: event.getSender() ?? "",
relatesTo: relation
? { relType: relation.rel_type, eventId: relation.event_id }
: undefined,
};
}),
);
}
export interface Props {
scope: ObservableScope;
memberships$: Behavior<Epoch<CallMembership[]>>;
matrixRoomMembers$: Behavior<RoomMemberMap>;
/** The call's members; only who they are matters here. */
memberships$: Behavior<Epoch<Pick<CallMembership, "userId">[]>>;
/** The room's members; only who they are matters here. */
matrixRoomMembers$: Behavior<ReadonlyMap<string, unknown>>;
sentCallNotification$: Observable<CallNotificationWrapper | null>;
receivedDecline$: Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>;
receivedDecline$: Observable<DeclineEvent>;
options: { waitForCallPickup?: boolean; autoLeaveWhenOthersLeft?: boolean };
localUser: { deviceId: string; userId: string };
}
@@ -147,10 +164,10 @@ export function createCallNotificationLifecycle$({
// Call is declined when we receive a decline event
const decline$ = receivedDecline$.pipe(
filter(
([event]) =>
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id === notificationEvent.event_id &&
event.getSender() === recipient,
(event) =>
event.relatesTo?.relType === "m.reference" &&
event.relatesTo.eventId === notificationEvent.event_id &&
event.sender === recipient,
),
map(() => "decline" as const),
);
@@ -0,0 +1,224 @@
/*
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.
*/
/**
* `createCallViewModel$` over a real `RtcParticipationManager` (the crate, through
* the mock drivers) and mocked LiveKit connections: the Matrix side end to
* end, from the user's join to the roster and back out.
*
* Real wasm, real timers — nothing here may use `vi.useFakeTimers`.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { BehaviorSubject } from "rxjs";
import { type Room as LivekitRoom } from "livekit-client";
import { MatrixRTCMode } from "../../config/ConfigOptions";
import { E2eeType } from "../../e2ee/e2eeType";
import { MockElementCallMatrixClientDriver } from "../../driver/MockElementCallMatrixClientDriver";
import {
MOCK_LK_SERVICE_URL,
MockRtcMatrixDriver,
slotEvent,
waitFor,
} from "../../driver/MockRtcMatrixDriver";
import { FfiStatus } from "../../matrix-rtc-sdk";
import { type RaisedHandInfo, type ReactionInfo } from "../../reactions";
import { MatrixRTCTransportMissingError } from "../../utils/errors";
import {
MockConnection,
mockConfig,
mockLivekitRoom,
mockLocalParticipant,
mockMediaDevices,
mockMuteStates,
testScope,
} from "../../utils/test";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { constant } from "../Behavior";
import { RtcParticipationManager } from "../rtc/RtcParticipationManager";
import { joinParamsFromConfig, participationConfig } from "../rtc/joinParams";
import { type CallViewModel, createCallViewModel$ } from "./CallViewModel";
mockConfig({});
const session = {
delayed_leave: { delay_ms: 15_000 },
delegated_delayed_leave: { delay_ms: 3_600_000 },
network_error_retry_ms: 1000,
wait_for_key_rotation_ms: 50,
};
const peer = {
userId: "@peer:example.org",
deviceId: "PEERDEV",
memberId: "m-peer",
};
function createEnvironment(driver: MockRtcMatrixDriver): {
vm: CallViewModel;
rtcParticipationManager: RtcParticipationManager;
clientDriver: MockElementCallMatrixClientDriver;
} {
const scope = testScope();
const clientDriver = new MockElementCallMatrixClientDriver({
userId: driver.userId,
deviceId: driver.deviceId,
roomId: driver.roomId,
members: [
{
userId: driver.userId,
displayName: "Me",
avatarUrl: null,
membership: "join",
},
{
userId: peer.userId,
displayName: "Peer",
avatarUrl: null,
membership: "join",
},
],
});
const rtcParticipationManager = new RtcParticipationManager(
scope,
driver,
driver.roomId,
driver.userId,
driver.deviceId,
{
config: participationConfig({
mode: MatrixRTCMode.Matrix_2_0,
manageMediaKeys: false,
session,
}),
},
);
const livekitRoomFactory = (): LivekitRoom =>
mockLivekitRoom({
localParticipant: mockLocalParticipant({ identity: "" }),
remoteParticipants: new Map(),
disconnect: async () => Promise.resolve(),
setE2EEEnabled: async () => Promise.resolve(),
});
const vm = createCallViewModel$(
scope,
rtcParticipationManager,
clientDriver,
mockMediaDevices({}),
mockMuteStates(),
{
encryptionSystem: { kind: E2eeType.NONE },
autoLeaveWhenOthersLeft: false,
livekitRoomFactory,
connectionFactory: {
createConnection(scope, transport, ownMembershipIdentity, logger, sfu) {
return new MockConnection(
{
scope,
transport,
ownMembershipIdentity,
existingSFUConfig: sfu,
client: null,
roomId: driver.roomId,
livekitRoomFactory,
},
logger,
);
},
},
windowSize$: constant({ width: 1000, height: 800 }),
joinParams: joinParamsFromConfig({
session,
delegateDelayedLeave: false,
}),
},
new BehaviorSubject<Record<string, RaisedHandInfo>>({}),
new BehaviorSubject<Record<string, ReactionInfo>>({}),
constant({ processor: undefined, supported: false }),
);
return { vm, rtcParticipationManager, clientDriver };
}
describe("createCallViewModel$ over a RtcParticipationManager", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
it("joins through the crate, lists the members and leaves again", async () => {
// Somebody already started the call: the slot is open.
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
const { vm, rtcParticipationManager } = createEnvironment(driver);
expect(vm.participantCount$.value).toBe(0);
expect(vm.connected$.value).toBe(false);
vm.join();
await waitFor("the crate to be connected", () =>
FfiStatus.Connected.instanceOf(rtcParticipationManager.status$.value),
);
// The crate discovered the transport and minted our token; the view
// model holds a connection to it.
await waitFor(
"our connection",
() => vm.allConnections$.value.getConnections().length === 1,
);
expect(
vm.allConnections$.value.getConnections()[0].transport
.livekit_service_url,
).toBe(MOCK_LK_SERVICE_URL);
// Our own membership echoed back: we are a member with our device.
await waitFor(
"our own tile",
() => vm.localMatrixLivekitMember$.value !== null,
);
expect(vm.localMatrixLivekitMember$.value?.membership$.value).toMatchObject(
{ userId: driver.userId, deviceId: driver.deviceId },
);
expect(vm.participantCount$.value).toBe(1);
expect(vm.fatalError$.value).toBeNull();
driver.peerJoins(peer);
await waitFor(
"the peer's tile",
() => vm.remoteMatrixLivekitMembers$.value.length === 1,
);
const [remote] = vm.remoteMatrixLivekitMembers$.value;
expect(remote.membership$.value).toMatchObject({
userId: peer.userId,
memberId: peer.memberId,
});
// Both publish on the same service: one connection carries both.
expect(remote.connection$.value?.transport.livekit_service_url).toBe(
MOCK_LK_SERVICE_URL,
);
expect(vm.participantCount$.value).toBe(2);
vm.leave();
await waitFor("the crate to be disconnected", () =>
FfiStatus.Disconnected.instanceOf(rtcParticipationManager.status$.value),
);
await waitFor(
"our tile to go",
() => vm.localMatrixLivekitMember$.value === null,
);
expect(vm.fatalError$.value).toBeNull();
});
it("shows the crate's join failure as the fatal error", async () => {
// The homeserver advertises no transport and there is no fallback.
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
transports: [],
});
const { vm } = createEnvironment(driver);
vm.join();
await waitFor("the fatal error", () => vm.fatalError$.value !== null);
expect(vm.fatalError$.value).toBeInstanceOf(MatrixRTCTransportMissingError);
});
});
+347 -10
View File
@@ -109,6 +109,7 @@ import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts
import {
createLocalMembership$,
enterRTCSession,
type LocalMembership,
TransportState,
} from "./localMember/LocalMember.ts";
import {
@@ -138,10 +139,13 @@ import {
createCallNotificationLifecycle$,
createReceivedDecline$,
createSentCallNotification$,
type RingAttempt,
} from "./CallNotificationLifecycle.ts";
import {
createMatrixMemberMetadata$,
createRoomMembers$,
type MatrixMemberMetadata,
type RoomMemberMap,
} from "./remoteMembers/MatrixMemberMetadata.ts";
import { Publisher } from "./localMember/Publisher.ts";
import { type Connection } from "./remoteMembers/Connection.ts";
@@ -163,6 +167,25 @@ import {
type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
import { mapEpoch } from "../ObservableScope.ts";
import { type RtcParticipationManager } from "../rtc/RtcParticipationManager.ts";
import { joinParamsFromConfig } from "../rtc/joinParams.ts";
import { type FfiJoinParams } from "../../matrix-rtc-sdk";
import { type ElementCallMatrixClientDriver } from "../../driver/ElementCallMatrixClientDriver.ts";
import { observeDriver } from "../../driver/observe.ts";
import { ParticipationKeyProvider } from "../../e2ee/participationKeyProvider.ts";
import { customLivekitUrl } from "../../settings/settings.ts";
import { createParticipationConnectionManager$ } from "./remoteMembers/ParticipationConnections.ts";
import {
callMemberOf,
createParticipationRemoteMembers$,
} from "./remoteMembers/ParticipationMembers.ts";
import { createParticipationRoomMembers$ } from "./remoteMembers/ParticipationMemberMetadata.ts";
import { createParticipationLocalMembership$ } from "./localMember/ParticipationLocalMember.ts";
import {
createParticipationReceivedDecline$,
createParticipationSentCallNotification$,
} from "./ParticipationCallNotification.ts";
//TODO
// Larger rename
@@ -222,6 +245,12 @@ export interface CallViewModelOptions {
matrixRTCMode?: MatrixRTCMode;
/** Optional behavior overriding for the screensharing, for testing */
toggleScreensharing?: () => void;
/**
* How to join the MatrixRTC session. Only {@link createCallViewModel$}
* reads it; defaults to {@link joinParamsFromConfig} over the deployment's
* `matrix_rtc_session` configuration and {@link callIntent}.
*/
joinParams?: FfiJoinParams;
}
/**
@@ -491,7 +520,7 @@ export interface CallViewModel {
// Throughout this class and related code we must distinguish between MatrixRTC
// state and LiveKit state. We use the common terminology of room "members", RTC
// "memberships", and LiveKit "participants".
export function createCallViewModel$(
export function createJsClientCallViewModel$(
scope: ObservableScope,
// A call is permanently tied to a single Matrix room
matrixRTCSession: MatrixRTCSession,
@@ -515,8 +544,6 @@ export function createCallViewModel$(
const {
hostBridge = nullHostBridge,
controlledAudioDevices = false,
header = HeaderStyle.Standard,
showControls = true,
hideScreensharing = false,
sendNotificationType,
callIntent,
@@ -741,9 +768,86 @@ export function createCallViewModel$(
options,
localUser: { userId, deviceId },
});
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
scope,
matrixRTCSession,
);
return assembleCallViewModel(
scope,
{
localMembership,
matrixLivekitMembers$,
remoteMatrixLivekitMembers$,
localMatrixLivekitMember$,
matrixMemberMetadataStore,
matrixRoomMembers$,
ringAttempts$,
autoLeave$,
connectionManagerData$: connectionManager.connectionManagerData$,
keyRotationSuppressed$,
},
options,
mediaDevices,
handsRaisedSubject$,
reactionsSubject$,
);
}
/**
* The Matrix-side inputs the call view model is assembled from: what a
* MatrixRTC implementation has to provide so that the rest — tiles, layout,
* sounds, header and footer — is the same whichever one it is.
*
* {@link createJsClientCallViewModel$} builds it from matrix-js-sdk's
* `MatrixRTCSession`; {@link createCallViewModel$} from a
* {@link RtcParticipationManager} over the drivers.
*/
export interface CallViewModelCore {
localMembership: LocalMembership;
matrixLivekitMembers$: Behavior<
(LocalMatrixLivekitMember | RemoteMatrixLivekitMember)[]
>;
remoteMatrixLivekitMembers$: Behavior<Epoch<RemoteMatrixLivekitMember[]>>;
localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null>;
matrixMemberMetadataStore: MatrixMemberMetadata;
/** The room's members, for the ringing name and the name-tag threshold. */
matrixRoomMembers$: Behavior<RoomMemberMap>;
ringAttempts$: Observable<RingAttempt>;
autoLeave$: Observable<AutoLeaveReason>;
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
keyRotationSuppressed$: Behavior<boolean>;
}
/** The half of the view model that does not care where the call comes from. */
function assembleCallViewModel(
scope: ObservableScope,
{
localMembership,
matrixLivekitMembers$,
remoteMatrixLivekitMembers$,
localMatrixLivekitMember$,
matrixMemberMetadataStore,
matrixRoomMembers$,
ringAttempts$,
autoLeave$,
connectionManagerData$,
keyRotationSuppressed$,
}: CallViewModelCore,
options: CallViewModelOptions,
mediaDevices: MediaDevices,
handsRaisedSubject$: Observable<Record<string, RaisedHandInfo>>,
reactionsSubject$: Observable<Record<string, ReactionInfo>>,
): CallViewModel {
const logger = rootLogger.getChild("[CallViewModel]");
const {
hostBridge = nullHostBridge,
header = HeaderStyle.Standard,
showControls = true,
} = options;
const allConnections$ = scope.behavior(
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
connectionManagerData$.pipe(map((d) => d.value)),
);
const livekitRoomItems$ = scope.behavior(
remoteMatrixLivekitMembers$.pipe(
@@ -838,7 +942,9 @@ export function createCallViewModel$(
createWrappedUserMedia(scope, {
id: `${mediaId}:${dup}`,
userId,
rtcBackendIdentity: rtcId,
// Shown for debugging only; a member whose identity is not yet
// known has none to show.
rtcBackendIdentity: rtcId ?? "",
participant,
encryptionSystem: options.encryptionSystem,
livekitRoom$: scope.behavior(
@@ -946,11 +1052,6 @@ export function createCallViewModel$(
matrixLivekitMembers$.pipe(map((ms) => ms.length)),
);
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
scope,
matrixRTCSession,
);
const leaveSoundEffect$ = userMedia$.pipe(
pairwise(),
filter(
@@ -1923,6 +2024,242 @@ export function createCallViewModel$(
};
}
/**
* The call view model over the host's drivers: a {@link RtcParticipationManager}
* (the crate: memberships, connections and their tokens, media keys, our own
* membership) and an {@link ElementCallMatrixClientDriver} (the room's
* members and metadata, the timeline for notifications).
*
* The counterpart of {@link createJsClientCallViewModel$}, which reads the
* same things from matrix-js-sdk; both assemble the same view model.
*/
export function createCallViewModel$(
scope: ObservableScope,
rtcParticipationManager: RtcParticipationManager,
clientDriver: ElementCallMatrixClientDriver,
mediaDevices: MediaDevices,
muteStates: MuteStates,
options: CallViewModelOptions,
handsRaisedSubject$: Observable<Record<string, RaisedHandInfo>>,
reactionsSubject$: Observable<Record<string, ReactionInfo>>,
trackProcessorState$: Behavior<ProcessorState>,
): CallViewModel {
const logger = rootLogger.getChild("[CallViewModel]");
const { userId, deviceId, roomId } = clientDriver;
const {
hostBridge = nullHostBridge,
controlledAudioDevices = false,
hideScreensharing = false,
callIntent,
} = options;
const livekitKeyProvider = getParticipationKeyProvider(
options.encryptionSystem,
scope,
rtcParticipationManager,
logger,
);
// ------------------------------------------------------------------------
// connections and remote members
const connectionFactory =
options.connectionFactory ??
new ECConnectionFactory(
// The crate mints every token; no connection fetches its own.
null,
roomId,
mediaDevices,
trackProcessorState$,
livekitKeyProvider,
controlledAudioDevices,
options.livekitRoomFactory,
);
const connectionManager = createParticipationConnectionManager$({
scope,
rtcParticipationManager,
connectionFactory,
ownIdentity: { userId, deviceId },
logger,
});
const remoteMatrixLivekitMembers$ = createParticipationRemoteMembers$({
scope,
rtcParticipationManager,
connectionManager,
});
// ------------------------------------------------------------------------
// localMembership
const roomInfo$ = observeDriver(
scope,
() => clientDriver.getRoomInfo(),
(listener) => clientDriver.subscribeRoomInfo(listener),
);
// Whether the homeserver takes sticky events decides how a failed first
// send reads; assume it does until the driver says otherwise.
let stickyEventsSupported = true;
clientDriver.getMatrixClientFeatures().then(
(features) => {
stickyEventsSupported = features.stickyEvents;
},
(e) => logger.warn("Could not read the driver's Matrix client features", e),
);
const localMembership = createParticipationLocalMembership$({
scope,
rtcParticipationManager,
connectionManager,
createPublisherFactory: (connection: Connection) =>
new Publisher(
connection,
mediaDevices,
muteStates,
trackProcessorState$,
logger.getChild(
"[Publisher " + connection.transport.livekit_service_url + "]",
),
controlledAudioDevices,
),
muteStates,
hideScreensharing,
hostBridge,
joinParams:
options.joinParams ??
joinParamsFromConfig({
session: Config.get().matrix_rtc_session,
callIntent,
}),
slotPolicy$: scope.behavior(
roomInfo$.pipe(
map((info) => ({
encrypted: info.encrypted,
canOpen: info.canOpenSlot,
})),
),
),
customLivekitUrl$: customLivekitUrl.value$,
disconnectContext: () => ({
domain: userId.slice(userId.indexOf(":") + 1),
stickyEventsSupported,
}),
roomId,
logger: logger.getChild(`[${Date.now()}]`),
});
const localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null> =
scope.behavior(
rtcParticipationManager.ownMembership$.pipe(
map((membership) =>
membership === null ? null : callMemberOf(membership),
),
filterBehavior((member) => member !== null),
map((membership$) => {
if (membership$ === null) return null;
return {
membership$,
participant: {
type: "local" as const,
value$: localMembership.participant$,
},
connection$: localMembership.connection$,
userId,
};
}),
),
);
const matrixLivekitMembers$ = scope.behavior(
combineLatest(
[localMatrixLivekitMember$, remoteMatrixLivekitMembers$],
(local, remote) => [...(local === null ? [] : [local]), ...remote.value],
),
);
// ------------------------------------------------------------------------
// matrixMemberMetadataStore
const matrixRoomMembers$ = createParticipationRoomMembers$(
scope,
clientDriver,
);
const callMemberUserIds$ = scope.behavior(
rtcParticipationManager.memberships$.pipe(
mapEpoch((memberships) =>
memberships.map((m) => ({ userId: m.member.userId })),
),
),
);
const matrixMemberMetadataStore = createMatrixMemberMetadata$(
scope,
scope.behavior(callMemberUserIds$.pipe(map((ms) => ms.value))),
matrixRoomMembers$,
);
// ------------------------------------------------------------------------
// callLifecycle
const { ringAttempts$, autoLeave$ } = createCallNotificationLifecycle$({
scope,
memberships$: callMemberUserIds$,
matrixRoomMembers$,
sentCallNotification$: createParticipationSentCallNotification$({
scope,
rtcParticipationManager,
timeline: clientDriver,
options,
logger,
}),
receivedDecline$: createParticipationReceivedDecline$(clientDriver),
options,
localUser: { userId, deviceId },
});
return assembleCallViewModel(
scope,
{
localMembership,
matrixLivekitMembers$,
remoteMatrixLivekitMembers$,
localMatrixLivekitMember$,
matrixMemberMetadataStore,
matrixRoomMembers$,
ringAttempts$,
autoLeave$,
connectionManagerData$: connectionManager.connectionManagerData$,
// The crate has no participant limit for key rotation.
keyRotationSuppressed$: constant(false),
},
options,
mediaDevices,
handsRaisedSubject$,
reactionsSubject$,
);
}
function getParticipationKeyProvider(
e2eeSystem: EncryptionSystem,
scope: ObservableScope,
rtcParticipationManager: RtcParticipationManager,
logger: Logger,
): BaseKeyProvider | undefined {
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
const keyProvider = new ParticipationKeyProvider();
keyProvider.attach(scope, rtcParticipationManager);
return keyProvider;
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
const keyProvider = new ExternalE2EEKeyProvider();
keyProvider
.setKey(e2eeSystem.secret)
.catch((e) => logger.error("Failed to set shared key for E2EE", e));
return keyProvider;
}
}
function getE2eeKeyProvider(
e2eeSystem: EncryptionSystem,
rtcSession: MatrixRTCSession,
@@ -26,7 +26,7 @@ import { E2eeType } from "../../e2ee/e2eeType";
import { type RaisedHandInfo, type ReactionInfo } from "../../reactions";
import {
type CallViewModel,
createCallViewModel$,
createJsClientCallViewModel$,
type CallViewModelOptions,
} from "./CallViewModel";
import {
@@ -197,7 +197,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
setE2EEEnabled: async () => Promise.resolve(),
});
const vm = createCallViewModel$(
const vm = createJsClientCallViewModel$(
testScope(),
rtcSession.asMockedSession(),
room,
@@ -0,0 +1,142 @@
/*
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 { logger } from "matrix-js-sdk/lib/logger";
import { testScope } from "../../utils/test";
import {
FakeParticipation,
fakeMembership,
} from "../../utils/test-participation";
import { MockElementCallMatrixClientDriver } from "../../driver/MockElementCallMatrixClientDriver";
import { waitFor } from "../../driver/MockRtcMatrixDriver";
import { type DeclineEvent } from "./CallNotificationLifecycle";
import {
createParticipationReceivedDecline$,
createParticipationSentCallNotification$,
RTC_DECLINE_EVENT_TYPE,
RTC_NOTIFICATION_EVENT_TYPE,
} from "./ParticipationCallNotification";
const own = fakeMembership({
member: { memberId: "m-me", userId: "@me:x", eventId: "$my-join" },
});
const peer = fakeMembership({ member: { memberId: "m-peer" } });
describe("createParticipationSentCallNotification$", () => {
it("rings once our membership echoes back, if we were first, and again after a rejoin", async () => {
const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver();
const sent$ = createParticipationSentCallNotification$({
scope: testScope(),
rtcParticipationManager,
timeline,
options: { sendNotificationType: "ring", callIntent: "video" },
logger,
});
expect(sent$.value).toBeNull();
// Our echo arrives; the roster has only us.
rtcParticipationManager.setMemberships([own]);
rtcParticipationManager.ownMembership$.next(own);
await waitFor("notification sent", () => sent$.value !== null);
const [call] = timeline.calls("sendRoomEvent");
expect(call.eventType).toBe(RTC_NOTIFICATION_EVENT_TYPE);
expect(call.content).toMatchObject({
notification_type: "ring",
"m.call.intent": "video",
"m.relates_to": { event_id: "$my-join", rel_type: "m.reference" },
lifetime: 90_000,
"m.mentions": { user_ids: [], room: true },
});
expect(sent$.value).toMatchObject({
event_id: call.eventId,
notification_type: "ring",
});
// A refresh of our membership is not a join.
rtcParticipationManager.ownMembership$.next({ ...own });
expect(timeline.calls("sendRoomEvent")).toHaveLength(1);
// We leave and come back alone: the room rings again.
rtcParticipationManager.ownMembership$.next(null);
rtcParticipationManager.setMemberships([]);
expect(sent$.value).toBeNull();
rtcParticipationManager.setMemberships([own]);
rtcParticipationManager.ownMembership$.next(own);
await waitFor(
"second notification",
() => timeline.calls("sendRoomEvent").length === 2,
);
});
it("does not ring when somebody was in the session before us", async () => {
const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver();
const sent$ = createParticipationSentCallNotification$({
scope: testScope(),
rtcParticipationManager,
timeline,
options: { sendNotificationType: "ring" },
logger,
});
rtcParticipationManager.setMemberships([peer, own]);
rtcParticipationManager.ownMembership$.next(own);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(timeline.calls("sendRoomEvent")).toEqual([]);
expect(sent$.value).toBeNull();
});
it("does nothing without a notification type", async () => {
const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver();
createParticipationSentCallNotification$({
scope: testScope(),
rtcParticipationManager,
timeline,
options: {},
logger,
});
rtcParticipationManager.setMemberships([own]);
rtcParticipationManager.ownMembership$.next(own);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(timeline.calls("sendRoomEvent")).toEqual([]);
});
});
describe("createParticipationReceivedDecline$", () => {
it("reports declines with who sent them and what they relate to", () => {
const timeline = new MockElementCallMatrixClientDriver();
const declines: DeclineEvent[] = [];
createParticipationReceivedDecline$(timeline).subscribe((d) =>
declines.push(d),
);
timeline.emitTimelineEvent({
eventId: "$other",
type: "m.room.message",
sender: "@peer:x",
content: {},
originServerTs: 1,
});
timeline.emitTimelineEvent({
eventId: "$decline",
type: RTC_DECLINE_EVENT_TYPE,
sender: "@peer:x",
content: {
"m.relates_to": { rel_type: "m.reference", event_id: "$notif" },
},
originServerTs: 2,
});
expect(declines).toEqual([
{
sender: "@peer:x",
relatesTo: { relType: "m.reference", eventId: "$notif" },
},
]);
});
});
@@ -0,0 +1,147 @@
/*
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 {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import {
BehaviorSubject,
Observable,
pairwise,
startWith,
withLatestFrom,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior";
import { type Epoch, type ObservableScope } from "../ObservableScope";
import { type FfiMembership } from "../../matrix-rtc-sdk";
import { type TimelineDriver } from "../../driver/ElementCallMatrixClientDriver";
import {
type CallNotificationWrapper,
type DeclineEvent,
} from "./CallNotificationLifecycle";
/** MSC4075, the unstable spelling every client sends and listens for. */
export const RTC_NOTIFICATION_EVENT_TYPE =
"org.matrix.msc4075.rtc.notification";
/** MSC4310. */
export const RTC_DECLINE_EVENT_TYPE = "org.matrix.msc4310.rtc.decline";
/** How long a ring is offered for, as matrix-js-sdk has it. */
export const NOTIFICATION_LIFETIME_MS = 90_000;
/** What sending the notification needs from a {@link RtcParticipationManager}. */
export interface ParticipationNotificationSource {
ownMembership$: Behavior<FfiMembership | null>;
memberships$: Behavior<Epoch<FfiMembership[]>>;
}
interface Props {
scope: ObservableScope;
rtcParticipationManager: ParticipationNotificationSource;
timeline: TimelineDriver;
options: {
/** Whether and what kind of notification to send when joining the call. */
sendNotificationType?: RTCNotificationType;
/** The kind of call being placed. */
callIntent?: RTCCallIntent;
};
logger: Logger;
}
/**
* Sends the call notification (`m.rtc.notification`) when we start a call:
* once our own membership has echoed back from the homeserver, and only if
* nobody else was in the session before us — whoever is first rings the
* room. Emits what was sent, so ringing can wait for the pickup, decline or
* timeout that relates to it. Resets when we leave, so a later join can
* ring again.
*/
export function createParticipationSentCallNotification$({
scope,
rtcParticipationManager,
timeline,
options: { sendNotificationType, callIntent },
logger: parentLogger,
}: Props): Behavior<CallNotificationWrapper | null> {
const logger = parentLogger.getChild("[CallNotification]");
const sent$ = new BehaviorSubject<CallNotificationWrapper | null>(null);
if (sendNotificationType === undefined) return scope.behavior(sent$);
rtcParticipationManager.ownMembership$
.pipe(
startWith(null),
pairwise(),
withLatestFrom(rtcParticipationManager.memberships$),
scope.bind(),
)
.subscribe(([[previous, own], memberships]) => {
if (own === null) {
// Left (or not in yet): the next join decides afresh.
if (previous !== null) sent$.next(null);
return;
}
if (previous !== null) return; // Already in; a refresh, not a join.
const others = memberships.value.filter(
(m) => m.member.memberId !== own.member.memberId,
);
if (others.length > 0) {
logger.debug(
`Not sending a call notification: ${others.length} member(s) were in the session before us`,
);
return;
}
const eventId = own.member.eventId;
if (eventId === undefined) {
logger.warn(
"Own membership has no event id; cannot send the call notification",
);
return;
}
const content: Record<string, unknown> = {
"m.mentions": { user_ids: [], room: true },
notification_type: sendNotificationType,
"m.relates_to": { event_id: eventId, rel_type: "m.reference" },
sender_ts: Date.now(),
lifetime: NOTIFICATION_LIFETIME_MS,
};
if (callIntent !== undefined) content["m.call.intent"] = callIntent;
timeline.sendRoomEvent(RTC_NOTIFICATION_EVENT_TYPE, content).then(
({ eventId: notificationEventId }) => {
logger.info(`Sent call notification ${notificationEventId}`);
sent$.next({
event_id: notificationEventId,
...(content as Omit<CallNotificationWrapper, "event_id">),
});
},
(e) => logger.error("Failed to send the call notification", e),
);
});
return scope.behavior(sent$);
}
/** Declines (`m.rtc.decline`) arriving in the room, for the ringing outcome. */
export function createParticipationReceivedDecline$(
timeline: TimelineDriver,
): Observable<DeclineEvent> {
return new Observable<DeclineEvent>((subscriber) =>
timeline.subscribeTimeline((event) => {
if (event.type !== RTC_DECLINE_EVENT_TYPE) return;
const relation = event.content["m.relates_to"] as
| { rel_type?: string; event_id?: string }
| undefined;
subscriber.next({
sender: event.sender,
relatesTo: relation
? { relType: relation.rel_type, eventId: relation.event_id }
: undefined,
});
}),
);
}
@@ -0,0 +1,565 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LocalParticipant,
MediaDeviceFailure,
type Participant,
ParticipantEvent,
RoomEvent,
type ScreenShareCaptureOptions,
type TrackPublishOptions,
} from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
from,
fromEvent,
map,
type Observable,
of,
pairwise,
startWith,
switchMap,
tap,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { type Behavior } from "../../Behavior.ts";
import { type ObservableScope } from "../../ObservableScope.ts";
import { type Publisher } from "./Publisher.ts";
import {
type ElementCallError,
FailToStartLivekitConnection,
UnknownCallError,
} from "../../../utils/errors.ts";
import { type HostBridge } from "../../../HostBridge.ts";
import {
advancedScreenShare,
screenShareResolution,
screenShareFramerate,
screenShareBitrate,
screenShareCodec,
parseResolution,
} from "../../../settings/settings.ts";
import { Config } from "../../../config/Config.ts";
import {
ConnectionState,
type Connection,
type FailedToStartError,
} from "../remoteMembers/Connection.ts";
export enum PublishState {
WaitingForUser = "publish_waiting_for_user",
// XXX: This state is removed for now since we do not have full control over
// track publication anymore with the publisher abstraction, might come back in the future?
// /** Implies lk connection is connected */
// Starting = "publish_start_publishing",
/** Implies lk connection is connected */
Publishing = "publish_publishing",
}
// TODO not sure how to map that correctly with the
// new publisher that does not manage tracks itself anymore
export enum TrackState {
/** The track is waiting for user input to create tracks (waiting to call `startTracks()`) */
WaitingForUser = "tracks_waiting_for_user",
// XXX: This state is removed for now since we do not have full control over
// track creation anymore with the publisher abstraction, might come back in the future?
// /** Implies lk connection is connected */
// Creating = "tracks_creating",
/** Implies lk connection is connected */
Ready = "tracks_ready",
}
export type LocalMemberMediaState =
| {
tracks: TrackState;
connection: ConnectionState | FailedToStartError;
}
| PublishState
| ElementCallError;
export interface LocalMediaProps {
scope: ObservableScope;
/** The connection we publish our media on, once there is one. */
localConnection$: Behavior<Connection | null>;
/**
* Whether the transport we publish on is known. Until it is, there is no
* media state to speak of (`mediaState$` is null).
*/
transportReady$: Behavior<boolean>;
/**
* Whether the Matrix side of the call is connected. Upstream media is paused
* while it is not, so that "reconnecting" never means "still transmitting".
*/
matrixConnected$: Observable<boolean>;
createPublisherFactory: (connection: Connection) => Publisher;
/** Whether to hide the screen-sharing button. */
hideScreensharing: boolean;
/** The application hosting Element Call, to be kept informed of join/leave. */
hostBridge: HostBridge;
logger: Logger;
}
export interface LocalMedia {
/**
* This request to start audio and video tracks.
* Can be called early to pre-emptively get media permissions and start devices.
*/
startTracks: () => void;
/**
* This sets a inner state (shouldPublish) to true and instructs the Matrix
* side and livekit to keep the user connected.
*/
requestJoinAndPublish: () => void;
requestDisconnect: () => void;
/** What the user last asked for: to be in the call, or out of it. */
joinAndPublishRequested$: Behavior<boolean>;
participant$: Behavior<LocalParticipant | null>;
/** The state of the connection we publish on; null without one. */
localConnectionState$: Observable<ConnectionState | Error | null>;
/** Null until the transport is known. */
mediaState$: Behavior<LocalMemberMediaState | null>;
/** A non-fatal failure to publish; we can still consume media. */
publishError$: Behavior<ElementCallError | null>;
sharingScreen$: Behavior<boolean>;
/**
* Callback to toggle screen sharing. If null, screen sharing is not possible.
*/
toggleScreenSharing: (() => void) | null;
/**
* The last error from toggling screen sharing, until dismissed.
*/
screenShareError$: Behavior<Error | null>;
dismissScreenShareError: () => void;
}
/**
* The LiveKit half of our own membership, shared by every Matrix side there
* is: creating a publisher on the local connection, starting tracks and
* publishing them when asked, screen sharing, pausing upstream media while
* Matrix is away, and telling the host when the user joins or hangs up.
*/
export function createLocalMedia$({
scope,
localConnection$,
transportReady$,
matrixConnected$,
createPublisherFactory,
hideScreensharing,
hostBridge,
logger,
}: LocalMediaProps): LocalMedia {
// Tracks error that happen when creating the local tracks.
const mediaErrors$ = localConnection$.pipe(
switchMap((connection) => {
if (!connection) {
return of(null);
} else {
return fromEvent(
connection.livekitRoom,
RoomEvent.MediaDevicesError,
(error: Error) => {
return MediaDeviceFailure.getFailure(error) ?? null;
},
);
}
}),
);
mediaErrors$.pipe(scope.bind()).subscribe((error) => {
if (error) {
// This is a MediaDevice error, can be PermissionDenied, NotFound, DeviceInUse, Other.
// Will also occurs if you cancel screen sharing browser prompt.
// This is not necessarily fatal, since the user might be able to join without media.
// XXX We might want to give some user feedback here to let them know their media is not working.
logger.error(`Failed to create local tracks:`, error);
}
});
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const trackStartRequested = Promise.withResolvers<void>();
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const joinAndPublishRequested$ = new BehaviorSubject(false);
/**
* The publisher is stored in here an abstracts creating and publishing tracks.
*/
const publisher$ = new BehaviorSubject<Publisher | null>(null);
const startTracks = (): void => {
trackStartRequested.resolve();
// This used to return the tracks, but now they are only accessible via the publisher.
};
const requestJoinAndPublish = (): void => {
trackStartRequested.resolve();
joinAndPublishRequested$.next(true);
};
const requestDisconnect = (): void => {
joinAndPublishRequested$.next(false);
};
// Take care of the publisher$
// create a new one as soon as a local Connection is available
//
// Recreate a new one once the local connection changes
// - stop publishing
// - destruct all current streams
// - overwrite current publisher
scope.reconcile(localConnection$, async (connection) => {
logger.info(
"reconcile based on new localConnection:",
connection?.transport.livekit_service_url,
);
if (connection !== null) {
const publisher = createPublisherFactory(connection);
publisher$.next(publisher);
// Clean-up callback
return Promise.resolve(async (): Promise<void> => {
await publisher.destroy();
});
}
});
// Use reconcile here to not run concurrent createAndSetupTracks calls
// `tracks$` will update once they are ready.
scope.reconcile(
scope.behavior(
combineLatest([
publisher$ /*, tracks$*/,
from(trackStartRequested.promise),
]),
null,
),
async (valueIfReady) => {
if (!valueIfReady) return;
const [publisher] = valueIfReady;
if (publisher) {
await publisher.createAndSetupTracks().catch((e) => logger.error(e));
}
},
);
// These are non fatal since we can join a room and concume media even though publishing failed.
const publishError$ = new BehaviorSubject<ElementCallError | null>(null);
const setPublishError = (e: ElementCallError): void => {
if (publishError$.value !== null) {
logger.error("Multiple Media Errors:", e);
} else {
publishError$.next(e);
}
};
// Based on `connectRequested$` we start publishing tracks. (once they are there!)
scope.reconcile(
scope.behavior(combineLatest([publisher$, joinAndPublishRequested$])),
async ([publisher, shouldJoinAndPublish]) => {
// Get the current publishing state to avoid redundant calls.
const isPublishing = publisher?.shouldPublish === true;
if (shouldJoinAndPublish && !isPublishing) {
try {
await publisher?.startPublishing();
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
setPublishError(new FailToStartLivekitConnection(message));
}
} else if (isPublishing) {
try {
await publisher?.stopPublishing();
} catch (error) {
setPublishError(new UnknownCallError(error as Error));
}
}
},
);
const localConnectionState$ = localConnection$.pipe(
switchMap((connection) => (connection ? connection.state$ : of(null))),
);
const mediaState$: Behavior<LocalMemberMediaState | null> = scope.behavior(
combineLatest([
localConnectionState$,
transportReady$,
joinAndPublishRequested$,
from(trackStartRequested.promise).pipe(
map(() => true),
startWith(false),
),
]).pipe(
map(
([
localConnectionState,
transportReady,
shouldPublish,
shouldStartTracks,
]) => {
if (!transportReady) return null;
const trackState: TrackState = shouldStartTracks
? TrackState.Ready
: TrackState.WaitingForUser;
if (
localConnectionState !== ConnectionState.LivekitConnected ||
trackState !== TrackState.Ready
)
return {
connection: localConnectionState,
tracks: trackState,
};
if (!shouldPublish) return PublishState.WaitingForUser;
// if (!publishing) return PublishState.Starting;
return PublishState.Publishing;
},
),
distinctUntilChanged(deepCompare),
),
);
// inform the host about the connect and disconnect intent from the user.
scope
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
undefined,
joinAndPublishRequested$.value,
])
.subscribe(([prev, current]) => {
// JOIN prev=false (was left) => current-true (now joiend)
if (!prev && current) {
hostBridge.notifyJoined().catch((e) => {
logger.error("Failed to notify the host that we joined", e);
});
}
// LEAVE prev=false (was joined) => current-true (now left)
if (prev && !current) {
hostBridge.notifyHungUp().catch((e) => {
logger.error("Failed to notify the host that we hung up", e);
});
}
});
const participant$ = scope.behavior(
localConnection$.pipe(
map((c) => c?.livekitRoom?.localParticipant ?? null),
tap((p) => {
logger.debug("participant$ updated:", p?.identity);
}),
),
);
// Pause upstream of all local media tracks when we're disconnected from
// MatrixRTC, because it can be an unpleasant surprise for the app to say
// 'reconnecting' and yet still be transmitting your media to others.
// We use matrixConnected$ rather than reconnecting$ because we want to
// pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen.
// TODO refactor this based no livekitState$
combineLatest([participant$, matrixConnected$])
.pipe(scope.bind())
.subscribe(([participant, connected]) => {
if (!participant) return;
const publications = participant.trackPublications.values();
if (connected) {
for (const p of publications) {
if (p.track?.isUpstreamPaused === true) {
const kind = p.track.kind;
logger.info(
`Resuming ${kind} track (MatrixRTC connection present)`,
);
p.track
.resumeUpstream()
.catch((e) =>
logger.error(
`Failed to resume ${kind} track after MatrixRTC reconnection`,
e,
),
);
}
}
} else {
for (const p of publications) {
if (p.track?.isUpstreamPaused === false) {
const kind = p.track.kind;
logger.info(
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
);
p.track
.pauseUpstream()
.catch((e) =>
logger.error(
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
e,
),
);
}
}
}
});
/**
* Whether the user is currently sharing their screen.
*/
const sharingScreen$ = scope.behavior(
participant$.pipe(
switchMap((p) => (p !== null ? observeSharingScreen$(p) : of(false))),
),
);
const screenShareError$ = new BehaviorSubject<Error | null>(null);
let toggleScreenSharing: (() => void) | null = null;
if (
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
!hideScreensharing
) {
toggleScreenSharing = (): void => {
const screenshareSettings: ScreenShareCaptureOptions = {
// Screen share audio shouldn't have any filtering.
// "echoCancellation" is purposely excluded, as setting it to
// false causes the screen share audio track to include
// an echo of the incoming participant's voice
audio: {
autoGainControl: false,
noiseSuppression: false,
voiceIsolation: false,
},
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
};
let publishOptions: TrackPublishOptions | undefined;
if (advancedScreenShare.getValue()) {
// User has advanced screen share settings enabled
const { width, height } = parseResolution(
screenShareResolution.getValue(),
);
const fps = screenShareFramerate.getValue();
const bps = screenShareBitrate.getValue();
const codec = screenShareCodec.getValue();
screenshareSettings.resolution = {
width,
height,
frameRate: fps,
};
publishOptions = {
screenShareEncoding: {
maxBitrate: bps,
maxFramerate: fps,
},
videoCodec: codec,
};
} else {
// Fall back to config.json settings if available
const screenConf = Config.get().media_quality?.screen_share;
if (screenConf?.max_resolution) {
screenshareSettings.resolution = {
width: Math.round((screenConf.max_resolution * 16) / 9),
height: screenConf.max_resolution,
frameRate: screenConf.max_framerate ?? 30,
};
}
}
const targetScreenshareState = !sharingScreen$.value;
logger.info(
`toggleScreenSharing called. Switching ${
targetScreenshareState ? "On" : "Off"
}`,
);
// If a connection is ready, toggle screen sharing.
// We deliberately do nothing in the case of a null connection because
// it looks nice for the call control buttons to all become available
// at once upon joining the call, rather than introducing a disabled
// state. The user can just click again.
// We also allow screen sharing to be toggled even if the connection
// is still initializing or publishing tracks, because there's no
// technical reason to disallow this. LiveKit will publish if it can.
const participant = participant$.value;
if (!participant) return;
watchScreenShareToggle(
participant.setScreenShareEnabled(
targetScreenshareState,
screenshareSettings,
publishOptions,
),
targetScreenshareState,
logger,
(e) => screenShareError$.next(e),
);
};
}
return {
startTracks,
requestJoinAndPublish,
requestDisconnect,
joinAndPublishRequested$,
participant$,
localConnectionState$,
mediaState$,
publishError$,
sharingScreen$,
toggleScreenSharing,
screenShareError$,
dismissScreenShareError: () => screenShareError$.next(null),
};
}
/**
* Logs the outcome of a screen share toggle and reports failures.
*
* getDisplayMedia may legitimately take a long time (the user is choosing
* what to share) or never settle at all, so nothing is inferred from silence:
* the request and its completion are logged with the elapsed time so that a
* hang is visible in the logs, and only an explicit rejection is reported.
*
* The user cancelling the picker rejects with a NotAllowedError; that is
* logged but not reported.
*/
export function watchScreenShareToggle(
toggle: Promise<unknown>,
enable: boolean,
logger: Logger,
onError: (e: Error) => void,
): void {
const what = `Screen share ${enable ? "start" : "stop"}`;
const started = Date.now();
const elapsed = (): string => `${Date.now() - started} ms`;
logger.info(`${what} requested`);
toggle.then(
() => logger.info(`${what} completed in ${elapsed()}`),
(e: unknown) => {
logger.error(`${what} failed after ${elapsed()}:`, e);
if (e instanceof DOMException && e.name === "NotAllowedError") return;
onError(e instanceof Error ? e : new Error(String(e)));
},
);
}
export function observeSharingScreen$(p: Participant): Observable<boolean> {
return observeParticipantEvents(
p,
ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled));
}
@@ -5,16 +5,7 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type Participant,
ParticipantEvent,
type LocalParticipant,
type ScreenShareCaptureOptions,
type TrackPublishOptions,
RoomEvent,
MediaDeviceFailure,
} from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core";
import { type LocalParticipant } from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk";
import {
Status as RTCSessionStatus,
@@ -30,7 +21,6 @@ import {
combineLatest,
distinctUntilChanged,
from,
fromEvent,
map,
type Observable,
of,
@@ -40,31 +30,22 @@ import {
tap,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
import { type ObservableScope } from "../../ObservableScope.ts";
import { type Publisher } from "./Publisher.ts";
import { createLocalMedia$, type LocalMemberMediaState } from "./LocalMedia.ts";
import { type MuteStates } from "../../MuteStates.ts";
import {
ElementCallError,
FailToStartLivekitConnection,
MembershipManagerError,
UnknownCallError,
} from "../../../utils/errors.ts";
import { type HostBridge } from "../../../HostBridge.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import {
advancedScreenShare,
screenShareResolution,
screenShareFramerate,
screenShareBitrate,
screenShareCodec,
parseResolution,
} from "../../../settings/settings.ts";
import {
MatrixRTCMode,
type ResolvedDelayedLeaveTimings,
@@ -73,7 +54,6 @@ import { Config } from "../../../config/Config.ts";
import {
ConnectionState,
type Connection,
type FailedToStartError,
} from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { type LocalTransport } from "./LocalTransport.ts";
@@ -86,42 +66,31 @@ export enum TransportState {
Waiting = "transport_waiting",
}
export enum PublishState {
WaitingForUser = "publish_waiting_for_user",
// XXX: This state is removed for now since we do not have full control over
// track publication anymore with the publisher abstraction, might come back in the future?
// /** Implies lk connection is connected */
// Starting = "publish_start_publishing",
/** Implies lk connection is connected */
Publishing = "publish_publishing",
export {
PublishState,
TrackState,
type LocalMemberMediaState,
watchScreenShareToggle,
observeSharingScreen$,
} from "./LocalMedia.ts";
/**
* The crate's view of our membership, for `LocalMemberState.matrix` when the
* call runs over a `RtcParticipationManager` (matrix-js-sdk reports its own
* `RTCSessionStatus` there).
*/
export enum MatrixConnectionStatus {
Disconnected = "matrix_disconnected",
Connecting = "matrix_connecting",
Connected = "matrix_connected",
}
// TODO not sure how to map that correctly with the
// new publisher that does not manage tracks itself anymore
export enum TrackState {
/** The track is waiting for user input to create tracks (waiting to call `startTracks()`) */
WaitingForUser = "tracks_waiting_for_user",
// XXX: This state is removed for now since we do not have full control over
// track creation anymore with the publisher abstraction, might come back in the future?
// /** Implies lk connection is connected */
// Creating = "tracks_creating",
/** Implies lk connection is connected */
Ready = "tracks_ready",
}
export type LocalMemberMediaState =
| {
tracks: TrackState;
connection: ConnectionState | FailedToStartError;
}
| PublishState
| ElementCallError;
export type LocalMemberState =
| ElementCallError
| TransportState.Waiting
| {
media: LocalMemberMediaState;
matrix: ElementCallError | RTCSessionStatus;
matrix: ElementCallError | RTCSessionStatus | MatrixConnectionStatus;
};
/*
@@ -191,6 +160,9 @@ interface Props {
* - connectionState: the current connection state. Including matrix server and livekit server connection.
* - sharingScreen$: Whether we are sharing our screen. `undefined` if we cannot share the screen.
*/
/** Our own membership as the view model sees it, whichever Matrix side made it. */
export type LocalMembership = ReturnType<typeof createLocalMembership$>;
export const createLocalMembership$ = ({
scope,
connectionManager,
@@ -357,139 +329,36 @@ export const createLocalMembership$ = ({
),
);
// Tracks error that happen when creating the local tracks.
const mediaErrors$ = localConnection$.pipe(
switchMap((connection) => {
if (!connection) {
return of(null);
} else {
return fromEvent(
connection.livekitRoom,
RoomEvent.MediaDevicesError,
(error: Error) => {
return MediaDeviceFailure.getFailure(error) ?? null;
},
);
}
}),
);
mediaErrors$.pipe(scope.bind()).subscribe((error) => {
if (error) {
// This is a MediaDevice error, can be PermissionDenied, NotFound, DeviceInUse, Other.
// Will also occurs if you cancel screen sharing browser prompt.
// This is not necessarily fatal, since the user might be able to join without media.
// XXX We might want to give some user feedback here to let them know their media is not working.
logger.error(`Failed to create local tracks:`, error);
}
});
// MATRIX RELATED
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const trackStartRequested = Promise.withResolvers<void>();
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const joinAndPublishRequested$ = new BehaviorSubject(false);
/**
* The publisher is stored in here an abstracts creating and publishing tracks.
*/
const publisher$ = new BehaviorSubject<Publisher | null>(null);
const startTracks = (): void => {
trackStartRequested.resolve();
// This used to return the tracks, but now they are only accessible via the publisher.
};
const requestJoinAndPublish = (): void => {
trackStartRequested.resolve();
joinAndPublishRequested$.next(true);
};
const requestDisconnect = (): void => {
joinAndPublishRequested$.next(false);
};
// Take care of the publisher$
// create a new one as soon as a local Connection is available
//
// Recreate a new one once the local connection changes
// - stop publishing
// - destruct all current streams
// - overwrite current publisher
scope.reconcile(localConnection$, async (connection) => {
logger.info(
"reconcile based on new localConnection:",
connection?.transport.livekit_service_url,
);
if (connection !== null) {
const publisher = createPublisherFactory(connection);
publisher$.next(publisher);
// Clean-up callback
return Promise.resolve(async (): Promise<void> => {
await publisher.destroy();
});
}
});
// Use reconcile here to not run concurrent createAndSetupTracks calls
// `tracks$` will update once they are ready.
scope.reconcile(
scope.behavior(
combineLatest([
publisher$ /*, tracks$*/,
from(trackStartRequested.promise),
]),
null,
const {
startTracks,
requestJoinAndPublish,
requestDisconnect,
joinAndPublishRequested$,
participant$,
localConnectionState$,
mediaState$,
publishError$,
sharingScreen$,
toggleScreenSharing,
screenShareError$,
dismissScreenShareError,
} = createLocalMedia$({
scope,
localConnection$,
transportReady$: scope.behavior(
activeTransport$.pipe(map((transport) => transport !== null)),
),
async (valueIfReady) => {
if (!valueIfReady) return;
const [publisher] = valueIfReady;
if (publisher) {
await publisher.createAndSetupTracks().catch((e) => logger.error(e));
}
},
);
// Based on `connectRequested$` we start publishing tracks. (once they are there!)
scope.reconcile(
scope.behavior(combineLatest([publisher$, joinAndPublishRequested$])),
async ([publisher, shouldJoinAndPublish]) => {
// Get the current publishing state to avoid redundant calls.
const isPublishing = publisher?.shouldPublish === true;
if (shouldJoinAndPublish && !isPublishing) {
try {
await publisher?.startPublishing();
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
setPublishError(new FailToStartLivekitConnection(message));
}
} else if (isPublishing) {
try {
await publisher?.stopPublishing();
} catch (error) {
setPublishError(new UnknownCallError(error as Error));
}
}
},
);
matrixConnected$: homeserverConnected.combined$.pipe(
map(([connected]) => connected),
),
createPublisherFactory,
hideScreensharing,
hostBridge,
logger,
});
// STATE COMPUTATION
// These are non fatal since we can join a room and concume media even though publishing failed.
const publishError$ = new BehaviorSubject<ElementCallError | null>(null);
const setPublishError = (e: ElementCallError): void => {
if (publishError$.value !== null) {
logger.error("Multiple Media Errors:", e);
} else {
publishError$.next(e);
}
};
const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>(
null,
);
@@ -502,48 +371,6 @@ export const createLocalMembership$ = ({
}
};
const localConnectionState$ = localConnection$.pipe(
switchMap((connection) => (connection ? connection.state$ : of(null))),
);
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
combineLatest([
localConnectionState$,
activeTransport$,
joinAndPublishRequested$,
from(trackStartRequested.promise).pipe(
map(() => true),
startWith(false),
),
]).pipe(
map(
([
localConnectionState,
localTransport,
shouldPublish,
shouldStartTracks,
]) => {
if (!localTransport) return null;
const trackState: TrackState = shouldStartTracks
? TrackState.Ready
: TrackState.WaitingForUser;
if (
localConnectionState !== ConnectionState.LivekitConnected ||
trackState !== TrackState.Ready
)
return {
connection: localConnectionState,
tracks: trackState,
};
if (!shouldPublish) return PublishState.WaitingForUser;
// if (!publishing) return PublishState.Starting;
return PublishState.Publishing;
},
),
distinctUntilChanged(deepCompare),
),
);
const fatalMatrixError$ = new BehaviorSubject<ElementCallError | null>(null);
const setMatrixError = (e: ElementCallError): void => {
if (fatalMatrixError$.value !== null) {
@@ -650,27 +477,6 @@ export const createLocalMembership$ = ({
}
});
// inform the host about the connect and disconnect intent from the user.
scope
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
undefined,
joinAndPublishRequested$.value,
])
.subscribe(([prev, current]) => {
// JOIN prev=false (was left) => current-true (now joiend)
if (!prev && current) {
hostBridge.notifyJoined().catch((e) => {
logger.error("Failed to notify the host that we joined", e);
});
}
// LEAVE prev=false (was joined) => current-true (now left)
if (prev && !current) {
hostBridge.notifyHungUp().catch((e) => {
logger.error("Failed to notify the host that we hung up", e);
});
}
});
muteStates.video.enabled$.pipe(scope.bind()).subscribe((videoEnabled) => {
void matrixRTCSession
.updateCallIntent(videoEnabled ? "video" : "audio")
@@ -718,15 +524,6 @@ export const createLocalMembership$ = ({
},
);
const participant$ = scope.behavior(
localConnection$.pipe(
map((c) => c?.livekitRoom?.localParticipant ?? null),
tap((p) => {
logger.debug("participant$ updated:", p?.identity);
}),
),
);
// Delegate delayed leaves to the SFU
scope.reconcile(
scope.behavior(combineLatest([joinParams$, delayId$])),
@@ -755,151 +552,6 @@ export const createLocalMembership$ = ({
},
);
// Pause upstream of all local media tracks when we're disconnected from
// MatrixRTC, because it can be an unpleasant surprise for the app to say
// 'reconnecting' and yet still be transmitting your media to others.
// We use matrixConnected$ rather than reconnecting$ because we want to
// pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen.
// TODO refactor this based no livekitState$
combineLatest([participant$, homeserverConnected.combined$])
.pipe(scope.bind())
.subscribe(([participant, [connected]]) => {
if (!participant) return;
const publications = participant.trackPublications.values();
if (connected) {
for (const p of publications) {
if (p.track?.isUpstreamPaused === true) {
const kind = p.track.kind;
logger.info(
`Resuming ${kind} track (MatrixRTC connection present)`,
);
p.track
.resumeUpstream()
.catch((e) =>
logger.error(
`Failed to resume ${kind} track after MatrixRTC reconnection`,
e,
),
);
}
}
} else {
for (const p of publications) {
if (p.track?.isUpstreamPaused === false) {
const kind = p.track.kind;
logger.info(
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
);
p.track
.pauseUpstream()
.catch((e) =>
logger.error(
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
e,
),
);
}
}
}
});
/**
* Whether the user is currently sharing their screen.
*/
const sharingScreen$ = scope.behavior(
participant$.pipe(
switchMap((p) => (p !== null ? observeSharingScreen$(p) : of(false))),
),
);
const screenShareError$ = new BehaviorSubject<Error | null>(null);
let toggleScreenSharing: (() => void) | null = null;
if (
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
!hideScreensharing
) {
toggleScreenSharing = (): void => {
const screenshareSettings: ScreenShareCaptureOptions = {
// Screen share audio shouldn't have any filtering.
// "echoCancellation" is purposely excluded, as setting it to
// false causes the screen share audio track to include
// an echo of the incoming participant's voice
audio: {
autoGainControl: false,
noiseSuppression: false,
voiceIsolation: false,
},
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
};
let publishOptions: TrackPublishOptions | undefined;
if (advancedScreenShare.getValue()) {
// User has advanced screen share settings enabled
const { width, height } = parseResolution(
screenShareResolution.getValue(),
);
const fps = screenShareFramerate.getValue();
const bps = screenShareBitrate.getValue();
const codec = screenShareCodec.getValue();
screenshareSettings.resolution = {
width,
height,
frameRate: fps,
};
publishOptions = {
screenShareEncoding: {
maxBitrate: bps,
maxFramerate: fps,
},
videoCodec: codec,
};
} else {
// Fall back to config.json settings if available
const screenConf = Config.get().media_quality?.screen_share;
if (screenConf?.max_resolution) {
screenshareSettings.resolution = {
width: Math.round((screenConf.max_resolution * 16) / 9),
height: screenConf.max_resolution,
frameRate: screenConf.max_framerate ?? 30,
};
}
}
const targetScreenshareState = !sharingScreen$.value;
logger.info(
`toggleScreenSharing called. Switching ${
targetScreenshareState ? "On" : "Off"
}`,
);
// If a connection is ready, toggle screen sharing.
// We deliberately do nothing in the case of a null connection because
// it looks nice for the call control buttons to all become available
// at once upon joining the call, rather than introducing a disabled
// state. The user can just click again.
// We also allow screen sharing to be toggled even if the connection
// is still initializing or publishing tracks, because there's no
// technical reason to disallow this. LiveKit will publish if it can.
const participant = participant$.value;
if (!participant) return;
watchScreenShareToggle(
participant.setScreenShareEnabled(
targetScreenshareState,
screenshareSettings,
publishOptions,
),
targetScreenshareState,
logger,
(e) => screenShareError$.next(e),
);
};
}
return {
startTracks,
requestJoinAndPublish,
@@ -916,53 +568,12 @@ export const createLocalMembership$ = ({
sharingScreen$,
toggleScreenSharing,
screenShareError$,
dismissScreenShareError: () => screenShareError$.next(null),
dismissScreenShareError,
connection$: localConnection$,
internalLoggerRef: logger,
};
};
/**
* Logs the outcome of a screen share toggle and reports failures.
*
* getDisplayMedia may legitimately take a long time (the user is choosing
* what to share) or never settle at all, so nothing is inferred from silence:
* the request and its completion are logged with the elapsed time so that a
* hang is visible in the logs, and only an explicit rejection is reported.
*
* The user cancelling the picker rejects with a NotAllowedError; that is
* logged but not reported.
*/
export function watchScreenShareToggle(
toggle: Promise<unknown>,
enable: boolean,
logger: Logger,
onError: (e: Error) => void,
): void {
const what = `Screen share ${enable ? "start" : "stop"}`;
const started = Date.now();
const elapsed = (): string => `${Date.now() - started} ms`;
logger.info(`${what} requested`);
toggle.then(
() => logger.info(`${what} completed in ${elapsed()}`),
(e: unknown) => {
logger.error(`${what} failed after ${elapsed()}:`, e);
if (e instanceof DOMException && e.name === "NotAllowedError") return;
onError(e instanceof Error ? e : new Error(String(e)));
},
);
}
export function observeSharingScreen$(p: Participant): Observable<boolean> {
return observeParticipantEvents(
p,
ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled));
}
interface EnterRTCSessionOptions {
encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode;
@@ -0,0 +1,431 @@
/*
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 {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
type Observable,
pairwise,
tap,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior.ts";
import { type ObservableScope } from "../../ObservableScope.ts";
import { type MuteStates } from "../../MuteStates.ts";
import { type HostBridge } from "../../../HostBridge.ts";
import {
ElementCallError,
MembershipManagerError,
} from "../../../utils/errors.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import {
FfiImpairment,
FfiKeepAlive,
FfiStatus,
type FfiConnectionWithMembers,
type FfiJoinParams,
type FfiStatus as FfiStatusType,
type FfiTransportIntent,
} from "../../../matrix-rtc-sdk";
import { type SlotPolicy } from "../../rtc/RtcParticipationManager.ts";
import { type DisconnectContext, errorForStatus } from "../../rtc/errors.ts";
import { publishOnLivekit } from "../../rtc/transportIntent.ts";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
import {
ConnectionState,
type Connection,
} from "../remoteMembers/Connection.ts";
import { type Publisher } from "./Publisher.ts";
import { createLocalMedia$ } from "./LocalMedia.ts";
import {
type LocalMembership,
type LocalMemberState,
MatrixConnectionStatus,
TransportState,
} from "./LocalMember.ts";
import { type HomeserverDisconnectReason } from "./HomeserverConnected.ts";
/** What our own membership needs from a {@link RtcParticipationManager}. */
export interface ParticipationLocalMemberSource {
status$: Behavior<FfiStatusType>;
connections$: Behavior<FfiConnectionWithMembers[]>;
ownMemberId$: Behavior<string | null>;
join(
intent: FfiTransportIntent,
params: FfiJoinParams,
slot: SlotPolicy,
): Promise<void>;
leave(): Promise<void>;
updateApplication(intent: string | undefined): Promise<void>;
}
interface Props {
scope: ObservableScope;
rtcParticipationManager: ParticipationLocalMemberSource;
connectionManager: IConnectionManager;
createPublisherFactory: (connection: Connection) => Publisher;
muteStates: MuteStates;
/** Whether to hide the screen-sharing button. */
hideScreensharing: boolean;
/** The application hosting Element Call, to be kept informed of join/leave. */
hostBridge: HostBridge;
/** How to join, from the configuration. */
joinParams: FfiJoinParams;
/** Whether the room's slot may be opened by us, and how. */
slotPolicy$: Behavior<SlotPolicy>;
/**
* A developer's own LiveKit service URL to publish on instead of whatever
* the homeserver advertises; null or empty for none.
*/
customLivekitUrl$: Observable<string | null | undefined>;
/** For turning a failed participation into the error the UI shows. */
disconnectContext: () => DisconnectContext;
/** The room, as the call identifier in analytics events. */
roomId: string;
logger: Logger;
}
/**
* The Matrix side of the call as the crate sees it, reduced to what the local
* member state needs: connected or not, and if not, why.
*/
interface MatrixConnection {
connected: boolean;
reason: HomeserverDisconnectReason | null;
status: MatrixConnectionStatus;
}
function describeStatus(status: FfiStatusType): MatrixConnection {
if (FfiStatus.Disconnected.instanceOf(status))
return {
connected: false,
reason: "membership",
status: MatrixConnectionStatus.Disconnected,
};
if (!FfiStatus.Connected.instanceOf(status))
return {
connected: false,
reason: "membership",
status: MatrixConnectionStatus.Connecting,
};
const { impairments, keepAlive } = status.inner;
if (
impairments.some((i) => FfiImpairment.HomeserverUnreachable.instanceOf(i))
)
return {
connected: false,
reason: "sync",
status: MatrixConnectionStatus.Connected,
};
// A keep-alive that cannot be restarted may already have fired: the
// homeserver may think we left.
if (
FfiKeepAlive.RestartFailing.instanceOf(keepAlive) ||
FfiKeepAlive.Expired.instanceOf(keepAlive)
)
return {
connected: false,
reason: "probablyLeft",
status: MatrixConnectionStatus.Connected,
};
return {
connected: true,
reason: null,
status: MatrixConnectionStatus.Connected,
};
}
/**
* Our own membership over a {@link RtcParticipationManager}: the crate publishes
* and keeps alive the membership, discovers the transport and mints its
* token; this joins and leaves when the user asks, publishes our media on
* the connection the crate gave us, and projects the crate's status onto the
* connected / reconnecting / error states the call UI shows.
*/
export const createParticipationLocalMembership$ = ({
scope,
rtcParticipationManager,
connectionManager,
createPublisherFactory,
muteStates,
hideScreensharing,
hostBridge,
joinParams,
slotPolicy$,
customLivekitUrl$,
disconnectContext,
roomId,
logger: parentLogger,
}: Props): LocalMembership => {
const logger = parentLogger.getChild("[ParticipationLocalMembership]");
logger.debug(`Creating local membership..`);
// The connection we publish on: the one the crate lists our own member on.
const ownServiceUrl$ = scope.behavior(
combineLatest([
rtcParticipationManager.connections$,
rtcParticipationManager.ownMemberId$,
]).pipe(
map(
([connections, ownMemberId]) =>
connections.find((c) =>
c.members.some((m) => m.memberId === ownMemberId),
)?.connection.serviceUrl ?? null,
),
distinctUntilChanged(),
),
);
const localConnection$ = scope.behavior(
combineLatest([
connectionManager.connectionManagerData$,
ownServiceUrl$,
]).pipe(
map(([{ value: connectionData }, serviceUrl]) =>
serviceUrl === null
? null
: connectionData.getConnectionForTransport({
type: "livekit",
livekit_service_url: serviceUrl,
}),
),
tap((connection) => {
logger.info(
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
);
}),
),
);
const matrixConnection$ = scope.behavior(
rtcParticipationManager.status$.pipe(
map(describeStatus),
distinctUntilChanged(
(a, b) =>
a.connected === b.connected &&
a.reason === b.reason &&
a.status === b.status,
),
),
);
const {
startTracks,
requestJoinAndPublish,
requestDisconnect,
joinAndPublishRequested$,
participant$,
localConnectionState$,
mediaState$,
publishError$,
sharingScreen$,
toggleScreenSharing,
screenShareError$,
dismissScreenShareError,
} = createLocalMedia$({
scope,
localConnection$,
transportReady$: scope.behavior(
ownServiceUrl$.pipe(map((url) => url !== null)),
),
matrixConnected$: matrixConnection$.pipe(map((c) => c.connected)),
createPublisherFactory,
hideScreensharing,
hostBridge,
logger,
});
// MATRIX RELATED
const fatalMatrixError$ = new BehaviorSubject<ElementCallError | null>(null);
const setMatrixError = (e: ElementCallError): void => {
if (fatalMatrixError$.value !== null) {
logger.error("Multiple Matrix Errors:", e);
} else {
fatalMatrixError$.next(e);
}
};
// Join and leave as the user asks. The crate does the rest: it opens the
// slot when we may, discovers the transport, publishes and keeps the
// membership alive, and delegates the delayed leave.
scope.reconcile(
scope.behavior(
combineLatest([joinAndPublishRequested$, customLivekitUrl$]),
),
async ([shouldConnect, customLivekitUrl]) => {
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration.
if (!shouldConnect) return;
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(roomId);
try {
await rtcParticipationManager.join(
publishOnLivekit(customLivekitUrl || undefined),
joinParams,
slotPolicy$.value,
);
} catch (error) {
logger.error("Error joining the session", error);
setMatrixError(
error instanceof ElementCallError
? error
: (errorForStatus(
rtcParticipationManager.status$.value,
disconnectContext(),
) ??
new MembershipManagerError(
error instanceof Error ? error : new Error(String(error)),
)),
);
}
return Promise.resolve(async (): Promise<void> => {
try {
await rtcParticipationManager.leave();
} catch (e) {
logger.error("Error leaving the session", e);
}
});
},
);
// The crate can end the participation on its own (the slot closed, the
// manager stopped): while the user still wants to be in the call, that is
// an error to show.
combineLatest([rtcParticipationManager.status$, joinAndPublishRequested$])
.pipe(scope.bind())
.subscribe(([status, shouldConnect]) => {
if (!shouldConnect) return;
const error = errorForStatus(status, disconnectContext());
if (error !== null && fatalMatrixError$.value === null) {
logger.warn("The participation ended on its own", error);
setMatrixError(error);
}
});
const localMemberState$ = scope.behavior<LocalMemberState>(
combineLatest([
mediaState$,
matrixConnection$,
fatalMatrixError$,
publishError$,
]).pipe(
map(([mediaState, matrixConnection, fatalMatrixError, publishError]) => {
// `mediaState` will be 'null' until the transport/connection appears.
if (mediaState)
return {
matrix: fatalMatrixError ?? matrixConnection.status,
media: publishError ?? mediaState,
};
// A join that failed before any transport was known is still fatal.
if (fatalMatrixError) return fatalMatrixError;
return TransportState.Waiting;
}),
),
);
/**
* The disconnect reason for the combined Matrix + LiveKit connection, or null
* when fully connected. Homeserver reasons take priority over livekit.
*/
const connectionDisconnectReason$ = scope.behavior(
combineLatest([
matrixConnection$,
localConnectionState$.pipe(
map((state) => state === ConnectionState.LivekitConnected),
),
]).pipe(
map(([matrix, livekitConnected]) => {
if (!matrix.connected) return matrix.reason!;
if (!livekitConnected) return "livekit" as const;
return null;
}),
tap((v) => logger.debug("livekit+matrix: Connected state changed", v)),
),
);
/**
* Whether we are "fully" connected to the call. Accounts for both the
* connection to the MatrixRTC session and the LiveKit publish connection.
*/
const matrixAndLivekitConnected$ = scope.behavior(
connectionDisconnectReason$.pipe(map((reason) => reason === null)),
);
/**
* Whether we should tell the user that we're reconnecting to the call.
*/
const reconnecting$ = scope.behavior(
matrixAndLivekitConnected$.pipe(
pairwise(),
map(([prev, current]) => prev === true && current === false),
),
false,
);
let reconnectStart: {
time: number;
reason: NonNullable<(typeof connectionDisconnectReason$)["value"]>;
} | null = null;
connectionDisconnectReason$
.pipe(distinctUntilChanged(), pairwise(), scope.bind())
.subscribe(([prev, reason]) => {
if (reason !== null) {
// Only begin tracking when transitioning FROM connected (null → non-null).
if (prev === null) {
reconnectStart ??= { time: Date.now(), reason };
}
} else if (reconnectStart !== null) {
PosthogAnalytics.instance.eventCallReconnecting.track(
roomId,
reconnectStart.reason,
(Date.now() - reconnectStart.time) / 1000,
);
PosthogAnalytics.instance.eventCallEnded.cacheReconnecting(
reconnectStart.reason,
);
reconnectStart = null;
}
});
// The call intent follows the camera (C11). Before the join the crate
// refuses, which is expected.
muteStates.video.enabled$.pipe(scope.bind()).subscribe((videoEnabled) => {
rtcParticipationManager
.updateApplication(videoEnabled ? "video" : "audio")
.catch((e) => {
logger.debug(
"Could not update the call intent (expected before the join)",
e,
);
});
});
return {
startTracks,
requestJoinAndPublish,
requestDisconnect,
localMemberState$,
participant$,
reconnecting$,
connected$: matrixAndLivekitConnected$,
disconnected$: scope.behavior(
matrixConnection$.pipe(
map((c) => c.status === MatrixConnectionStatus.Disconnected),
),
),
sharingScreen$,
toggleScreenSharing,
screenShareError$,
dismissScreenShareError,
connection$: localConnection$,
internalLoggerRef: logger,
};
};
@@ -53,8 +53,12 @@ export interface ConnectionOpts {
ownMembershipIdentity: CallMembershipIdentityParts;
/** The media transport to connect to. */
transport: LivekitTransportConfig;
/** The Matrix client to use for OpenID and SFU config requests. */
client: OpenIDClientParts;
/**
* The Matrix client to use for OpenID and SFU config requests. `null` when
* every connection comes with its token already (the crate mints them), in
* which case a connection without `existingSFUConfig` cannot start.
*/
client: OpenIDClientParts | null;
/** The room ID this connection is associated with. */
roomId: string;
/** The observable scope to use for this connection. */
@@ -137,7 +141,7 @@ export class Connection {
protected stopped = false;
// TODO: can we just keep the ConnectionOpts object instead of spreading?
private readonly client: OpenIDClientParts;
private readonly client: OpenIDClientParts | null;
private readonly roomId: string;
private readonly logger: Logger;
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
@@ -398,6 +402,10 @@ export class Connection {
protected async getSFUConfigForRemoteConnection(): Promise<SFUConfig> {
// This will only be called for sfu's where we do not publish ourselves.
// For the local connection we will use the existingJwtTokenData
if (this.client === null)
throw new FailedToStartError(
"No token for this connection and no client to fetch one with",
);
return await getSFUConfigWithOpenID(
this.client,
this.ownMembershipIdentity,
@@ -57,7 +57,7 @@ export class ECConnectionFactory implements ConnectionFactory {
/**
* Creates a ConnectionFactory for LiveKit connections.
*
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens. `null` when every connection is created with its token (the crate mints them).
* @param roomId - The current room ID.
* @param devices - Used for video/audio out/in capture options.
* @param processorState$ - Effects like background blur (only for publishing connection?)
@@ -66,7 +66,7 @@ export class ECConnectionFactory implements ConnectionFactory {
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
*/
public constructor(
private client: OpenIDClientParts,
private client: OpenIDClientParts | null,
private readonly roomId: string,
private devices: MediaDevices,
private processorState$: Behavior<ProcessorState>,
@@ -31,8 +31,26 @@ export type TaggedParticipant =
| LocalTaggedParticipant
| RemoteTaggedParticipant;
/**
* What a tile needs to know about a call member, whichever MatrixRTC
* implementation produced it: matrix-js-sdk's `CallMembership` has this shape,
* and the crate's `FfiMembership` is projected onto it.
*/
export interface CallMember {
userId: string;
/**
* The member's device. The crate does not always know it (an unencrypted
* room, a pre-sticky event); the projection then falls back to the member
* id, which keeps the `${userId}:${deviceId}` media ids unique.
*/
deviceId: string;
memberId: string;
/** The LiveKit participant identity, once known. */
rtcBackendIdentity: string | undefined;
}
export interface MatrixLivekitMember {
membership$: Behavior<CallMembership>;
membership$: Behavior<CallMember>;
connection$: Behavior<Connection | null>;
// participantId: string; We do not want a participantId here since it will be generated by the jwt
// TODO decide if we can also drop the userId. Its in the matrix membership anyways.
@@ -109,6 +109,11 @@ export const memberDisplaynames$ = (
);
};
/** Per-member display names and avatars, disambiguated over the call. */
export type MatrixMemberMetadata = ReturnType<
typeof createMatrixMemberMetadata$
>;
export const createMatrixMemberMetadata$ = (
scope: ObservableScope,
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
@@ -0,0 +1,123 @@
/*
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 { logger } from "matrix-js-sdk/lib/logger";
import {
MockConnection,
mockLivekitRoom,
mockLocalParticipant,
testScope,
} from "../../../utils/test";
import {
FakeParticipation,
fakeConnection,
} from "../../../utils/test-participation";
import { type ConnectionFactory } from "./ConnectionFactory";
import { createParticipationConnectionManager$ } from "./ParticipationConnections";
interface Created {
serviceUrl: string;
jwt: string | undefined;
url: string | undefined;
}
function recordingFactory(): {
factory: ConnectionFactory;
created: Created[];
} {
const created: Created[] = [];
const factory: ConnectionFactory = {
createConnection(scope, transport, ownMembershipIdentity, logger, sfu) {
created.push({
serviceUrl: transport.livekit_service_url,
jwt: sfu?.jwt,
url: sfu?.url,
});
return new MockConnection(
{
scope,
transport,
ownMembershipIdentity,
existingSFUConfig: sfu,
client: null,
roomId: "!room:example.org",
livekitRoomFactory: () =>
mockLivekitRoom({
localParticipant: mockLocalParticipant({ identity: "" }),
remoteParticipants: new Map(),
}),
},
logger,
);
},
};
return { factory, created };
}
describe("createParticipationConnectionManager$", () => {
it("opens one connection per service with the crate's token and keeps it across a refresh", () => {
const scope = testScope();
const rtcParticipationManager = new FakeParticipation();
const { factory, created } = recordingFactory();
const manager = createParticipationConnectionManager$({
scope,
rtcParticipationManager,
connectionFactory: factory,
ownIdentity: { userId: "@me:example.org", deviceId: "MYDEV" },
logger,
});
expect(manager.connectionManagerData$.value.value.getConnections()).toEqual(
[],
);
rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://a", jwtToken: "t1" }),
]);
expect(created).toEqual([
{ serviceUrl: "https://a", jwt: "t1", url: "wss://a" },
]);
const data = manager.connectionManagerData$.value.value;
expect(data.getConnections()).toHaveLength(1);
expect(
data.getConnectionForTransport({
type: "livekit",
livekit_service_url: "https://a",
})?.transport.livekit_service_url,
).toBe("https://a");
// The crate refreshed the token: the same connection stays up.
rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }),
]);
expect(created).toHaveLength(1);
// A second service appears; the first is untouched.
rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }),
fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }),
]);
expect(created.map((c) => c.serviceUrl)).toEqual([
"https://a",
"https://b",
]);
expect(
manager.connectionManagerData$.value.value.getConnections(),
).toHaveLength(2);
// Everybody left the first service: its connection goes away.
rtcParticipationManager.connections$.next([
fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }),
]);
expect(
manager.connectionManagerData$.value.value
.getConnections()
.map((c) => c.transport.livekit_service_url),
).toEqual(["https://b"]);
});
});
@@ -0,0 +1,124 @@
/*
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, map, of, skip, switchMap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior";
import { Epoch, type ObservableScope, trackEpoch } from "../../ObservableScope";
import { generateItemsWithEpoch } from "../../../utils/observable";
import { type FfiConnectionWithMembers } from "../../../matrix-rtc-sdk";
import { type ConnectionFactory } from "./ConnectionFactory";
import {
ConnectionManagerData,
type IConnectionManager,
} from "./ConnectionManager";
/** What this module needs from a {@link RtcParticipationManager}. */
export interface ParticipationConnectionsSource {
/** The LiveKit rooms to hold, with a token for each, keyed by service URL. */
connections$: Behavior<FfiConnectionWithMembers[]>;
}
interface Props {
scope: ObservableScope;
rtcParticipationManager: ParticipationConnectionsSource;
connectionFactory: ConnectionFactory;
/** Who we publish as. Connections only log it; the tokens come minted. */
ownIdentity: { userId: string; deviceId: string };
logger: Logger;
}
/**
* One LiveKit connection per transport the crate says the session uses — ours
* and everybody else's — each started with the token the crate minted for
* it. The crate discovers the transports, mints and refreshes the tokens;
* this only turns its list into live `Connection`s, keyed by service URL so a
* refreshed token does not tear a connection down.
*/
export function createParticipationConnectionManager$({
scope,
rtcParticipationManager,
connectionFactory,
ownIdentity,
logger: parentLogger,
}: Props): IConnectionManager {
const logger = parentLogger.getChild("[ParticipationConnections]");
const connections$ = scope.behavior(
rtcParticipationManager.connections$.pipe(
trackEpoch(),
generateItemsWithEpoch(
"ParticipationConnections connections$",
function* (connections) {
for (const { connection } of connections) {
yield {
keys: [connection.serviceUrl] as const,
data: { wsUrl: connection.wsUrl, jwt: connection.jwtToken },
};
}
},
(scope, token$, serviceUrl) => {
const { wsUrl, jwt } = token$.value;
const connection = connectionFactory.createConnection(
scope,
{ type: "livekit", livekit_service_url: serviceUrl },
{ ...ownIdentity, memberId: "" },
logger,
// The crate minted this; nobody asks the authorisation service
// again. The alias and identity are in the token itself.
{ url: wsUrl, jwt, livekitAlias: "", livekitIdentity: "" },
);
// A token the crate refreshed while we are connected is used on
// the next full (re)connect; livekit-client keeps the session on
// the token it connected with.
// TODO: hand the new token to livekit-client when it can take one.
token$.pipe(skip(1), scope.bind()).subscribe(() => {
logger.info(
`New token for ${serviceUrl}; it is used on the next connect`,
);
});
// Start the connection immediately; its state$ tracks progress.
void connection.start();
return connection;
},
),
),
new Epoch([], -1),
);
const connectionManagerData$ = scope.behavior(
connections$.pipe(
switchMap((connections) => {
const epoch = connections.epoch;
if (connections.value.length === 0)
return of(new Epoch(new ConnectionManagerData(), epoch));
return combineLatest(
connections.value.map((connection) =>
connection.remoteParticipants$.pipe(
map((participants) => ({ connection, participants })),
),
),
).pipe(
map(
(lists) =>
new Epoch(
lists.reduce((data, { connection, participants }) => {
data.add(connection, participants);
return data;
}, new ConnectionManagerData(logger)),
epoch,
),
),
);
}),
),
new Epoch(new ConnectionManagerData(), -1),
);
return { connectionManagerData$ };
}
@@ -0,0 +1,47 @@
/*
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 { map } from "rxjs";
import { type Behavior } from "../../Behavior";
import { type ObservableScope } from "../../ObservableScope";
import { observeDriver } from "../../../driver/observe";
import {
type RoomDriver,
type RoomMemberProfile,
} from "../../../driver/ElementCallMatrixClientDriver";
import { type RoomMemberMap } from "./MatrixMemberMetadata";
/** The room's roster as the display-name and ringing code reads it. */
export function roomMemberMapOf(profiles: RoomMemberProfile[]): RoomMemberMap {
return profiles.reduce((acc, profile) => {
acc.set(profile.userId, {
userId: profile.userId,
rawDisplayName: profile.displayName ?? profile.userId,
getMxcAvatarUrl: () => profile.avatarUrl ?? undefined,
});
return acc;
}, new Map() as RoomMemberMap);
}
/**
* The room's joined and invited members, from the client driver. Call
* members' names come from the crate; this is for the people who are in the
* room but not (yet) in the call, and for disambiguating names.
*/
export function createParticipationRoomMembers$(
scope: ObservableScope,
room: RoomDriver,
): Behavior<RoomMemberMap> {
return scope.behavior(
observeDriver(
scope,
() => room.getRoomMembers(),
(listener) => room.subscribeRoomMembers(listener),
).pipe(map(roomMemberMapOf)),
);
}

Some files were not shown because too many files have changed in this diff Show More