mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d38116d2f0 | ||
|
|
eb8db733a6 | ||
|
|
0f4370749a | ||
|
|
48db16a08a | ||
|
|
14b595345f | ||
|
|
7132e6e6ec | ||
|
|
e791133e74 | ||
|
|
edfdc23aef | ||
|
|
edf2f39eec | ||
|
|
e4049e3d69 | ||
|
|
8506c098e9 | ||
|
|
36ed60d95c |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -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 })`.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+20
@@ -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': {}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -29,7 +29,6 @@ const TestComponent: FC<
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
state: "valid",
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
reactions: true,
|
||||
},
|
||||
|
||||
+28
-9
@@ -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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 +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]);
|
||||
|
||||
@@ -64,7 +64,7 @@ export const KnockLobbyView: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<LobbyView
|
||||
client={client}
|
||||
developerSettingsClient={client}
|
||||
matrixInfo={{
|
||||
userId: client.getUserId() ?? "",
|
||||
displayName: profile.displayName,
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(), []),
|
||||
);
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user