mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-19 23:52:26 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4441bb5e8c | ||
|
|
f2f49315eb | ||
|
|
99256fa01c | ||
|
|
12352b94f5 | ||
|
|
e393b6a984 | ||
|
|
3f63a6fc3c | ||
|
|
addc1b10a0 | ||
|
|
ee0d7dc0cb | ||
|
|
7b1f3f28ed | ||
|
|
8c44ecd61b | ||
|
|
7d92ddf0c9 | ||
|
|
b7bd7d18b2 | ||
|
|
b4b72432b9 | ||
|
|
ac1f5e2c8c | ||
|
|
98d77cc816 | ||
|
|
d5812ddd34 | ||
|
|
cf7c275fb7 | ||
|
|
93b2661513 | ||
|
|
1f84cb9c5c | ||
|
|
4269aaa163 | ||
|
|
bc62bb9565 | ||
|
|
be56bbd05b | ||
|
|
eb44fd545d | ||
|
|
c9f911b7c9 | ||
|
|
3a189d155b | ||
|
|
0211d5c367 | ||
|
|
c88f884fc9 | ||
|
|
9f7c35cd8f | ||
|
|
ac81a7c0ab | ||
|
|
03f81bcd64 | ||
|
|
9f6c8bb434 | ||
|
|
f56c74adf9 | ||
|
|
3c88d217f2 | ||
|
|
967a06ba39 | ||
|
|
71593f1a24 | ||
|
|
5d38a13c46 | ||
|
|
426acd222e |
@@ -14,13 +14,17 @@ on:
|
||||
description: The build mode for vite. Must be either 'development' or 'production'
|
||||
required: false
|
||||
default: production
|
||||
# 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.
|
||||
secrets:
|
||||
SENTRY_ORG:
|
||||
required: true
|
||||
SENTRY_PROJECT:
|
||||
required: true
|
||||
SENTRY_URL:
|
||||
required: true
|
||||
SENTRY_AUTH_TOKEN:
|
||||
required: true
|
||||
CODECOV_TOKEN:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -38,14 +42,6 @@ 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,7 +15,12 @@ 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: inherit
|
||||
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 }}
|
||||
deploy_develop:
|
||||
# Deploy main branch to call.element.dev after build completes
|
||||
if: github.ref == 'refs/heads/main'
|
||||
@@ -60,7 +65,12 @@ 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: inherit
|
||||
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 }}
|
||||
|
||||
build_sdk_element_call:
|
||||
# Use the embedded package vite build
|
||||
@@ -69,7 +79,12 @@ 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: inherit
|
||||
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 }}
|
||||
|
||||
build_storybook:
|
||||
name: Build Storybook
|
||||
@@ -87,14 +102,6 @@ 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,14 +17,6 @@ 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,7 +66,11 @@ jobs:
|
||||
with:
|
||||
vite_app_version: embedded-${{ needs.versioning.outputs.PREFIXED_VERSION }}
|
||||
package: embedded
|
||||
secrets: inherit
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
publish_tarball:
|
||||
needs: [build_element_call, versioning]
|
||||
|
||||
@@ -13,7 +13,11 @@ jobs:
|
||||
with:
|
||||
package: full
|
||||
vite_app_version: ${{ github.event.release.tag_name }} # Using ${{ env.VERSION }} here doesn't work
|
||||
secrets: inherit
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
publish_tarball:
|
||||
needs: build_element_call
|
||||
|
||||
@@ -19,14 +19,6 @@ 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"
|
||||
@@ -52,17 +44,9 @@ jobs:
|
||||
flags: unittests
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests (${{ matrix.call_implementation }})
|
||||
name: Run end-to-end tests
|
||||
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:
|
||||
@@ -73,14 +57,6 @@ 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
|
||||
@@ -104,12 +80,11 @@ 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-${{ matrix.call_implementation }}
|
||||
name: html-report
|
||||
path: playwright-report
|
||||
if-no-files-found: error
|
||||
retention-days: 4
|
||||
|
||||
@@ -25,14 +25,6 @@ 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"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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,7 +10,6 @@ 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";
|
||||
@@ -36,10 +35,6 @@ 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",
|
||||
|
||||
@@ -48,6 +48,8 @@ pnpm i18n:check
|
||||
- View model + marble tests, thin view, a story per state, an e2e spec.
|
||||
- Any new shared component called out explicitly, with why nothing existing fit.
|
||||
- Template filled for real: what, why, before/after screenshots, repro steps.
|
||||
- Comments only where the code can't give the reason — see
|
||||
[code-style.md](docs/agents/code-style.md#comments).
|
||||
- Anything that redraws continuously — an animation frame, a stream — says what it
|
||||
costs, or better, counts the redraws in a test.
|
||||
- Branch `<handle>/<topic>`. Plain imperative commit subjects, no prefixes.
|
||||
|
||||
@@ -206,19 +206,6 @@ 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
|
||||
@@ -233,7 +220,6 @@ 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)
|
||||
|
||||
@@ -312,9 +298,7 @@ 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
|
||||
(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
|
||||
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 {
|
||||
ElementCallClientBased,
|
||||
ElementCall,
|
||||
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 && (
|
||||
<ElementCallClientBased
|
||||
<ElementCall
|
||||
ref={handle}
|
||||
client={session.client}
|
||||
roomId={roomId}
|
||||
|
||||
+24
-115
@@ -43,7 +43,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||
import { type MatrixClient } 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,6 +56,7 @@ 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 {
|
||||
@@ -77,17 +78,6 @@ 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,
|
||||
@@ -99,20 +89,6 @@ 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 {
|
||||
@@ -148,25 +124,12 @@ export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||
|
||||
export interface ElementCallProps {
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
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
|
||||
@@ -213,48 +176,16 @@ 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())
|
||||
@@ -299,9 +230,8 @@ const Decoration: FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
};
|
||||
|
||||
export const ElementCall: FC<ElementCallProps> = ({
|
||||
rtcDriver,
|
||||
clientDriver,
|
||||
roomId: suppliedRoomId,
|
||||
client,
|
||||
roomId,
|
||||
intent = UserIntent.JoinExistingCall,
|
||||
config,
|
||||
hostBridge: suppliedHostBridge,
|
||||
@@ -310,11 +240,6 @@ 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)
|
||||
@@ -363,19 +288,24 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
};
|
||||
}, [controlledAudioDevices, callIntent]);
|
||||
|
||||
// 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],
|
||||
const room = client.getRoom(roomId);
|
||||
const rtcSession = useMemo(
|
||||
() => (room === null ? null : client.matrixRTC.getRoomSession(room)),
|
||||
[client, room],
|
||||
);
|
||||
|
||||
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 && mediaDevices !== null;
|
||||
const ready =
|
||||
container !== null && rtcSession !== null && mediaDevices !== null;
|
||||
const announcedLoaded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ready || announcedLoaded.current) return;
|
||||
@@ -401,10 +331,12 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<MatrixDriverProvider value={drivers}>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={params.confineToRoom}
|
||||
preload={params.preload}
|
||||
@@ -412,7 +344,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</MatrixDriverProvider>
|
||||
</ClientProvider>
|
||||
</TooltipProvider>
|
||||
</Decoration>
|
||||
</ErrorBoundary>
|
||||
@@ -424,26 +356,3 @@ 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} />;
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:cfbe7ceb346918257ef07cb25f7b9b03ec0575ecb5ce51dfeeef873e67c60c95
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:cfbe7ceb346918257ef07cb25f7b9b03ec0575ecb5ce51dfeeef873e67c60c95
|
||||
|
||||
@@ -9,7 +9,6 @@ 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)
|
||||
|
||||
|
||||
@@ -31,7 +31,28 @@ copy, the PR says so.
|
||||
- **Suites.** Test cases first, helpers below. Older suites invert this; follow the
|
||||
rule in new ones, don't reorder old ones.
|
||||
- **Names.** What a thing means, not what it is made of: `naturalLayout$`, not
|
||||
`computedLayout$`. Comments explain why, never what.
|
||||
`computedLayout$`.
|
||||
|
||||
## Comments
|
||||
|
||||
No comment is the default. One earns its place by giving a reason the code
|
||||
cannot: a platform quirk, an outside constraint, a trap that looks like a
|
||||
simplification.
|
||||
|
||||
- Say the reason, then stop. Test each sentence: does the reason still stand
|
||||
without it? If so, cut it. Most reasons fit on a line, so a long comment is
|
||||
worth a second look — it is usually carrying something that isn't one.
|
||||
- Write about the line the comment sits on, in terms of what the code does
|
||||
there — leave layout to the stylesheet and measurements to the design.
|
||||
- Name the condition ("where the platform can't route audio"), not the browser
|
||||
or the spec entry.
|
||||
- Put history — what was tried, removed or rejected, the dev journal — in the
|
||||
commit or the PR description.
|
||||
- In tests, comment only structure that looks wrong but isn't: batching, timers,
|
||||
why this scroll position.
|
||||
|
||||
Before keeping one: would it still be true after a CSS change or a refactor of
|
||||
its caller? If not, cut it.
|
||||
|
||||
## TypeScript, React and RxJS idiom win on a clash
|
||||
|
||||
|
||||
@@ -29,9 +29,6 @@ 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",
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# 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 })`.
|
||||
@@ -1,977 +0,0 @@
|
||||
# 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,12 +26,6 @@ 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
|
||||
|
||||
+10
-14
@@ -58,18 +58,6 @@
|
||||
},
|
||||
"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: ",
|
||||
@@ -102,6 +90,7 @@
|
||||
"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",
|
||||
@@ -121,8 +110,6 @@
|
||||
"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",
|
||||
@@ -176,6 +163,12 @@
|
||||
"login_auth_links_prompt": "Not registered yet?",
|
||||
"login_subheading": "To continue to Element",
|
||||
"login_title": "Login",
|
||||
"microphone_level": {
|
||||
"label": "Microphone level",
|
||||
"no_device": "No microphone found. Connect one, then try again.",
|
||||
"permission_denied": "Microphone access is blocked. Allow it, then try again.",
|
||||
"value": "{{level}} of {{max}}"
|
||||
},
|
||||
"microphone_off": "Microphone off",
|
||||
"microphone_on": "Microphone on",
|
||||
"mute_microphone_button_label": "Mute microphone",
|
||||
@@ -228,11 +221,14 @@
|
||||
"activating": "Activating…",
|
||||
"camera": "Camera",
|
||||
"camera_numbered": "Camera {{n}}",
|
||||
"camera_source": "Camera Source",
|
||||
"change_device_button": "Change audio device",
|
||||
"default": "Default",
|
||||
"default_named": "Default <2>({{name}})</2>",
|
||||
"default_named_plain": "Default ({{name}})",
|
||||
"handset": "Handset",
|
||||
"loudspeaker": "Loudspeaker",
|
||||
"mic_source": "Mic Source",
|
||||
"microphone": "Microphone",
|
||||
"microphone_numbered": "Microphone {{n}}",
|
||||
"speaker": "Speaker",
|
||||
|
||||
+1
-3
@@ -48,7 +48,6 @@
|
||||
},
|
||||
"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",
|
||||
@@ -84,7 +83,7 @@
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@typescript-eslint/utils": "^8.61.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-web": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
@@ -108,7 +107,6 @@
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"oxfmt": "^0.66.0",
|
||||
"oxlint": "^1.70.0",
|
||||
"oxlint-tsgolint": "^0.25.0",
|
||||
|
||||
@@ -19,32 +19,6 @@ 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,
|
||||
@@ -70,7 +44,6 @@ 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",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
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 { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers.ts";
|
||||
import { installFakeDevices } from "./utils/fake-devices.ts";
|
||||
|
||||
test.describe("the quick audio menu", () => {
|
||||
test("lists speakers and microphones with a live level meter", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFakeDevices(page, { microphones: 3, speakers: 3 });
|
||||
await joinACall(page, "Menu user", "Audio menu");
|
||||
await openAudioMenu(page);
|
||||
|
||||
await expect(page.getByRole("group", { name: "Speaker" })).toBeVisible();
|
||||
await expect(page.getByRole("group", { name: "Microphone" })).toBeVisible();
|
||||
// By name, not count: the browser adds its own fake output and a Default.
|
||||
for (const n of [1, 2, 3])
|
||||
await expect(
|
||||
page
|
||||
.getByRole("group", { name: "Speaker" })
|
||||
.getByRole("menuitemradio", { name: `Fake Speaker ${n}` }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole("menuitemradio", { checked: true }),
|
||||
).toHaveCount(2);
|
||||
const meter = page.getByRole("meter", { name: "Microphone level" });
|
||||
await expect(meter).toBeVisible();
|
||||
await expect(meter).toHaveAttribute("aria-valuenow", /\d+/);
|
||||
await expect(meter).toHaveAttribute("aria-valuetext", /\d+ of \d+/);
|
||||
|
||||
// Both browsers here can route audio; the platform that can't is a unit check.
|
||||
await expect(
|
||||
page
|
||||
.getByRole("group", { name: "Speaker" })
|
||||
.getByRole("menuitemradio")
|
||||
.first(),
|
||||
).toHaveAttribute("aria-disabled", "false");
|
||||
});
|
||||
|
||||
test("moves the microphone and the speaker without disturbing the call", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Two browsers and a real call.
|
||||
test.slow();
|
||||
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const host = await hostContext.newPage();
|
||||
await installFakeDevices(host, { microphones: 3, speakers: 3 });
|
||||
await joinACall(host, "Host", "Device switch");
|
||||
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(host);
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guest = await guestContext.newPage();
|
||||
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Guest");
|
||||
await SpaHelpers.expectVideoTilesCount(guest, 2);
|
||||
|
||||
await openAudioMenu(host);
|
||||
await selectDevice(host, "Microphone", "Fake Microphone 2");
|
||||
await openAudioMenu(host);
|
||||
await selectDevice(host, "Speaker", "Fake Speaker 2");
|
||||
|
||||
// Not a rejoin: neither side drops, and the guest still has both tiles.
|
||||
await expect(
|
||||
host.getByRole("dialog", { name: "Reconnecting…" }),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
guest.getByRole("dialog", { name: "Reconnecting…" }),
|
||||
).not.toBeVisible();
|
||||
await SpaHelpers.expectVideoTilesCount(guest, 2);
|
||||
await expect(guest.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
});
|
||||
|
||||
test("keeps the meter moving while muted, and sends nothing", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Two browsers and a real call.
|
||||
test.slow();
|
||||
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const host = await hostContext.newPage();
|
||||
await installFakeDevices(host);
|
||||
await joinACall(host, "Muted host", "Muted meter");
|
||||
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(host);
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guest = await guestContext.newPage();
|
||||
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Listener");
|
||||
await SpaHelpers.expectVideoTilesCount(guest, 2);
|
||||
|
||||
const mute = host.getByTestId("incall_mute");
|
||||
await mute.click();
|
||||
await expect(mute).toHaveAttribute("aria-checked", "false");
|
||||
await openAudioMenu(host);
|
||||
|
||||
const meter = host.getByRole("meter", { name: "Microphone level" });
|
||||
await expect(meter).toBeVisible();
|
||||
// By test id: the modal menu hides the rest of the call from the a11y tree.
|
||||
await expect(mute).toHaveAttribute("aria-checked", "false");
|
||||
await expect(mute).toBeVisible();
|
||||
// And the guest is shown the mute.
|
||||
await expect(
|
||||
guest.getByTestId("videoTile").filter({ hasText: "Muted host" }),
|
||||
).toBeVisible();
|
||||
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
});
|
||||
|
||||
test("keeps the meter at the foot of the list while it scrolls", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||
await joinACall(page, "Scroller", "Long device list");
|
||||
await openAudioMenu(page);
|
||||
|
||||
const meter = page.getByRole("meter", { name: "Microphone level" });
|
||||
await expect(meter).toBeVisible();
|
||||
|
||||
// Scrolled so the microphones start at the top: only there does a pinned
|
||||
// meter differ from one that is simply last.
|
||||
const list = page.locator("[role='menu'] div[role='none']").first();
|
||||
await list.evaluate((element) => {
|
||||
const group = element.querySelector("[role='group'][aria-label*='icro']");
|
||||
element.scrollTop +=
|
||||
group!.getBoundingClientRect().top -
|
||||
element.getBoundingClientRect().top;
|
||||
});
|
||||
|
||||
await expect(meter).toBeInViewport();
|
||||
await expectPinnedInside(meter, list);
|
||||
await expect(
|
||||
page.getByRole("menuitemradio", { name: "Fake Microphone 20" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the focus ring only when the keyboard moved the focus", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"Headless Firefox does not deliver synthetic key presses reliably; see reconnect.spec.ts",
|
||||
);
|
||||
await installFakeDevices(page);
|
||||
await joinACall(page, "Keyboard user", "Focus ring");
|
||||
await openAudioMenu(page);
|
||||
|
||||
const first = page.getByRole("menuitemradio").first();
|
||||
// Opened by pointer: no ring, though Radix has moved focus into the menu.
|
||||
await expect.poll(async () => outlineWidth(first)).toBe(0);
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
const focused = page.locator("[role='menuitemradio']:focus");
|
||||
await expect.poll(async () => outlineWidth(focused)).toBeGreaterThan(0);
|
||||
|
||||
// And the pointer takes it away again.
|
||||
await first.hover();
|
||||
await expect.poll(async () => outlineWidth(focused)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/** Creates a call and joins it, leaving the page in the call. */
|
||||
async function joinACall(
|
||||
page: Page,
|
||||
userName: string,
|
||||
callName: string,
|
||||
): Promise<void> {
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, userName, callName, true);
|
||||
await expect(page.getByTestId("name_tag")).toContainText(userName);
|
||||
// The media controls stay disabled until devices have enumerated.
|
||||
await expect(page.getByTestId("incall_mute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openAudioMenu(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
}
|
||||
|
||||
async function selectDevice(
|
||||
page: Page,
|
||||
section: "Speaker" | "Microphone",
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const item = page
|
||||
.getByRole("group", { name: section })
|
||||
.getByRole("menuitemradio", { name });
|
||||
await item.click();
|
||||
// Selecting doesn't close the menu, so dismiss it.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menu")).not.toBeVisible();
|
||||
}
|
||||
|
||||
/** Asserts the meter sits within the scrollport and inside the menu's frame. */
|
||||
async function expectPinnedInside(
|
||||
meter: Locator,
|
||||
list: Locator,
|
||||
): Promise<void> {
|
||||
const meterBox = (await meter.boundingBox())!;
|
||||
const listBox = (await list.boundingBox())!;
|
||||
const frame = (await meter.page().getByRole("menu").boundingBox())!;
|
||||
|
||||
expect(meterBox.y + meterBox.height).toBeLessThanOrEqual(
|
||||
listBox.y + listBox.height + 1,
|
||||
);
|
||||
expect(meterBox.y).toBeGreaterThanOrEqual(listBox.y - 1);
|
||||
expect(meterBox.x).toBeGreaterThan(frame.x);
|
||||
expect(meterBox.x + meterBox.width).toBeLessThan(frame.x + frame.width);
|
||||
}
|
||||
|
||||
/** Painted outline width, in px. */
|
||||
async function outlineWidth(item: Locator): Promise<number> {
|
||||
if ((await item.count()) === 0) return 0;
|
||||
return item.first().evaluate((element) => {
|
||||
const { outlineStyle, outlineWidth } = getComputedStyle(element);
|
||||
return outlineStyle === "none" ? 0 : Number.parseFloat(outlineWidth) || 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
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 { expect, type Locator, type Page, test } from "@playwright/test";
|
||||
|
||||
import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
|
||||
import { installFakeDevices } from "../utils/fake-devices.ts";
|
||||
|
||||
/**
|
||||
* The device menu with Element Call as a component in a host page, where the
|
||||
* portalled menu must be sized against the call, not the window. Driven from
|
||||
* the lobby: it builds the same menu, without two connections' worth of flake.
|
||||
*/
|
||||
|
||||
// Sign-in, crypto setup and sync happen twice before anything shows.
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("sizes the device list against the call, not the window", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||
const { username, roomId } = await createUserAndRoom("menusize");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
|
||||
// A short call in a much taller page.
|
||||
const container = pane.getByTestId("call-container");
|
||||
await resizeContainer(container, { width: 900, height: 400 });
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const list = await openDeviceList(page, pane);
|
||||
const callHeight = (await container.boundingBox())!.height;
|
||||
const windowHeight = page.viewportSize()!.height;
|
||||
const listHeight = (await list.boundingBox())!.height;
|
||||
|
||||
expect(callHeight).toBeLessThan(windowHeight * 0.75);
|
||||
expect(listHeight).toBeLessThanOrEqual(callHeight);
|
||||
expect(listHeight).toBeLessThan(windowHeight * 0.6);
|
||||
});
|
||||
|
||||
test("follows the call area when the host resizes it", async ({ page }) => {
|
||||
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||
const { username, roomId } = await createUserAndRoom("menuresize");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
|
||||
const container = pane.getByTestId("call-container");
|
||||
await resizeContainer(container, { width: 900, height: 360 });
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const list = await openDeviceList(page, pane);
|
||||
const whenShort = (await list.boundingBox())!.height;
|
||||
|
||||
// The host grows the call while the menu is open.
|
||||
await resizeContainer(container, { width: 900, height: 700 });
|
||||
await expect
|
||||
.poll(async () => (await list.boundingBox())!.height)
|
||||
.toBeGreaterThan(whenShort);
|
||||
});
|
||||
|
||||
test("keeps every device reachable in a small container", async ({ page }) => {
|
||||
await installFakeDevices(page, { microphones: 20, speakers: 4 });
|
||||
const { username, roomId } = await createUserAndRoom("menureach");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
|
||||
const container = pane.getByTestId("call-container");
|
||||
await resizeContainer(container, { width: 400, height: 360 });
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const list = await openDeviceList(page, pane);
|
||||
// More devices than fit, so the list must scroll.
|
||||
expect(await list.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const last = page.getByRole("menuitemradio", { name: "Fake Microphone 20" });
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
await expect(last).toBeInViewport();
|
||||
// Reachable means clickable: the menu may be drawn outside the call area.
|
||||
await last.click();
|
||||
await expect(last).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
test("tracks the focus source of its own call, not the page", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFakeDevices(page);
|
||||
const { username, roomId } = await createUserAndRoom("menufocus");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
const other = panes.nth(1);
|
||||
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await openDeviceList(page, pane);
|
||||
const menu = page.getByRole("menu");
|
||||
|
||||
// Asserted on the attribute: the component build's scoped stylesheet doesn't
|
||||
// reach the portalled menu, so its ring can't be read here.
|
||||
await expect(menu).toHaveAttribute("data-focus-source", "pointer");
|
||||
|
||||
// A key pressed in the other call says nothing about this menu.
|
||||
await other.evaluate((element) =>
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
|
||||
),
|
||||
);
|
||||
await expect(menu).toHaveAttribute("data-focus-source", "pointer");
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(menu).toHaveAttribute("data-focus-source", "keyboard");
|
||||
});
|
||||
|
||||
/** Opens a component's microphone menu and returns its device list. */
|
||||
async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
|
||||
await pane
|
||||
.getByRole("button", { name: "Microphone" })
|
||||
.click({ timeout: 60_000 });
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
const list = page.locator("[role='menu'] div[role='none']").first();
|
||||
await expect(list).toBeVisible();
|
||||
return list;
|
||||
}
|
||||
@@ -109,10 +109,15 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
|
||||
|
||||
// Accept the invite
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await expect(
|
||||
ewPage2.getByRole("option", { name: "Welcome Room" }),
|
||||
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
|
||||
).toBeVisible();
|
||||
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
|
||||
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
|
||||
await ewPage2.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
ewPage2
|
||||
@@ -152,8 +157,12 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
).toBeVisible();
|
||||
|
||||
// Accept the DM invite from brooks
|
||||
// This how playwright record selects the DM invite in the room list
|
||||
await ewPage2.getByRole("button", { name: "Open room" }).click();
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
|
||||
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
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 Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Adds synthetic devices beside the browser's one fake microphone and speaker,
|
||||
* so the menu has a choice to show. They share one capture and `setSinkId` is
|
||||
* accepted but not honoured: routing still needs hardware and a manual check.
|
||||
* Call before the page navigates.
|
||||
*/
|
||||
export async function installFakeDevices(
|
||||
page: Page,
|
||||
{ microphones = 2, speakers = 2 } = {},
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ microphones, speakers }) => {
|
||||
const synthetic = (
|
||||
kind: MediaDeviceKind,
|
||||
count: number,
|
||||
name: string,
|
||||
): MediaDeviceInfo[] =>
|
||||
Array.from({ length: count }, (_, i) => {
|
||||
const info = {
|
||||
deviceId: `${kind}-${i + 1}`,
|
||||
groupId: `${kind}-group-${i + 1}`,
|
||||
kind,
|
||||
label: `${name} ${i + 1}`,
|
||||
};
|
||||
return { ...info, toJSON: () => info } as MediaDeviceInfo;
|
||||
});
|
||||
const ids = new Set(
|
||||
[
|
||||
...synthetic("audioinput", microphones, ""),
|
||||
...synthetic("audiooutput", speakers, ""),
|
||||
].map((d) => d.deviceId),
|
||||
);
|
||||
|
||||
const devices = navigator.mediaDevices;
|
||||
const enumerate = devices.enumerateDevices.bind(devices);
|
||||
devices.enumerateDevices = async (): Promise<MediaDeviceInfo[]> => [
|
||||
...(await enumerate()),
|
||||
...synthetic("audioinput", microphones, "Fake Microphone"),
|
||||
...synthetic("audiooutput", speakers, "Fake Speaker"),
|
||||
];
|
||||
|
||||
// Our ids name no hardware, so drop the exact-device constraint.
|
||||
const getUserMedia = devices.getUserMedia.bind(devices);
|
||||
devices.getUserMedia = async (
|
||||
constraints?: MediaStreamConstraints,
|
||||
): Promise<MediaStream> => {
|
||||
const audio = constraints?.audio;
|
||||
if (typeof audio === "object") {
|
||||
const requested = audio.deviceId;
|
||||
const id =
|
||||
typeof requested === "object" && requested !== null
|
||||
? ((requested as ConstrainDOMStringParameters).exact as string)
|
||||
: (requested as string | undefined);
|
||||
if (id !== undefined && ids.has(id))
|
||||
return getUserMedia({ ...constraints, audio: true });
|
||||
}
|
||||
return getUserMedia(constraints);
|
||||
};
|
||||
|
||||
// Accept routing to our ids on both media elements and the AudioContext,
|
||||
// which the app also routes.
|
||||
for (const proto of [
|
||||
HTMLMediaElement.prototype,
|
||||
AudioContext.prototype,
|
||||
]) {
|
||||
const sink = proto as { setSinkId?: (id: string) => Promise<void> };
|
||||
const setSinkId = sink.setSinkId;
|
||||
if (setSinkId === undefined) continue;
|
||||
sink.setSinkId = async function (id: string): Promise<void> {
|
||||
if (!ids.has(id)) await setSinkId.call(this, id);
|
||||
};
|
||||
}
|
||||
},
|
||||
{ microphones, speakers },
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type JSHandle,
|
||||
type Page,
|
||||
type FrameLocator,
|
||||
type Locator,
|
||||
} from "@playwright/test";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
@@ -203,6 +204,16 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
public static async expandAllSections(page: Page): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Expand all sections" })
|
||||
.click({ timeout: 2000 });
|
||||
} catch {
|
||||
// Already expanded or button not present
|
||||
}
|
||||
}
|
||||
|
||||
public static async createRoom(
|
||||
name: string,
|
||||
page: Page,
|
||||
@@ -253,9 +264,9 @@ export class TestHelpers {
|
||||
roomName: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: roomName }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Accept" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -350,6 +361,17 @@ export class TestHelpers {
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a room in the room list by its name.
|
||||
*
|
||||
* Matches on the aria-label prefix because the item's role differs between
|
||||
* the flat room list (`option`) and the sectioned room list (`button`), and
|
||||
* the label may carry a suffix such as " invitation.".
|
||||
*/
|
||||
public static roomListItem(page: Page, roomName: string): Locator {
|
||||
return page.locator(`[aria-label^="Open room ${roomName}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
@@ -359,7 +381,8 @@ export class TestHelpers {
|
||||
page: Page,
|
||||
roomName: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click();
|
||||
}
|
||||
|
||||
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
|
||||
|
||||
Generated
+10
-46
@@ -25,9 +25,6 @@ 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
|
||||
@@ -134,11 +131,11 @@ importers:
|
||||
specifier: ^10.2.11
|
||||
version: 10.3.1(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens':
|
||||
specifier: ^10.0.0
|
||||
version: 10.2.4(@types/react@19.2.18)(react@19.2.8)
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-web':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(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)))(babel-plugin-react-compiler@1.0.0)(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))
|
||||
@@ -205,9 +202,6 @@ importers:
|
||||
normalize.css:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
observable-hooks:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2)
|
||||
oxfmt:
|
||||
specifier: ^0.66.0
|
||||
version: 0.66.0
|
||||
@@ -412,13 +406,6 @@ 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==}
|
||||
|
||||
@@ -3228,9 +3215,6 @@ 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==}
|
||||
|
||||
@@ -3242,8 +3226,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>= 16.8.0'
|
||||
|
||||
'@vector-im/compound-design-tokens@10.2.4':
|
||||
resolution: {integrity: sha512-2aCjhSvJxktPWnvh5s9Xdc0Rd76AfHqbDbN4QjOxo2VIac6zYdLyQbxMUQ5ZeoR9G3vAxILd0H1UvApRfMilpQ==}
|
||||
'@vector-im/compound-design-tokens@11.0.0':
|
||||
resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==}
|
||||
peerDependencies:
|
||||
'@adobe/leonardo-contrast-colors': ^1.0.0
|
||||
'@types/react': '*'
|
||||
@@ -3259,8 +3243,8 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@vector-im/compound-web@10.0.1':
|
||||
resolution: {integrity: sha512-CT3aGMalPSf53o3JUbBF7EA10Rfp+auftIuBwOUSna+hfZ/zPxGLTFcuCuOJ7L8hrzijM+V4I8L0PUkfyOczpg==}
|
||||
'@vector-im/compound-web@10.1.0':
|
||||
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
|
||||
peerDependencies:
|
||||
'@fontsource/inconsolata': ^5
|
||||
'@fontsource/inter': ^5
|
||||
@@ -4829,13 +4813,6 @@ packages:
|
||||
resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
observable-hooks@4.2.4:
|
||||
resolution: {integrity: sha512-FdTQgyw1h5bG/QHCBIqctdBSnv9VARJCEilgpV6L2qlw1yeLqFIwPm4U15dMtl5kDmNN0hSt+Nl6iYbLFwEcQA==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
rxjs: '>=6.0.0'
|
||||
|
||||
obug@2.1.4:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -6372,11 +6349,6 @@ 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': {}
|
||||
@@ -8704,8 +8676,6 @@ 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': {}
|
||||
@@ -8715,12 +8685,12 @@ snapshots:
|
||||
'@use-gesture/core': 10.3.1
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8)':
|
||||
'@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8)':
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
'@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -8732,7 +8702,7 @@ snapshots:
|
||||
'@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens': 10.2.4(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens': 11.0.0(@types/react@19.2.18)(react@19.2.8)
|
||||
classnames: 2.5.1
|
||||
react: 19.2.8
|
||||
vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -10430,12 +10400,6 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
object-keys: 1.1.1
|
||||
|
||||
observable-hooks@4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
rxjs: 7.8.2
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
once@1.4.0:
|
||||
|
||||
@@ -3,8 +3,6 @@ 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,6 +31,7 @@ import {
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type CallMembership,
|
||||
MatrixRTCSessionEvent,
|
||||
MatrixRTCSessionManager,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -47,7 +48,7 @@ import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/roo
|
||||
import { type Behavior, constant } from "../src/state/Behavior";
|
||||
import {
|
||||
callViewModelOptionsFromParams,
|
||||
createJsClientCallViewModel$,
|
||||
createCallViewModel$,
|
||||
} from "../src/state/CallViewModel/CallViewModel";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { getUrlParams } from "../src/UrlParams";
|
||||
@@ -58,7 +59,6 @@ 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: CallMember;
|
||||
membership: CallMembership;
|
||||
participant: LocalParticipant | RemoteParticipant | null;
|
||||
}[]
|
||||
>;
|
||||
@@ -92,7 +92,7 @@ interface MatrixRTCSdk {
|
||||
*/
|
||||
localMember$: Behavior<{
|
||||
connection: Connection | null;
|
||||
membership: CallMember;
|
||||
membership: CallMembership;
|
||||
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 = createJsClientCallViewModel$(
|
||||
const callViewModel = createCallViewModel$(
|
||||
scope,
|
||||
rtcSession,
|
||||
room,
|
||||
|
||||
@@ -29,6 +29,7 @@ const TestComponent: FC<
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
state: "valid",
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
reactions: true,
|
||||
},
|
||||
|
||||
+9
-28
@@ -16,7 +16,6 @@ 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 {
|
||||
@@ -77,9 +76,6 @@ 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(
|
||||
() =>
|
||||
@@ -99,30 +95,16 @@ export const Avatar: FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let url: Promise<string | null>;
|
||||
let blob: Promise<Blob>;
|
||||
|
||||
if (hostBridge.downloadMedia) {
|
||||
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",
|
||||
);
|
||||
blob = hostBridge.downloadMedia(src);
|
||||
} else if (
|
||||
clientState?.state === "valid" &&
|
||||
clientState.authenticated?.client &&
|
||||
sizePx
|
||||
) {
|
||||
url = getAvatarFromServer(
|
||||
clientState.authenticated.client,
|
||||
src,
|
||||
sizePx,
|
||||
).then((blob) => URL.createObjectURL(blob));
|
||||
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
|
||||
} else {
|
||||
setAvatarUrl(undefined);
|
||||
return;
|
||||
@@ -130,14 +112,13 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
let objectUrl: string | undefined;
|
||||
let stale = false;
|
||||
url
|
||||
.then((resolved) => {
|
||||
if (stale || resolved === null) {
|
||||
blob
|
||||
.then((blob) => {
|
||||
if (stale) {
|
||||
return;
|
||||
}
|
||||
// Only what we created (or the driver created for us) is ours to revoke.
|
||||
if (resolved.startsWith("blob:")) objectUrl = resolved;
|
||||
setAvatarUrl(resolved);
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setAvatarUrl(objectUrl);
|
||||
})
|
||||
.catch((ex) => {
|
||||
if (stale) {
|
||||
@@ -152,7 +133,7 @@ export const Avatar: FC<Props> = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [clientState, hostBridge, drivers, src, sizePx]);
|
||||
}, [clientState, hostBridge, src, sizePx]);
|
||||
|
||||
return (
|
||||
<CompoundAvatar
|
||||
|
||||
+32
-2
@@ -17,7 +17,8 @@ import {
|
||||
type JSX,
|
||||
} from "react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { ErrorPage } from "./FullScreenView";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
@@ -41,6 +42,9 @@ 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;
|
||||
};
|
||||
@@ -272,6 +276,7 @@ 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(() => {
|
||||
@@ -295,6 +300,7 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
state: "valid",
|
||||
authenticated,
|
||||
setClient,
|
||||
disconnected: isDisconnected,
|
||||
supportedFeatures: {
|
||||
reactions: supportsReactions,
|
||||
},
|
||||
@@ -305,9 +311,17 @@ 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;
|
||||
@@ -319,10 +333,20 @@ 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);
|
||||
}, [initClientState, hostBridge]);
|
||||
|
||||
return (): void => {
|
||||
if (initClientState.client) {
|
||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||
}
|
||||
};
|
||||
}, [initClientState, onSync, hostBridge]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage error={alreadyOpenedErr} />;
|
||||
@@ -364,3 +388,9 @@ const loadSession = (): Session | undefined => {
|
||||
|
||||
return JSON.parse(data);
|
||||
};
|
||||
|
||||
const clientIsDisconnected = (
|
||||
syncState: SyncState,
|
||||
syncData?: ISyncStateData,
|
||||
): boolean =>
|
||||
syncState === "ERROR" && syncData?.error?.name === "ConnectionError";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+18
-7
@@ -23,27 +23,38 @@ 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>
|
||||
<>
|
||||
<header
|
||||
ref={ref}
|
||||
className={classNames(styles.header, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
{disconnectedBanner && <DisconnectedBanner />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type MediaDevices } from "./state/MediaDevices";
|
||||
import { useBehavior } from "./useBehavior";
|
||||
|
||||
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
|
||||
undefined,
|
||||
@@ -35,7 +35,7 @@ export const useEarpieceAudioConfig = (): {
|
||||
volume: number;
|
||||
} => {
|
||||
const devices = useMediaDevices();
|
||||
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
|
||||
const audioOutput = useBehavior(devices.audioOutput.selected$);
|
||||
const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false;
|
||||
return {
|
||||
// We use only the right speaker (pan = 1) for the earpiece.
|
||||
|
||||
@@ -19,7 +19,6 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import {
|
||||
mediaKeyStatisticsOf,
|
||||
CallEndedTracker,
|
||||
CallReconnectingTracker,
|
||||
type CallReconnectingReason,
|
||||
@@ -68,7 +67,7 @@ describe("CallEnded", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.track("test-call-id", 2, false, mediaKeyStatisticsOf(mockSession));
|
||||
tracker.track("test-call-id", 2, false, mockSession);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
|
||||
@@ -82,7 +81,7 @@ describe("CallEnded", () => {
|
||||
|
||||
tracker.cacheStartCall(new Date(Date.now() - 60000));
|
||||
tracker.cacheParticipantCountChanged(5);
|
||||
tracker.track("test-call-id", 3, true, mediaKeyStatisticsOf(mockSession));
|
||||
tracker.track("test-call-id", 3, true, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -112,7 +111,7 @@ describe("CallEnded", () => {
|
||||
tracker.cacheParticipantCountChanged(3);
|
||||
tracker.cacheParticipantCountChanged(7);
|
||||
tracker.cacheParticipantCountChanged(2);
|
||||
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -129,7 +128,7 @@ describe("CallEnded", () => {
|
||||
});
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -147,7 +146,7 @@ describe("CallEnded", () => {
|
||||
});
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -162,7 +161,7 @@ describe("CallEnded", () => {
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -179,7 +178,7 @@ describe("CallEnded", () => {
|
||||
tracker.cacheReconnecting("sync");
|
||||
tracker.cacheReconnecting("livekit");
|
||||
tracker.cacheReconnecting("membership");
|
||||
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -15,34 +15,6 @@ 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
|
||||
@@ -108,7 +80,7 @@ export class CallEndedTracker {
|
||||
callId: string,
|
||||
callParticipantsNow: number,
|
||||
sendInstantly: boolean,
|
||||
keys: MediaKeyStatistics,
|
||||
rtcSession: MatrixRTCSession,
|
||||
): void {
|
||||
if (this.cache.startTime) {
|
||||
PosthogAnalytics.instance.trackEvent<CallEnded>(
|
||||
@@ -118,10 +90,16 @@ export class CallEndedTracker {
|
||||
callParticipantsMax: this.cache.maxParticipantsCount,
|
||||
callParticipantsOnLeave: callParticipantsNow,
|
||||
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
|
||||
roomEventEncryptionKeysSent: keys.sent,
|
||||
roomEventEncryptionKeysReceived: keys.received,
|
||||
roomEventEncryptionKeysSent:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
|
||||
roomEventEncryptionKeysReceived:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
|
||||
roomEventEncryptionKeysReceivedAverageAge:
|
||||
keys.received > 0 ? keys.receivedTotalAge / keys.received : 0,
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
|
||||
? rtcSession.statistics.totals
|
||||
.roomEventEncryptionKeysReceivedTotalAge /
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
|
||||
: 0,
|
||||
callReconnectingCount: this.cache.reconnectingCount,
|
||||
callReconnectingCountSync: this.cache.reconnectingCountByReason.sync,
|
||||
callReconnectingCountMembership:
|
||||
|
||||
@@ -17,10 +17,7 @@ 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 {
|
||||
jsSdkReactionsTimeline,
|
||||
ReactionsSenderProvider,
|
||||
} from "../reactions/useReactionsSender";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
vi.mock("livekit-client/e2ee-worker?worker");
|
||||
@@ -38,12 +35,7 @@ function TestComponent({
|
||||
<TooltipProvider>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
ownIdentifier={localIdent}
|
||||
ownMembershipEventId={localRtcMember.eventId}
|
||||
timeline={jsSdkReactionsTimeline(
|
||||
rtcSession.room.client,
|
||||
rtcSession.room.roomId,
|
||||
)}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
>
|
||||
<ReactionToggleButton
|
||||
reactionData={{
|
||||
|
||||
@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_bb_"
|
||||
aria-labelledby="_r_bh_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
|
||||
@@ -43,7 +43,10 @@ Please see LICENSE in the repository root for full details.
|
||||
inset-inline: 0;
|
||||
}
|
||||
|
||||
.footer.overlay:has(:focus-visible) {
|
||||
/* Keep the footer while it is in use: focus inside it, or a menu opened from
|
||||
it. The menu is portalled out, so the trigger's aria-expanded tracks it. */
|
||||
.footer.overlay:has(:focus-visible),
|
||||
.footer.overlay:has([aria-expanded="true"]) {
|
||||
opacity: 1;
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Link } from "@vector-im/compound-web";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CallFooter, type FooterSnapshot } from "./CallFooter";
|
||||
import inCallViewStyles from "../room/InCallView.module.css";
|
||||
import styles from "./CallFooter.module.css";
|
||||
import { useStaticViewModel } from "../state/ViewModel";
|
||||
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
@@ -137,10 +138,13 @@ export const Default: Story = {
|
||||
debugTileLayout: false,
|
||||
tileStoreGeneration: undefined,
|
||||
audioOptions: [],
|
||||
audioOutputOptions: [],
|
||||
videoOptions: [],
|
||||
selectedAudio: undefined,
|
||||
selectedAudioOutput: undefined,
|
||||
selectedVideo: undefined,
|
||||
selectAudioButtonOption: undefined,
|
||||
selectAudioOutputOption: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
},
|
||||
parameters: {
|
||||
@@ -158,15 +162,46 @@ export const WithAudioAndVideoOptions: Story = {
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||
],
|
||||
audioOutputOptions: [
|
||||
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "2" },
|
||||
],
|
||||
videoOptions: [
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||
],
|
||||
selectedAudio: "2",
|
||||
selectedAudioOutput: "default",
|
||||
selectedVideo: "1",
|
||||
},
|
||||
};
|
||||
|
||||
export const StaysWhileAMenuIsOpen: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...WithAudioAndVideoOptions.args,
|
||||
// In a short window the footer overlays the call and hides itself.
|
||||
asOverlay: true,
|
||||
showFooter: true,
|
||||
},
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const footer = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-testid="footer-container"]',
|
||||
)!;
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", { name: "Microphone" }),
|
||||
);
|
||||
await expect(document.body.querySelector('[role="menu"]')).not.toBeNull();
|
||||
|
||||
// The call hides the footer with this class.
|
||||
footer.classList.add(styles.hidden);
|
||||
|
||||
// Read after the 0.15s fade, not mid-transition.
|
||||
await new Promise((settled) => setTimeout(settled, 400));
|
||||
await expect(getComputedStyle(footer).opacity).toBe("1");
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioBusy: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
|
||||
@@ -99,11 +99,15 @@ export interface FooterState {
|
||||
|
||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||
audioOptions: MenuOptions[];
|
||||
/** Output (speaker) devices. */
|
||||
audioOutputOptions: MenuOptions[];
|
||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||
videoOptions: MenuOptions[];
|
||||
selectedAudio: string | undefined;
|
||||
selectedAudioOutput: string | undefined;
|
||||
selectedVideo: string | undefined;
|
||||
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
|
||||
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
|
||||
selectVideoButtonOption: ((option: string) => void) | undefined;
|
||||
}
|
||||
|
||||
@@ -143,6 +147,9 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const audioOptions = useBehavior(vm.audioOptions$);
|
||||
const selectedAudio = useBehavior(vm.selectedAudio$);
|
||||
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
|
||||
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
|
||||
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
|
||||
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
|
||||
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||
const toggleBlur = useBehavior(vm.toggleBlur$);
|
||||
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
||||
@@ -168,7 +175,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
if ((audioOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Mic Source"}
|
||||
key="audio"
|
||||
iconsAndLabels="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
@@ -178,6 +184,9 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
options={audioOptions}
|
||||
selectedOption={selectedAudio}
|
||||
onSelect={selectAudioButtonOption}
|
||||
outputOptions={audioOutputOptions}
|
||||
selectedOutputOption={selectedAudioOutput}
|
||||
onSelectOutput={selectAudioOutputOption}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
@@ -197,7 +206,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
if ((videoOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Camera Source"}
|
||||
key="video"
|
||||
iconsAndLabels="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
|
||||
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import type { Alignment, Layout } from "../state/layout-types";
|
||||
import type { Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import type { DeviceLabel } from "../state/MediaDevices";
|
||||
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
||||
@@ -30,6 +29,11 @@ vi.mock("@livekit/track-processors", () => ({
|
||||
supportsBackgroundProcessors: (): boolean => false,
|
||||
}));
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("livekit-client", () => ({
|
||||
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Returns the minimum set of CallViewModel fields required by
|
||||
* createCallFooterViewModel, with all other properties stubbed to
|
||||
@@ -60,10 +64,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
const gridLayout: Layout = {
|
||||
type: "grid",
|
||||
grid: [],
|
||||
spotlightAlignment$: new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
}),
|
||||
setVisibleTiles: (_: number) => {},
|
||||
};
|
||||
|
||||
@@ -96,6 +96,65 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
||||
});
|
||||
|
||||
describe("createCallFooterViewModel", () => {
|
||||
describe("selectAudioOutputOption", () => {
|
||||
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
return createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
}
|
||||
|
||||
it("is withheld where the platform cannot route audio to a chosen device", () => {
|
||||
outputSelectionMock.mockReturnValue(false);
|
||||
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is offered where the platform can route audio to a chosen device", () => {
|
||||
outputSelectionMock.mockReturnValue(true);
|
||||
expect(buildFooterVm().selectAudioOutputOption$.value).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioOutputOptions", () => {
|
||||
it("is an empty list, not absent, where the platform enumerates no outputs", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
outputSelectionMock.mockReturnValue(true);
|
||||
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic1", { type: "name", name: "Microphone 1" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
// As Safari: no outputs listed.
|
||||
audioOutput: {
|
||||
available$: constant(new Map<string, DeviceLabel>()),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
}),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
// Empty, not undefined: undefined would hide the section.
|
||||
expect(vm.audioOutputOptions$.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioOptions and videoOptions", () => {
|
||||
function checkEmptyFor(platform: string, layout: Layout): void {
|
||||
platformMock.mockReturnValue(platform);
|
||||
|
||||
@@ -5,8 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, switchMap } from "rxjs";
|
||||
import { combineLatest, map, type Observable, switchMap } from "rxjs";
|
||||
import { supportsBackgroundProcessors } from "@livekit/track-processors";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
@@ -67,49 +68,50 @@ function buildDeviceBehaviors(
|
||||
| "audioOptions$"
|
||||
| "selectedAudio$"
|
||||
| "selectAudioButtonOption$"
|
||||
| "audioOutputOptions$"
|
||||
| "selectedAudioOutput$"
|
||||
| "selectAudioOutputOption$"
|
||||
| "videoOptions$"
|
||||
| "selectedVideo$"
|
||||
| "selectVideoButtonOption$"
|
||||
| "toggleBlur$"
|
||||
| "videoBlurEnabled$"
|
||||
> {
|
||||
return {
|
||||
audioOptions$: scope.behavior(
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: mediaDevices.audioInput.available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({
|
||||
id,
|
||||
label,
|
||||
})),
|
||||
),
|
||||
const options$ = (
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
): Observable<MenuOptions[]> =>
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({ id, label })),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
|
||||
selectedAudio$: scope.behavior(
|
||||
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
|
||||
videoOptions$: scope.behavior(
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: mediaDevices.videoInput.available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({
|
||||
id,
|
||||
label,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
audioOutputOptions$: scope.behavior(
|
||||
options$(mediaDevices.audioOutput.available$),
|
||||
),
|
||||
selectedAudioOutput$: scope.behavior(
|
||||
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
// Withheld where the platform can't route audio to a chosen device, which
|
||||
// disables the speaker section.
|
||||
selectAudioOutputOption$: constant(
|
||||
supportsAudioOutputSelection()
|
||||
? mediaDevices.audioOutput.select
|
||||
: undefined,
|
||||
),
|
||||
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
|
||||
selectedVideo$: scope.behavior(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
@@ -263,10 +265,13 @@ export function createLobbyFooterViewModel(
|
||||
reactionData: undefined,
|
||||
tileStoreGeneration: undefined,
|
||||
audioOptions: undefined,
|
||||
audioOutputOptions: undefined,
|
||||
videoOptions: undefined,
|
||||
selectedAudio: undefined,
|
||||
selectedAudioOutput: undefined,
|
||||
selectedVideo: undefined,
|
||||
selectAudioButtonOption: undefined,
|
||||
selectAudioOutputOption: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
}),
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
|
||||
@@ -35,3 +35,60 @@ Please see LICENSE in the repository root for full details.
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Only the device lists scroll, bounded by the measured call area: the menu is
|
||||
portalled out of the root, so CSS can't size it against the call. */
|
||||
.deviceList {
|
||||
overflow-y: auto;
|
||||
min-block-size: 0;
|
||||
max-block-size: var(--device-list-max-height);
|
||||
/* Keeps a row reached by keyboard clear of the sticky heading and meter. */
|
||||
scroll-padding-block: var(--device-list-scroll-padding-start, 0)
|
||||
var(--device-list-scroll-padding-end, 0);
|
||||
}
|
||||
|
||||
/* Compound's heading rule heads each section; the design adds space below it. */
|
||||
.menu h3 {
|
||||
margin-block-end: var(--cpd-space-5x);
|
||||
}
|
||||
|
||||
/* And more above a heading that follows another section. */
|
||||
.deviceList [role="group"] + [role="group"] h3 {
|
||||
margin-block-start: var(--cpd-space-7x);
|
||||
}
|
||||
|
||||
/* Sticky within its own section. Opaque and inset a border width so it doesn't
|
||||
paint over the menu's outline. */
|
||||
.sectionHeading {
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-start: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
/* Sticky at the foot of the microphone section; opaque and inset as above. */
|
||||
.stickyMeter {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
margin-inline: var(--cpd-border-width-1);
|
||||
margin-block-end: var(--cpd-border-width-1);
|
||||
}
|
||||
|
||||
/* Radix focuses whatever the pointer is over, so the browser's ring would
|
||||
follow the mouse. Replaced by one shown only for keyboard focus. */
|
||||
.menu [role^="menuitem"]:focus,
|
||||
.menu [role^="menuitem"]:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.menu[data-focus-source="keyboard"] [role^="menuitem"]:focus {
|
||||
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
|
||||
outline-offset: calc(-1 * var(--cpd-border-width-2));
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { fn, userEvent, within, expect } from "storybook/test";
|
||||
import { type JSX } from "react";
|
||||
import { fn, userEvent, waitFor, within, expect } from "storybook/test";
|
||||
import { useEffect, useState, type FC, type JSX, type ReactNode } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
import meterStyles from "./MicrophoneLevelMeter.module.css";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { RootElementProvider } from "../RootElementContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
|
||||
@@ -18,12 +21,74 @@ const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
/** Supplies a microphone, a wavering tone: a story's invented device ids match no hardware. */
|
||||
const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
const context = new AudioContext();
|
||||
const microphone = context.createMediaStreamDestination();
|
||||
const tone = context.createOscillator();
|
||||
const loudness = context.createGain();
|
||||
// Wavers between a third and two thirds of full scale, so it reads as live.
|
||||
const swing = context.createOscillator();
|
||||
const depth = context.createGain();
|
||||
loudness.gain.value = 0.25;
|
||||
depth.gain.value = 0.2;
|
||||
swing.frequency.value = 0.6;
|
||||
tone.frequency.value = 220;
|
||||
swing.connect(depth).connect(loudness.gain);
|
||||
tone.connect(loudness).connect(microphone);
|
||||
tone.start();
|
||||
swing.start();
|
||||
|
||||
const devices = navigator.mediaDevices;
|
||||
const openedForReal = devices.getUserMedia.bind(devices);
|
||||
const opened = Promise.resolve(microphone.stream);
|
||||
// A fresh clone each time, so a caller stopping its tracks doesn't end the next.
|
||||
devices.getUserMedia = async (): Promise<MediaStream> =>
|
||||
(await opened).clone();
|
||||
|
||||
return (): void => {
|
||||
devices.getUserMedia = openedForReal;
|
||||
tone.stop();
|
||||
swing.stop();
|
||||
void context.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
/** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
|
||||
const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [callArea, setCallArea] = useState<HTMLElement | null>(null);
|
||||
return (
|
||||
<div
|
||||
ref={setCallArea}
|
||||
style={{
|
||||
// A call's size: anything smaller makes a short device list scroll.
|
||||
blockSize: 720,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{callArea !== null && (
|
||||
<RootElementProvider value={callArea}>{children}</RootElementProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
component: MediaMuteAndSwitchButton,
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<Story />
|
||||
<WithACallArea>
|
||||
<WithAMicrophone>
|
||||
<Story />
|
||||
</WithAMicrophone>
|
||||
</WithACallArea>
|
||||
</MediaDevicesContext>
|
||||
),
|
||||
],
|
||||
@@ -34,7 +99,6 @@ type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "SomeMenu",
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
@@ -42,6 +106,13 @@ export const Default: Story = {
|
||||
{ label: { type: "name", name: "Option 2" }, id: "2" },
|
||||
],
|
||||
selectedOption: "1",
|
||||
// The footer always passes an output list to the audio menu.
|
||||
outputOptions: [
|
||||
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "default",
|
||||
onSelectOutput: fn(),
|
||||
onMuteClick: fn(),
|
||||
onSelect: fn(),
|
||||
},
|
||||
@@ -50,7 +121,6 @@ export const Default: Story = {
|
||||
export const AudioMute: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
title: "Microphone",
|
||||
iconsAndLabels: "audio",
|
||||
enabled: false,
|
||||
options: [
|
||||
@@ -73,21 +143,19 @@ export const AudioMute: Story = {
|
||||
|
||||
export const AudioUnmute: Story = {
|
||||
args: {
|
||||
title: "Microphone",
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||
],
|
||||
|
||||
selectedOption: "2",
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoMute: Story = {
|
||||
args: {
|
||||
title: "Camera",
|
||||
iconsAndLabels: "video",
|
||||
enabled: false,
|
||||
options: [
|
||||
@@ -101,7 +169,6 @@ export const VideoMute: Story = {
|
||||
|
||||
export const VideoUnmute: Story = {
|
||||
args: {
|
||||
title: "Camera",
|
||||
iconsAndLabels: "video",
|
||||
enabled: true,
|
||||
options: [
|
||||
@@ -113,3 +180,479 @@ export const VideoUnmute: Story = {
|
||||
selectedOption: "2",
|
||||
},
|
||||
};
|
||||
|
||||
export const SpeakerAndMicrophoneSections: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
outputOptions: [
|
||||
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "default",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const headset = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Headset",
|
||||
});
|
||||
await userEvent.click(headset);
|
||||
await expect(args.onSelectOutput).toHaveBeenCalledWith("spk2");
|
||||
|
||||
// A few devices fit, so the list doesn't scroll.
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
await expect(list.scrollHeight).toBe(list.clientHeight);
|
||||
|
||||
// Each section is headed by its own edge-to-edge rule, with no separator.
|
||||
const menu = document.body.querySelector("[role='menu']")!;
|
||||
await expect(
|
||||
document.body.querySelectorAll("[role='separator']"),
|
||||
).toHaveLength(0);
|
||||
const headings = document.body.querySelectorAll<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
);
|
||||
await expect(headings).toHaveLength(2);
|
||||
const frame = menu.getBoundingClientRect();
|
||||
for (const heading of headings) {
|
||||
const rule = heading.querySelector("h3")!;
|
||||
await expect(
|
||||
Number.parseFloat(getComputedStyle(rule).borderBottomWidth),
|
||||
).toBeGreaterThan(0);
|
||||
// Edge to edge, within the frame.
|
||||
const box = rule.getBoundingClientRect();
|
||||
await expect(box.left - frame.left).toBeLessThanOrEqual(2);
|
||||
await expect(frame.right - box.right).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
// The first device sits further below the rule than from the menu's edge.
|
||||
// Asserted as a relationship, not pixels: the asymmetry is the design.
|
||||
const control = document.body.querySelector("input[type='radio']")!;
|
||||
const ruleBottom = headings[0]
|
||||
.querySelector("h3")!
|
||||
.getBoundingClientRect().bottom;
|
||||
const box = control.getBoundingClientRect();
|
||||
await expect(box.top - ruleBottom).toBeGreaterThan(box.left - frame.left);
|
||||
|
||||
// And a section stands further from the one above than from its own first
|
||||
// device.
|
||||
const groups = document.body.querySelectorAll("[role='group']");
|
||||
const speakers = groups[0].querySelectorAll("input[type='radio']");
|
||||
const lastSpeaker = speakers[speakers.length - 1].getBoundingClientRect();
|
||||
const nextHeading = groups[1]!.querySelector("h3")!.getBoundingClientRect();
|
||||
await expect(nextHeading.top - lastSpeaker.bottom).toBeGreaterThan(
|
||||
box.top - ruleBottom,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OutputCannotBeChosen: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
outputOptions: [
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "spk1",
|
||||
// No callback: the speakers are listed, but none can be picked.
|
||||
onSelectOutput: undefined,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const speakers = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Speakers",
|
||||
});
|
||||
await expect(speakers).toHaveAttribute("aria-disabled", "true");
|
||||
},
|
||||
};
|
||||
|
||||
export const OnlyOneDevice: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }],
|
||||
selectedOption: "mic1",
|
||||
outputOptions: [{ label: { type: "name", name: "Speakers" }, id: "spk1" }],
|
||||
selectedOutputOption: "spk1",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
// Shown, disabled, rather than hidden.
|
||||
const only = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Microphone 1",
|
||||
});
|
||||
await expect(only).toHaveAttribute("aria-disabled", "true");
|
||||
},
|
||||
};
|
||||
|
||||
/** A requested device hasn't arrived: nothing in either section can be picked. */
|
||||
export const SelectionSettling: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
outputOptions: [
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
],
|
||||
selectedOutputOption: "spk1",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
|
||||
// selectedOption never changes, so the request stays in flight.
|
||||
await userEvent.click(
|
||||
await menu.findByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
);
|
||||
|
||||
await expect(await menu.findByLabelText("Activating…")).toBeVisible();
|
||||
for (const item of menu.getAllByRole("menuitemradio"))
|
||||
await expect(item).toHaveAttribute("aria-disabled", "true");
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The ring shows for keyboard focus only. Asserted on the painted outline:
|
||||
* asserting data-focus-source would still pass with the CSS rule deleted.
|
||||
*/
|
||||
export const KeyboardFocusRing: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
const first = await menu.findByRole("menuitemradio", {
|
||||
name: "Microphone 1",
|
||||
});
|
||||
|
||||
// Opened by pointer: no ring, even though Radix has moved focus.
|
||||
await expect(outlineWidth(first)).toBe(0);
|
||||
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
const focused = document.activeElement as HTMLElement;
|
||||
await expect(focused).toHaveRole("menuitemradio");
|
||||
await expect(outlineWidth(focused)).toBeGreaterThan(0);
|
||||
|
||||
// And the pointer takes it away again.
|
||||
await userEvent.hover(first);
|
||||
await expect(outlineWidth(document.activeElement as HTMLElement)).toBe(0);
|
||||
},
|
||||
};
|
||||
|
||||
/** The list scrolls; the meter stays at the foot of the microphone section. */
|
||||
export const ManyDevices: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: Array.from({ length: 20 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Microphone ${i + 1}` },
|
||||
id: `mic${i + 1}`,
|
||||
})),
|
||||
selectedOption: "mic1",
|
||||
outputOptions: Array.from({ length: 6 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Speaker ${i + 1}` },
|
||||
id: `spk${i + 1}`,
|
||||
})),
|
||||
selectedOutputOption: "spk1",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
|
||||
// The sticky wrapper rather than the meter: without a fake microphone (as on
|
||||
// WebKit) the meter shows a message instead of a level.
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const sticky = await waitFor(() => {
|
||||
const element = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.stickyMeter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
});
|
||||
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
|
||||
|
||||
// Scrolled so the microphone section starts at the top: only there does a
|
||||
// pinned meter differ from one that is simply last.
|
||||
const group = await menu.findByRole("group", { name: "Microphone" });
|
||||
list.scrollTop +=
|
||||
group.getBoundingClientRect().top - list.getBoundingClientRect().top;
|
||||
await expect(list.scrollTop + list.clientHeight).toBeLessThan(
|
||||
list.scrollHeight,
|
||||
);
|
||||
|
||||
const scrollport = list.getBoundingClientRect();
|
||||
const pinned = sticky.getBoundingClientRect();
|
||||
await expect(pinned.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
|
||||
await expect(pinned.top).toBeGreaterThanOrEqual(scrollport.top - 1);
|
||||
|
||||
// The whole menu is on screen, not bounded by the document.
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(frame.top).toBeGreaterThanOrEqual(0);
|
||||
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
|
||||
|
||||
// The meter is the menu's one opaque part, so it must stay inside the frame.
|
||||
await expect(pinned.left).toBeGreaterThan(frame.left);
|
||||
await expect(pinned.right).toBeLessThan(frame.right);
|
||||
},
|
||||
};
|
||||
|
||||
/** Painted outline width, in px. */
|
||||
function outlineWidth(element: HTMLElement): number {
|
||||
const { outlineStyle, outlineWidth } = getComputedStyle(element);
|
||||
if (outlineStyle === "none") return 0;
|
||||
return Number.parseFloat(outlineWidth) || 0;
|
||||
}
|
||||
|
||||
/** Safari lists no outputs: a disabled, selected Default stands in. */
|
||||
export const OutputNotEnumerated: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
outputOptions: [],
|
||||
selectedOutputOption: undefined,
|
||||
onSelectOutput: undefined,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const speakers = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Default",
|
||||
});
|
||||
await expect(speakers).toHaveAttribute("aria-disabled", "true");
|
||||
},
|
||||
};
|
||||
|
||||
/** The meter's icon shares the radio controls' centre line; only a real browser lays this out. */
|
||||
export const MeterAlignsWithTheDeviceRows: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
],
|
||||
selectedOption: "mic1",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const menu = document.body.querySelector("[role='menu']")!;
|
||||
const radio = menu.querySelector("input[type='radio']")!;
|
||||
const icon = await waitFor(() => {
|
||||
const element = document.body.querySelector(`.${meterStyles.icon}`);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
});
|
||||
|
||||
// A pixel of slack, for subpixel layout.
|
||||
await expect(Math.abs(centre(icon) - centre(radio))).toBeLessThanOrEqual(1);
|
||||
},
|
||||
};
|
||||
|
||||
/** Inline-axis centre of an element. */
|
||||
function centre(element: Element): number {
|
||||
const box = element.getBoundingClientRect();
|
||||
return box.left + box.width / 2;
|
||||
}
|
||||
|
||||
/** Every row the keyboard reaches is fully visible, not under a heading or the meter. */
|
||||
export const KeyboardReachesEveryDevice: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
// Enough to scroll both ways, so either end can hide a row.
|
||||
options: Array.from({ length: 20 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Microphone ${i + 1}` },
|
||||
id: `mic${i + 1}`,
|
||||
})),
|
||||
selectedOption: "mic1",
|
||||
outputOptions: Array.from({ length: 4 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Speaker ${i + 1}` },
|
||||
id: `spk${i + 1}`,
|
||||
})),
|
||||
selectedOutputOption: "spk1",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const meter = await waitFor(() => {
|
||||
const element = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.stickyMeter}`,
|
||||
);
|
||||
if (element === null) throw new Error("the meter has not rendered yet");
|
||||
return element;
|
||||
});
|
||||
|
||||
// Drawn over the list: the sticky headings and the meter.
|
||||
const overlays = [
|
||||
meter,
|
||||
...document.body.querySelectorAll<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
),
|
||||
];
|
||||
const items = within(document.body).getAllByRole("menuitemradio");
|
||||
|
||||
// Down to the last device and back up.
|
||||
for (const key of ["{ArrowDown}", "{ArrowUp}"])
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await userEvent.keyboard(key);
|
||||
const focused = document.activeElement as HTMLElement;
|
||||
await expect(focused).toHaveRole("menuitemradio");
|
||||
// Checked as overlap: a heading only covers rows while its section is on
|
||||
// screen.
|
||||
await expect(overlapping(focused, overlays)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** A section's heading stays at the top while the section is in view, and leaves with it. */
|
||||
export const HeadingsStayWhileScrolling: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
iconsAndLabels: "audio",
|
||||
enabled: true,
|
||||
options: Array.from({ length: 20 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Microphone ${i + 1}` },
|
||||
id: `mic${i + 1}`,
|
||||
})),
|
||||
selectedOption: "mic1",
|
||||
outputOptions: Array.from({ length: 4 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Speaker ${i + 1}` },
|
||||
id: `spk${i + 1}`,
|
||||
})),
|
||||
selectedOutputOption: "spk1",
|
||||
onSelectOutput: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||
const menu = within(document.body);
|
||||
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const group = await menu.findByRole("group", { name: "Microphone" });
|
||||
// By class: the heading is aria-hidden, and its group carries the name.
|
||||
const heading = group.querySelector<HTMLElement>(
|
||||
`.${styles.sectionHeading}`,
|
||||
)!;
|
||||
|
||||
// Far enough that the heading is only on screen if it is stuck there.
|
||||
list.scrollTop +=
|
||||
group.getBoundingClientRect().top - list.getBoundingClientRect().top + 80;
|
||||
|
||||
const scrollport = list.getBoundingClientRect();
|
||||
await expect(heading.getBoundingClientRect().top).toBeLessThanOrEqual(
|
||||
scrollport.top + 2,
|
||||
);
|
||||
await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan(
|
||||
scrollport.top,
|
||||
);
|
||||
// Clear of the menu's frame.
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(heading.getBoundingClientRect().left).toBeGreaterThan(
|
||||
frame.left,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** How far the most overlapping of `overlays` covers `element`, in px. */
|
||||
function overlapping(element: Element, overlays: Element[]): number {
|
||||
const box = element.getBoundingClientRect();
|
||||
return overlays.reduce((worst, overlay) => {
|
||||
const over = overlay.getBoundingClientRect();
|
||||
const shared =
|
||||
Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
|
||||
return Math.max(worst, shared);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** The blur toggle gets the keyboard ring too: it is the menu's child, not the list's. */
|
||||
export const FocusRingCoversTheBlurToggle: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
iconsAndLabels: "video",
|
||||
videoBlurEnabled: false,
|
||||
videoBlurToggleClick: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const toggle = await within(document.body).findByRole("menuitemcheckbox", {
|
||||
name: /Blur background/,
|
||||
});
|
||||
|
||||
// Arrow down past the cameras to the toggle.
|
||||
for (let i = 0; i < 6 && document.activeElement !== toggle; i++)
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await expect(document.activeElement).toBe(toggle);
|
||||
// The same ring the device rows get.
|
||||
await expect(outlineWidth(toggle)).toBeGreaterThan(0);
|
||||
|
||||
// And the pointer takes it away, with the toggle still focused.
|
||||
await userEvent.hover(toggle);
|
||||
await expect(document.activeElement).toBe(toggle);
|
||||
await expect(outlineWidth(toggle)).toBe(0);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,40 +5,61 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, render, screen, type RenderResult } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { axe } from "vitest-axe";
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
type RenderResult,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type JSX, useState, type ReactNode } from "react";
|
||||
import { Profiler, type JSX, useState, type ReactNode } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
||||
|
||||
interface RenderOptions {
|
||||
requestDeviceNames: () => void;
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
function withProviders(
|
||||
component: ReactNode,
|
||||
{ requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {},
|
||||
): RenderResult {
|
||||
return render(
|
||||
): JSX.Element {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext
|
||||
value={{ requestDeviceNames } as unknown as MediaDevices}
|
||||
>
|
||||
{component}
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>,
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
component: ReactNode,
|
||||
options: Partial<RenderOptions> = {},
|
||||
): RenderResult {
|
||||
return render(withProviders(component, options));
|
||||
}
|
||||
|
||||
describe("MediaMuteAndSwitchButton", () => {
|
||||
// Only one test stubs the capture; don't let it leak into the rest.
|
||||
afterEach(restoreAudioCapture);
|
||||
|
||||
test("renders", () => {
|
||||
const { container } = renderComponent(
|
||||
<TooltipProvider>
|
||||
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
|
||||
<MediaMuteAndSwitchButton iconsAndLabels={"audio"} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
@@ -50,11 +71,7 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
enabled: boolean,
|
||||
): RenderResult => {
|
||||
return renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
iconsAndLabels={type}
|
||||
enabled={enabled}
|
||||
/>,
|
||||
<MediaMuteAndSwitchButton iconsAndLabels={type} enabled={enabled} />,
|
||||
);
|
||||
};
|
||||
const renderAudioEndabled = renderLabels("audio", true);
|
||||
@@ -81,7 +98,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
@@ -98,7 +114,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
@@ -119,7 +134,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onMute = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title={"Switcher"}
|
||||
onMuteClick={onMute}
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
@@ -139,11 +153,7 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const user = userEvent.setup();
|
||||
const requestDeviceNames = vi.fn();
|
||||
renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled
|
||||
/>,
|
||||
<MediaMuteAndSwitchButton iconsAndLabels="audio" enabled />,
|
||||
{ requestDeviceNames },
|
||||
);
|
||||
|
||||
@@ -157,7 +167,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
renderComponent(
|
||||
<>
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled
|
||||
options={[
|
||||
@@ -167,7 +176,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
selectedOption="mic1"
|
||||
/>
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="video"
|
||||
enabled
|
||||
options={[
|
||||
@@ -193,7 +201,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
@@ -217,7 +224,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
@@ -246,7 +252,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const [selectedOption, setSelectedOption] = useState("mic1");
|
||||
return (
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
@@ -306,7 +311,6 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const onVideoBlurToggle = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
videoBlurToggleClick={onVideoBlurToggle}
|
||||
@@ -327,11 +331,10 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
expect(onVideoBlurToggle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders check icon to mark the selected menu item", async () => {
|
||||
test("marks the selected menu item as checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
title="Switcher"
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
@@ -342,19 +345,493 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// open menu
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
|
||||
const mic1Item = screen.getByRole("menuitemradio", {
|
||||
name: "Microphone 2",
|
||||
});
|
||||
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: true });
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false });
|
||||
});
|
||||
|
||||
// The unselected item (mic1) only renders its IconOptions SVG
|
||||
const mic2Item = screen.getByRole("menuitemradio", {
|
||||
test("disables every device while a selection is settling", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
function Wrapper(): JSX.Element {
|
||||
const [selectedOption, setSelectedOption] = useState("mic1");
|
||||
return (
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption={selectedOption}
|
||||
onSelect={(id) => {
|
||||
void promise.then(() => setSelectedOption(id));
|
||||
}}
|
||||
outputOptions={[
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="spk1"
|
||||
onSelectOutput={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByRole } = renderComponent(<Wrapper />);
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
);
|
||||
|
||||
// In flight: nothing else can be picked, in either section.
|
||||
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
|
||||
expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
resolve();
|
||||
await promise;
|
||||
});
|
||||
|
||||
// Settled: selectable again.
|
||||
expect(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
|
||||
).not.toHaveAttribute("aria-disabled", "true");
|
||||
expect(
|
||||
screen.getByRole("menuitemradio", { name: "Headset" }),
|
||||
).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
test("lets go of a device switch that never arrives", async () => {
|
||||
const user = userEvent.setup();
|
||||
// onSelect never reports back, as when a device is removed mid-switch.
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={vi.fn()}
|
||||
outputOptions={[
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="spk1"
|
||||
onSelectOutput={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
);
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
|
||||
expect(screen.getByRole("menuitemradio", { name })).not.toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("lets go of a device switch whose device is unplugged", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mics: MenuOptions[] = [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
];
|
||||
const menu = (options: MenuOptions[]): JSX.Element => (
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={options}
|
||||
selectedOption="mic1"
|
||||
onSelect={vi.fn()}
|
||||
outputOptions={[
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="spk1"
|
||||
onSelectOutput={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const { getByRole, rerender } = renderComponent(menu(mics));
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
);
|
||||
|
||||
// The second microphone is unplugged before the switch lands.
|
||||
rerender(withProviders(menu(mics.slice(0, 1))));
|
||||
|
||||
// Selectable again without closing the menu.
|
||||
expect(
|
||||
screen.getByRole("menuitemradio", { name: "Headset" }),
|
||||
).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
test("moves the level without re-rendering anything", async () => {
|
||||
// The level is drawn into the DOM, so a moving level commits nothing.
|
||||
const capture = stubAudioCapture();
|
||||
const user = userEvent.setup();
|
||||
let commits = 0;
|
||||
|
||||
const { getByRole } = renderComponent(
|
||||
<Profiler
|
||||
id="menu"
|
||||
onRender={(): void => {
|
||||
commits++;
|
||||
}}
|
||||
>
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={vi.fn()}
|
||||
outputOptions={[
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="spk1"
|
||||
onSelectOutput={vi.fn()}
|
||||
/>
|
||||
</Profiler>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
capture.grant();
|
||||
await vi.waitFor(() => expect(capture.contexts).toHaveLength(1));
|
||||
const meter = await screen.findByRole("meter");
|
||||
// The capture's arrival is one render: the idle level swapped for its own.
|
||||
await act(async () => {});
|
||||
const settled = commits;
|
||||
|
||||
// The meter smooths by elapsed time, so hand-driven frames need a clock.
|
||||
let elapsed = performance.now();
|
||||
const clock = vi
|
||||
.spyOn(performance, "now")
|
||||
.mockImplementation(() => (elapsed += 16));
|
||||
|
||||
// One frame per task, as a browser delivers them: one act() would batch them.
|
||||
for (let step = 1; step <= 8; step++) {
|
||||
capture.speak(step / 8);
|
||||
await act(async () => {
|
||||
capture.drawFrames(1);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
clock.mockRestore();
|
||||
|
||||
expect(meter.getAttribute("aria-valuenow")).not.toBe("0");
|
||||
expect(commits - settled).toBe(0);
|
||||
});
|
||||
|
||||
test("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "cam1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "cam2" },
|
||||
]}
|
||||
selectedOption="cam1"
|
||||
onSelect={vi.fn()}
|
||||
videoBlurToggleClick={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
|
||||
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
|
||||
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
|
||||
expect(
|
||||
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("marks focus as keyboard-driven only when the keyboard moved it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
const list = screen
|
||||
.getByRole("menuitemradio", { name: "Microphone 1" })
|
||||
.closest("[data-focus-source]");
|
||||
|
||||
// The menu focuses whatever the pointer is over, so focus alone says nothing.
|
||||
expect(list).toHaveAttribute("data-focus-source", "pointer");
|
||||
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(list).toHaveAttribute("data-focus-source", "keyboard");
|
||||
|
||||
await user.pointer({
|
||||
target: screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
coords: { clientX: 10, clientY: 10 },
|
||||
});
|
||||
expect(list).toHaveAttribute("data-focus-source", "pointer");
|
||||
});
|
||||
|
||||
test("marks the selected device with the accent fill", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic2"
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const selected = screen
|
||||
.getByRole("menuitemradio", { name: "Microphone 2" })
|
||||
.querySelector("input[type=radio]");
|
||||
expect(selected).toBeChecked();
|
||||
// readOnly would paint the selected radio muted.
|
||||
expect(selected).not.toHaveAttribute("readonly");
|
||||
});
|
||||
|
||||
test("the open menu has no accessibility violations", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={vi.fn()}
|
||||
outputOptions={[
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="spk1"
|
||||
onSelectOutput={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const menu = document.querySelector('[role="menu"]');
|
||||
expect(await axe(menu as HTMLElement)).toHaveNoViolations();
|
||||
});
|
||||
|
||||
test("puts the speaker section above the microphone section", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={vi.fn()}
|
||||
outputOptions={[
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="spk1"
|
||||
onSelectOutput={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const speakers = screen.getByRole("menuitemradio", { name: "Speakers" });
|
||||
const microphone = screen.getByRole("menuitemradio", {
|
||||
name: "Microphone 1",
|
||||
});
|
||||
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
|
||||
expect(
|
||||
speakers.compareDocumentPosition(microphone) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("lists speaker and microphone sections", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={vi.fn()}
|
||||
outputOptions={[
|
||||
{
|
||||
label: { type: "default", name: "Built-in Output" },
|
||||
id: "default",
|
||||
},
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="default"
|
||||
onSelectOutput={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
screen.getByRole("menuitemradio", {
|
||||
name: "Default (Built-in Output)",
|
||||
checked: true,
|
||||
});
|
||||
screen.getByRole("menuitemradio", { name: "Headset", checked: false });
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: true });
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: false });
|
||||
});
|
||||
|
||||
test("calls the output select callback on speaker click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectOutput = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
outputOptions={[
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="spk1"
|
||||
onSelectOutput={onSelectOutput}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitemradio", { name: "Headset" }));
|
||||
|
||||
expect(onSelectOutput).toHaveBeenCalledWith("spk2");
|
||||
});
|
||||
|
||||
test("shows a single device entry disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
// Shown, but not selectable.
|
||||
const only = screen.getByRole("menuitemradio", { name: "Microphone 1" });
|
||||
expect(only).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
test("shows a default speaker where the platform lists none", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={vi.fn()}
|
||||
// As Safari: no outputs listed.
|
||||
outputOptions={[]}
|
||||
selectedOutputOption={undefined}
|
||||
onSelectOutput={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
const speakers = screen
|
||||
.getAllByRole("group")
|
||||
.find((group) => group.getAttribute("aria-label") === "Speaker")!;
|
||||
const entries = within(speakers).getAllByRole("menuitemradio");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toHaveAccessibleName("Default");
|
||||
expect(entries[0]).toHaveAttribute("aria-disabled", "true");
|
||||
expect(entries[0]).toHaveAttribute("aria-checked", "true");
|
||||
expect(
|
||||
within(entries[0]).getByRole("radio", { hidden: true }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
test("shows the speaker section disabled when output selection is unsupported", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={true}
|
||||
options={[
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||
]}
|
||||
selectedOption="mic1"
|
||||
onSelect={vi.fn()}
|
||||
outputOptions={[
|
||||
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "spk2" },
|
||||
]}
|
||||
selectedOutputOption="spk1"
|
||||
onSelectOutput={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
expect(
|
||||
screen.getByRole("menuitemradio", { name: "Speakers" }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(
|
||||
screen.getByRole("menuitemradio", { name: "Headset" }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,37 +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 ComponentType, useState, type FC, useEffect } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
useEffect,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTitle,
|
||||
RadioInput,
|
||||
ToggleMenuItem,
|
||||
} from "@vector-im/compound-web";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
MicOnIcon,
|
||||
SpinnerIcon,
|
||||
VideoCallIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { distinctUntilChanged, map } from "rxjs";
|
||||
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
import { MicButton, VideoButton } from "../button";
|
||||
import { type DeviceLabel } from "../state/MediaDevices";
|
||||
import {
|
||||
type AudioOutputDeviceLabel,
|
||||
type DeviceLabel,
|
||||
} from "../state/MediaDevices";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useRootElement } from "../RootElementContext";
|
||||
import { observeElementSize$ } from "../utils/elementSize";
|
||||
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: DeviceLabel;
|
||||
label: DeviceLabel | AudioOutputDeviceLabel;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface MediaMuteAndSwitchButtonProps {
|
||||
/** The title used in the Switcher modal. */
|
||||
title: string;
|
||||
/** If the Mute button is enabled */
|
||||
enabled?: boolean;
|
||||
/** Callback if the mute button is clicked */
|
||||
@@ -47,6 +58,12 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
options?: MenuOptions[];
|
||||
/** The option that will currently be rendered as the selected option */
|
||||
selectedOption?: string;
|
||||
/** Output (speaker) devices. Audio menu only. */
|
||||
outputOptions?: MenuOptions[];
|
||||
/** The output option currently rendered as selected */
|
||||
selectedOutputOption?: string;
|
||||
/** Picks an output device. Undefined disables the speaker section. */
|
||||
onSelectOutput?: (id: string) => void;
|
||||
videoBlurToggleClick?: () => void;
|
||||
videoBlurEnabled?: boolean;
|
||||
/**
|
||||
@@ -58,90 +75,240 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
|
||||
const BLUR_ID = "blur";
|
||||
|
||||
/** Id of the placeholder "Default" row, shown when the platform lists no outputs. */
|
||||
const DEFAULT_OUTPUT_ID = "default";
|
||||
|
||||
/** Largest share of the call area's height the device list may take. */
|
||||
const LIST_SHARE_OF_CALL = 0.6;
|
||||
|
||||
/** Smallest device list height in px, so a short call still shows more than one device. */
|
||||
const MIN_LIST_HEIGHT = 160;
|
||||
|
||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
title,
|
||||
enabled,
|
||||
busy,
|
||||
onMuteClick,
|
||||
iconsAndLabels,
|
||||
options,
|
||||
selectedOption,
|
||||
outputOptions,
|
||||
selectedOutputOption,
|
||||
onSelectOutput,
|
||||
videoBlurEnabled,
|
||||
videoBlurToggleClick,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
|
||||
// Requested but not yet selected. Keyed by kind too, since Chrome uses
|
||||
// "default" for both an input and an output.
|
||||
const [plannedSelection, setPlannedSelection] = useState<{
|
||||
kind: "input" | "output";
|
||||
id: string;
|
||||
} | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const onOpenChange = useCallback((open: boolean): void => {
|
||||
setMenuOpen(open);
|
||||
// Drop a request that never arrived.
|
||||
if (!open) setPlannedSelection(null);
|
||||
}, []);
|
||||
const isBusy = busy ?? false;
|
||||
const { t } = useTranslation();
|
||||
const devices = useMediaDevices();
|
||||
|
||||
/**
|
||||
* Records on the menu whether the keyboard or the pointer moved focus, for
|
||||
* the focus ring. `:focus-visible` can't tell: Radix focuses whatever the
|
||||
* pointer is over. Listened for on the menu, not the document, so a second
|
||||
* Element Call on the page doesn't answer for this one.
|
||||
*/
|
||||
const trackFocusSource = useCallback(
|
||||
(list: HTMLDivElement | null): (() => void) | undefined => {
|
||||
const menu = list?.closest<HTMLElement>('[role="menu"]');
|
||||
if (menu === null || menu === undefined) return;
|
||||
const record = (source: "keyboard" | "pointer"): void => {
|
||||
menu.dataset.focusSource = source;
|
||||
};
|
||||
record("pointer");
|
||||
const usedKeyboard = (): void => record("keyboard");
|
||||
const usedPointer = (): void => record("pointer");
|
||||
menu.addEventListener("keydown", usedKeyboard, true);
|
||||
menu.addEventListener("pointermove", usedPointer, true);
|
||||
return (): void => {
|
||||
menu.removeEventListener("keydown", usedKeyboard, true);
|
||||
menu.removeEventListener("pointermove", usedPointer, true);
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Measured on the call area: CSS can't size the portalled menu against it.
|
||||
const rootElement = useRootElement();
|
||||
const [listMaxHeight, setListMaxHeight] = useState<number>();
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
// Followed, since a host can resize the call while the menu is open.
|
||||
const subscription = observeElementSize$(rootElement)
|
||||
.pipe(
|
||||
map(({ height }) =>
|
||||
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setListMaxHeight);
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [menuOpen, rootElement]);
|
||||
|
||||
// Kept clear at the list's foot, so a row reached by keyboard isn't under
|
||||
// the meter.
|
||||
const [meterHeight, meter] = useMeasuredHeight();
|
||||
|
||||
// Likewise at its head, for the sticky headings.
|
||||
const [headingHeight, heading] = useMeasuredHeight();
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
|
||||
}, [menuOpen, devices]);
|
||||
|
||||
let button;
|
||||
let toggles: { label: string; enabled: boolean; id: string }[] = [];
|
||||
switch (iconsAndLabels) {
|
||||
case "video":
|
||||
button = (
|
||||
<VideoButton
|
||||
enabled={enabled ?? false}
|
||||
busy={isBusy}
|
||||
onClick={(e) => {
|
||||
onMuteClick?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid="incall_videomute"
|
||||
/>
|
||||
);
|
||||
if (videoBlurToggleClick !== undefined) {
|
||||
toggles = [
|
||||
const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
|
||||
const button = (
|
||||
<MuteButton
|
||||
enabled={enabled ?? false}
|
||||
busy={isBusy}
|
||||
onClick={(e) => {
|
||||
onMuteClick?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid={
|
||||
iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute"
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggles =
|
||||
iconsAndLabels === "video" && videoBlurToggleClick !== undefined
|
||||
? [
|
||||
{
|
||||
label: t("action.blur_background"),
|
||||
enabled: videoBlurEnabled ?? false,
|
||||
id: BLUR_ID,
|
||||
},
|
||||
];
|
||||
}
|
||||
break;
|
||||
case "audio":
|
||||
button = (
|
||||
<MicButton
|
||||
enabled={enabled ?? false}
|
||||
busy={isBusy}
|
||||
onClick={(e) => {
|
||||
onMuteClick?.();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isBusy || onMuteClick === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
let IconOptions: ComponentType<React.SVGAttributes<SVGElement>> | undefined;
|
||||
let optionsButtonLabel: string;
|
||||
let menuTitle: string;
|
||||
let numberedLabel: (number: number) => string;
|
||||
switch (iconsAndLabels) {
|
||||
case "video":
|
||||
IconOptions = VideoCallIcon;
|
||||
optionsButtonLabel = t("settings.devices.camera");
|
||||
menuTitle = t("settings.devices.camera_source");
|
||||
numberedLabel = (n): string =>
|
||||
t("settings.devices.camera_numbered", { n });
|
||||
break;
|
||||
case "audio":
|
||||
IconOptions = MicOnIcon;
|
||||
optionsButtonLabel = t("settings.devices.microphone");
|
||||
menuTitle = t("settings.devices.mic_source");
|
||||
numberedLabel = (n): string =>
|
||||
t("settings.devices.microphone_numbered", { n });
|
||||
break;
|
||||
}
|
||||
|
||||
const labelText = (
|
||||
label: MenuOptions["label"],
|
||||
numbered: (n: number) => string,
|
||||
): string => {
|
||||
switch (label.type) {
|
||||
case "name":
|
||||
return label.name;
|
||||
case "number":
|
||||
return numbered(label.number);
|
||||
case "default":
|
||||
return label.name === null
|
||||
? t("settings.devices.default")
|
||||
: t("settings.devices.default_named_plain", { name: label.name });
|
||||
case "speaker":
|
||||
return t("settings.devices.loudspeaker");
|
||||
case "earpiece":
|
||||
return t("settings.devices.handset");
|
||||
}
|
||||
};
|
||||
|
||||
// Nothing can be picked while a requested device is on offer but not yet
|
||||
// selected, so a second request can't overtake it.
|
||||
const plannedOutput = plannedSelection?.kind === "output";
|
||||
const selectedOfPlannedKind = plannedOutput
|
||||
? selectedOutputOption
|
||||
: selectedOption;
|
||||
const offeredOfPlannedKind = plannedOutput ? outputOptions : options;
|
||||
const settling =
|
||||
plannedSelection !== null &&
|
||||
plannedSelection.id !== selectedOfPlannedKind &&
|
||||
offeredOfPlannedKind?.some(({ id }) => id === plannedSelection.id) === true;
|
||||
|
||||
// Safari lists no outputs: show a disabled, selected Default rather than an
|
||||
// empty section.
|
||||
const noOutputsListed = outputOptions?.length === 0;
|
||||
const speakerOptions: MenuOptions[] | undefined = noOutputsListed
|
||||
? [{ id: DEFAULT_OUTPUT_ID, label: { type: "default", name: null } }]
|
||||
: outputOptions;
|
||||
const selectedSpeaker = noOutputsListed
|
||||
? DEFAULT_OUTPUT_ID
|
||||
: selectedOutputOption;
|
||||
|
||||
const deviceItems = (
|
||||
kind: "input" | "output",
|
||||
items: MenuOptions[] | undefined,
|
||||
selected: string | undefined,
|
||||
select: ((id: string) => void) | undefined,
|
||||
numbered: (n: number) => string,
|
||||
): ReactElement[] => {
|
||||
const list = items ?? [];
|
||||
// Disabled rather than hidden, so the menu keeps its shape.
|
||||
const disabled = select === undefined || list.length <= 1 || settling;
|
||||
return list.map(({ label, id }) => (
|
||||
<MenuItem
|
||||
// A radio input may not sit inside a button.
|
||||
as="div"
|
||||
hideChevron
|
||||
disabled={disabled}
|
||||
label={labelText(label, numbered)}
|
||||
Icon={
|
||||
// Inert, not aria-hidden: aria-hidden alone leaves it focusable.
|
||||
<span inert>
|
||||
<RadioInput
|
||||
checked={selected === id}
|
||||
disabled={disabled}
|
||||
// Not readOnly, which mutes the selected fill. The item handles
|
||||
// the click.
|
||||
onChange={(): void => {}}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
if (id === selected) return;
|
||||
setPlannedSelection({ kind, id });
|
||||
select?.(id);
|
||||
}}
|
||||
key={id}
|
||||
role="menuitemradio"
|
||||
aria-checked={selected === id}
|
||||
>
|
||||
{selected !== id &&
|
||||
plannedSelection?.kind === kind &&
|
||||
plannedSelection.id === id && (
|
||||
<SpinnerIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.rotate}
|
||||
aria-label={t("settings.devices.activating")}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
@@ -152,10 +319,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
{/* The mute button lives inside */}
|
||||
{button}
|
||||
<Menu
|
||||
title={title}
|
||||
showTitle={true}
|
||||
className={styles.menu}
|
||||
// Named for screen readers only: each section has its own heading.
|
||||
title={menuTitle}
|
||||
showTitle={false}
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
side="top"
|
||||
trigger={
|
||||
<Button
|
||||
@@ -171,67 +340,74 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
{options?.map(({ label, id }) => {
|
||||
let labelText: string;
|
||||
switch (label.type) {
|
||||
case "name":
|
||||
labelText = label.name;
|
||||
break;
|
||||
case "number":
|
||||
labelText = numberedLabel(label.number);
|
||||
break;
|
||||
<div
|
||||
ref={trackFocusSource}
|
||||
// Keeps the items the menu's own children for assistive tech.
|
||||
role="none"
|
||||
className={styles.deviceList}
|
||||
style={
|
||||
{
|
||||
"--device-list-max-height":
|
||||
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
|
||||
"--device-list-scroll-padding-end":
|
||||
meterHeight === undefined ? undefined : `${meterHeight}px`,
|
||||
"--device-list-scroll-padding-start":
|
||||
headingHeight === undefined ? undefined : `${headingHeight}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
hideChevron
|
||||
label={labelText}
|
||||
Icon={
|
||||
IconOptions && (
|
||||
<IconOptions
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.itemIcon}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
if (id === selectedOption) return;
|
||||
setPlannedSelection(id);
|
||||
onSelect?.(id);
|
||||
}}
|
||||
key={id}
|
||||
role="menuitemradio"
|
||||
aria-checked={selectedOption === id}
|
||||
>
|
||||
{selectedOption === id && (
|
||||
<CheckIcon
|
||||
width={24}
|
||||
height={24}
|
||||
aria-hidden // A label would be redundant to aria-checked above
|
||||
>
|
||||
{iconsAndLabels === "audio" && speakerOptions && (
|
||||
<>
|
||||
{/* A menu may only contain items, separators and groups, so each
|
||||
heading is a hidden part of a named group. */}
|
||||
<div role="group" aria-label={t("settings.devices.speaker")}>
|
||||
<div aria-hidden className={styles.sectionHeading}>
|
||||
<MenuTitle title={t("settings.devices.speaker")} />
|
||||
</div>
|
||||
{deviceItems(
|
||||
"output",
|
||||
speakerOptions,
|
||||
selectedSpeaker,
|
||||
onSelectOutput,
|
||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div role="group" aria-label={optionsButtonLabel}>
|
||||
<div ref={heading} aria-hidden className={styles.sectionHeading}>
|
||||
<MenuTitle title={optionsButtonLabel} />
|
||||
</div>
|
||||
{/* Apart from the heading, so the sticky meter can't ride over it. */}
|
||||
<div role="none">
|
||||
{deviceItems(
|
||||
"input",
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
numberedLabel,
|
||||
)}
|
||||
{iconsAndLabels === "audio" && (
|
||||
<LiveMicrophoneLevelMeter
|
||||
ref={meter}
|
||||
deviceId={selectedOption}
|
||||
// Only while open, so the microphone isn't held all call.
|
||||
active={menuOpen}
|
||||
className={styles.stickyMeter}
|
||||
/>
|
||||
)}
|
||||
{selectedOption !== id && plannedSelection === id && (
|
||||
<SpinnerIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.rotate}
|
||||
aria-label={t("settings.devices.activating")}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
{(toggles?.length ?? 0) > 0 && <hr />}
|
||||
{toggles?.map((toggle) => (
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{toggles.length > 0 && <hr />}
|
||||
{toggles.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
label={toggle.label}
|
||||
onSelect={(e) => {
|
||||
videoBlurToggleClick?.();
|
||||
e.preventDefault();
|
||||
}}
|
||||
checked={toggle.enabled ?? false}
|
||||
checked={toggle.enabled}
|
||||
key={toggle.id}
|
||||
/>
|
||||
))}
|
||||
@@ -239,3 +415,25 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Follows an element's height. */
|
||||
function useMeasuredHeight(): [
|
||||
number | undefined,
|
||||
(element: HTMLElement | null) => (() => void) | undefined,
|
||||
] {
|
||||
const [height, setHeight] = useState<number>();
|
||||
const ref = useCallback(
|
||||
(element: HTMLElement | null): (() => void) | undefined => {
|
||||
if (element === null) return;
|
||||
const subscription = observeElementSize$(element)
|
||||
.pipe(
|
||||
map((size) => size.height),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setHeight);
|
||||
return (): void => subscription.unsubscribe();
|
||||
},
|
||||
[],
|
||||
);
|
||||
return [height, ref];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.meter {
|
||||
/* A device row's icon column, so the bars start where the names do. */
|
||||
--meter-icon-size: 24px;
|
||||
|
||||
/* Compound's radio size, whose centre line the icon shares. */
|
||||
--device-control-size: 20px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-3x);
|
||||
padding-block: var(--cpd-space-2x);
|
||||
/* Centres the icon on the radio column: less the row's inset border and half
|
||||
the icon's extra width. Checked in the MeterAlignsWithTheDeviceRows story. */
|
||||
padding-inline-start: calc(
|
||||
var(--cpd-space-4x) - var(--cpd-border-width-1) -
|
||||
(var(--meter-icon-size) - var(--device-control-size)) / 2
|
||||
);
|
||||
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
flex-shrink: 0;
|
||||
inline-size: var(--meter-icon-size);
|
||||
block-size: var(--meter-icon-size);
|
||||
}
|
||||
|
||||
.segments {
|
||||
/* Bars keep one size and the width decides how many; the component reads the
|
||||
sizes back from here. */
|
||||
flex: 1;
|
||||
/* So the bars never set the menu's width. */
|
||||
contain: inline-size;
|
||||
min-inline-size: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Half again a bar's width, per the design. */
|
||||
gap: var(--cpd-space-1-5x);
|
||||
}
|
||||
|
||||
.segment {
|
||||
flex: none;
|
||||
inline-size: var(--cpd-space-1x);
|
||||
block-size: var(--cpd-space-4x);
|
||||
border-radius: var(--cpd-space-1x);
|
||||
background: var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
|
||||
/* The count of lit bars carries the level as well as their colour. */
|
||||
.segmentLit {
|
||||
background: var(--cpd-color-bg-accent-rest);
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
/* A paragraph's margin would lift the text off centre. */
|
||||
margin-block: 0;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
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 { expect, within } from "storybook/test";
|
||||
import { type JSX } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
MicrophoneLevelMeter,
|
||||
type MicrophoneLevelMeterProps,
|
||||
} from "./MicrophoneLevelMeter";
|
||||
import styles from "./MicrophoneLevelMeter.module.css";
|
||||
import { LEVEL_SCALE } from "../state/MicrophoneLevel";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
/** Roughly the menu's width. It only decides how many bars fit. */
|
||||
const STORY_WIDTH = 256;
|
||||
|
||||
const meta = {
|
||||
component: MicrophoneLevelMeter,
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<div style={{ inlineSize: STORY_WIDTH }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
argTypes: {
|
||||
state: {
|
||||
description:
|
||||
"What the selected microphone can say about itself: a level, or a reason there is none.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof MicrophoneLevelMeter>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** A quiet room: hiss below the noise floor lights nothing. */
|
||||
export const Silent: Story = {
|
||||
args: { state: { type: "level", level$: constant(0) } },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(litSegments(canvasElement)).toBe(0);
|
||||
},
|
||||
};
|
||||
|
||||
export const QuietSpeech: Story = {
|
||||
args: { state: { type: "level", level$: constant(5) } },
|
||||
};
|
||||
|
||||
export const NormalSpeech: Story = {
|
||||
args: { state: { type: "level", level$: constant(12) } },
|
||||
play: async ({ canvasElement }) => {
|
||||
// A floor, not a count: the count follows from the design's bar and gap sizes.
|
||||
await expect(
|
||||
canvasElement.getElementsByClassName(styles.segment).length,
|
||||
).toBeGreaterThanOrEqual(15);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoudSpeech: Story = {
|
||||
args: { state: { type: "level", level$: constant(LEVEL_SCALE) } },
|
||||
};
|
||||
|
||||
/** The three volumes differ in how many bars are lit, not only in colour. */
|
||||
export const VolumesAreDistinguishable: Story = {
|
||||
args: { state: { type: "level", level$: constant(5) } },
|
||||
play: async ({ canvasElement, mount }) => {
|
||||
const lit: number[] = [];
|
||||
for (const level of [5, 12, LEVEL_SCALE]) {
|
||||
await mount(
|
||||
<MicrophoneLevelMeter
|
||||
state={{ type: "level", level$: constant(level) }}
|
||||
/>,
|
||||
);
|
||||
lit.push(litSegments(canvasElement));
|
||||
await expect(within(canvasElement).getByRole("meter")).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
String(level),
|
||||
);
|
||||
}
|
||||
await expect(new Set(lit).size).toBe(lit.length);
|
||||
await expect(lit).toEqual([...lit].sort((a, b) => a - b));
|
||||
},
|
||||
};
|
||||
|
||||
/** Permission refused: a message with a next action, not a still meter. */
|
||||
export const PermissionDenied: Story = {
|
||||
args: { state: { type: "permission-denied" } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByRole("meter")).toBeNull();
|
||||
await expect(
|
||||
canvas.getByText(/Microphone access is blocked/),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** No input device, told apart from a refusal. */
|
||||
export const NoDevice: Story = {
|
||||
args: { state: { type: "no-device" } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByRole("meter")).toBeNull();
|
||||
await expect(canvas.getByText(/No microphone found/)).toBeVisible();
|
||||
|
||||
// The icon sits on the middle of the text, however many lines it runs to.
|
||||
const middle = (element: Element): number => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return box.top + box.height / 2;
|
||||
};
|
||||
const icon = canvasElement.getElementsByClassName(styles.icon)[0];
|
||||
const words = canvasElement.getElementsByClassName(styles.message)[0];
|
||||
await expect(Math.abs(middle(icon) - middle(words))).toBeLessThanOrEqual(1);
|
||||
},
|
||||
};
|
||||
|
||||
/** The same meter at two widths: the bars keep their size and only their count changes. */
|
||||
export const ShapeStaysTheSameAtAnyWidth: Story = {
|
||||
args: { state: { type: "level", level$: constant(12) } },
|
||||
play: async ({ mount, args }) => {
|
||||
const narrow = await measureAt(mount, args, 180);
|
||||
const wide = await measureAt(mount, args, 400);
|
||||
|
||||
await expect(narrow.bar).toBe(wide.bar);
|
||||
await expect(narrow.gap).toBe(wide.gap);
|
||||
await expect(narrow.count).toBeLessThan(wide.count);
|
||||
// The bars are still separate, not one run of colour.
|
||||
await expect(narrow.gap).toBeGreaterThan(0);
|
||||
},
|
||||
};
|
||||
|
||||
/** How many bars are lit. */
|
||||
function litSegments(canvasElement: HTMLElement): number {
|
||||
return canvasElement.getElementsByClassName(styles.segmentLit).length;
|
||||
}
|
||||
|
||||
/** Renders the meter at one width and reports its bars' shape. */
|
||||
async function measureAt(
|
||||
mount: (ui: JSX.Element) => Promise<unknown>,
|
||||
args: MicrophoneLevelMeterProps,
|
||||
width: number,
|
||||
): Promise<{ count: number; bar: number; gap: number }> {
|
||||
await mount(
|
||||
<div style={{ inlineSize: width }}>
|
||||
<MicrophoneLevelMeter {...args} />
|
||||
</div>,
|
||||
);
|
||||
const bars = Array.from(document.body.getElementsByClassName(styles.segment));
|
||||
const first = bars[0].getBoundingClientRect();
|
||||
const second = bars[1].getBoundingClientRect();
|
||||
return {
|
||||
count: bars.length,
|
||||
bar: first.width,
|
||||
gap: second.left - first.right,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
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, test } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||
import { LEVEL_SCALE } from "../state/MicrophoneLevel";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
describe("MicrophoneLevelMeter", () => {
|
||||
test("announces the level rather than relying on hue", () => {
|
||||
render(
|
||||
<MicrophoneLevelMeter state={{ type: "level", level$: constant(6) }} />,
|
||||
);
|
||||
|
||||
const meter = screen.getByRole("meter", { name: "Microphone level" });
|
||||
expect(meter).toHaveAttribute("aria-valuenow", "6");
|
||||
expect(meter).toHaveAttribute("aria-valuemax", String(LEVEL_SCALE));
|
||||
expect(meter).toHaveAttribute("aria-valuetext", `6 of ${LEVEL_SCALE}`);
|
||||
});
|
||||
|
||||
test("shows distinct messages for denied permission and no input device", () => {
|
||||
const denied = render(
|
||||
<MicrophoneLevelMeter state={{ type: "permission-denied" }} />,
|
||||
);
|
||||
expect(
|
||||
denied.getByText(/Microphone access is blocked/),
|
||||
).toBeInTheDocument();
|
||||
expect(denied.queryByRole("meter")).toBeNull();
|
||||
|
||||
const missing = render(
|
||||
<MicrophoneLevelMeter state={{ type: "no-device" }} />,
|
||||
);
|
||||
expect(missing.getByText(/No microphone found/)).toBeInTheDocument();
|
||||
expect(missing.queryByRole("meter")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
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 {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FC,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { distinctUntilChanged, map } from "rxjs";
|
||||
|
||||
import styles from "./MicrophoneLevelMeter.module.css";
|
||||
import { LEVEL_SCALE, type MicrophoneState } from "../state/MicrophoneLevel";
|
||||
import { observeElementSize$ } from "../utils/elementSize";
|
||||
import { useMicrophoneLevel } from "./useMicrophoneLevel";
|
||||
|
||||
export interface LiveMicrophoneLevelMeterProps {
|
||||
/** The microphone to listen to. */
|
||||
deviceId: string | undefined;
|
||||
/** Whether to hold a capture at all. */
|
||||
active: boolean;
|
||||
className?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The meter, wired to a live capture. Its own component so a changing level
|
||||
* re-renders the meter, not its parent.
|
||||
*/
|
||||
export const LiveMicrophoneLevelMeter: FC<LiveMicrophoneLevelMeterProps> = ({
|
||||
deviceId,
|
||||
active,
|
||||
className,
|
||||
ref,
|
||||
}) => {
|
||||
const state = useMicrophoneLevel(deviceId, active);
|
||||
return <MicrophoneLevelMeter state={state} className={className} ref={ref} />;
|
||||
};
|
||||
|
||||
export interface MicrophoneLevelMeterProps {
|
||||
state: MicrophoneState;
|
||||
className?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** The input level of a microphone, or why there is none. */
|
||||
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
||||
state,
|
||||
className,
|
||||
ref,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Bars keep one size, so their count follows the width. Starts full so the
|
||||
// first paint, and jsdom, draw a whole meter.
|
||||
const [barCount, setBarCount] = useState(LEVEL_SCALE);
|
||||
const segments = useRef<HTMLDivElement | null>(null);
|
||||
const track = useCallback(
|
||||
(element: HTMLDivElement | null): (() => void) | undefined => {
|
||||
if (element === null) return;
|
||||
segments.current = element;
|
||||
const subscription = observeElementSize$(element)
|
||||
.pipe(
|
||||
map(({ width }) => barsThatFit(element, width)),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setBarCount);
|
||||
return (): void => {
|
||||
subscription.unsubscribe();
|
||||
segments.current = null;
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Drawn straight into the DOM: the level changes many times a second, and
|
||||
// re-rendering for each change is what this avoids.
|
||||
useLayoutEffect(() => {
|
||||
const element = segments.current;
|
||||
if (state.type !== "level" || element === null) return;
|
||||
const subscription = state.level$.subscribe((level) => {
|
||||
element.setAttribute("aria-valuenow", String(level));
|
||||
element.setAttribute(
|
||||
"aria-valuetext",
|
||||
t("microphone_level.value", { level, max: LEVEL_SCALE }),
|
||||
);
|
||||
// The level is a share of the scale, not a bar count.
|
||||
const lit = Math.round((level / LEVEL_SCALE) * barCount);
|
||||
Array.from(element.children).forEach((bar, i) =>
|
||||
bar.classList.toggle(styles.segmentLit, i < lit),
|
||||
);
|
||||
});
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [state, barCount, t]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={classNames(styles.meter, className)}>
|
||||
<MicOnIcon className={styles.icon} aria-hidden />
|
||||
{state.type !== "level" ? (
|
||||
<Text size="sm" className={styles.message}>
|
||||
{state.type === "permission-denied"
|
||||
? t("microphone_level.permission_denied")
|
||||
: t("microphone_level.no_device")}
|
||||
</Text>
|
||||
) : (
|
||||
<div
|
||||
ref={track}
|
||||
className={styles.segments}
|
||||
role="meter"
|
||||
aria-label={t("microphone_level.label")}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={LEVEL_SCALE}
|
||||
// The first paint's value; the effect above keeps it current.
|
||||
aria-valuenow={state.level$.value}
|
||||
aria-valuetext={t("microphone_level.value", {
|
||||
level: state.level$.value,
|
||||
max: LEVEL_SCALE,
|
||||
})}
|
||||
>
|
||||
{Array.from({ length: barCount }, (_, i) => (
|
||||
<span key={i} aria-hidden className={styles.segment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** How many bars fit across `width`, measured so the stylesheet alone sets their size. */
|
||||
function barsThatFit(track: HTMLElement, width: number): number {
|
||||
const gap = Number.parseFloat(getComputedStyle(track).columnGap);
|
||||
const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0;
|
||||
// No layout (jsdom): keep the full count.
|
||||
if (!(bar > 0) || !(gap >= 0)) return LEVEL_SCALE;
|
||||
return Math.max(1, Math.floor((width + gap) / (bar + gap)));
|
||||
}
|
||||
@@ -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 { afterEach, describe, expect, test } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
import { useMicrophoneLevel } from "./useMicrophoneLevel";
|
||||
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
||||
|
||||
// Capture and release are covered in MicrophoneLevel.test.ts; this covers the bridge.
|
||||
describe("useMicrophoneLevel", () => {
|
||||
afterEach(restoreAudioCapture);
|
||||
|
||||
test("holds no capture while the meter is not shown", () => {
|
||||
const capture = stubAudioCapture();
|
||||
|
||||
renderHook(() => useMicrophoneLevel("mic1", false));
|
||||
|
||||
expect(capture.getUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("starts from nothing rather than the previous device's level", () => {
|
||||
const capture = stubAudioCapture();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ id }: { id: string }) => useMicrophoneLevel(id, true),
|
||||
{ initialProps: { id: "mic1" } },
|
||||
);
|
||||
capture.grant();
|
||||
|
||||
rerender({ id: "mic2" });
|
||||
|
||||
// No level carried over from the previous device.
|
||||
const state = result.current;
|
||||
expect(state.type === "level" ? state.level$.value : state.type).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
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 MicrophoneState,
|
||||
observeMicrophoneState$,
|
||||
} from "../state/MicrophoneLevel";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
const IDLE: MicrophoneState = { type: "level", level$: constant(0) };
|
||||
|
||||
/** The live level of a microphone, captured only while `active`. */
|
||||
export function useMicrophoneLevel(
|
||||
deviceId: string | undefined,
|
||||
active: boolean,
|
||||
): MicrophoneState {
|
||||
const [state, setState] = useState<MicrophoneState>(IDLE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
// Idle first, so a new device doesn't start from the previous level.
|
||||
setState(IDLE);
|
||||
const subscription = observeMicrophoneState$(deviceId).subscribe(setState);
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [deviceId, active]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -9,11 +9,7 @@ import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Config, validateConfig } from "./Config";
|
||||
import {
|
||||
CallViewModelImplementation,
|
||||
DEFAULT_CONFIG,
|
||||
MatrixRTCMode,
|
||||
} from "./ConfigOptions";
|
||||
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
|
||||
|
||||
describe("validateConfig", () => {
|
||||
afterEach(() => {
|
||||
@@ -105,21 +101,3 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-14
@@ -15,14 +15,11 @@ import {
|
||||
type ResolvedConfigOptions,
|
||||
} from "./ConfigOptions";
|
||||
import { isFailure } from "../utils/fetch";
|
||||
import { CallViewModelImplementation, MatrixRTCMode } from "./ConfigOptions";
|
||||
import { 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;
|
||||
@@ -139,16 +136,6 @@ 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,18 +24,6 @@ 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.
|
||||
@@ -214,14 +202,6 @@ 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.
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
/*
|
||||
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>>;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
/*
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/*
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,676 +0,0 @@
|
||||
/*
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/*
|
||||
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;
|
||||
@@ -1,123 +0,0 @@
|
||||
/*
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,333 +0,0 @@
|
||||
/*
|
||||
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()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
/*
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
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" },
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
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(),
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
/*
|
||||
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,16 +87,8 @@ export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { client } = useClient();
|
||||
const { roomId: paramsRoomId, password } = useUrlParams();
|
||||
|
||||
const [storedPassword] = useRoomSharedKey(
|
||||
@@ -108,29 +100,18 @@ export function useEncryptionSystemFor(
|
||||
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
|
||||
);
|
||||
|
||||
return <EncryptionSystem>useMemo(() => {
|
||||
const room = client?.getRoom(roomId);
|
||||
const e2eeSystem = <EncryptionSystem>useMemo(() => {
|
||||
if (!room) return { kind: E2eeType.NONE };
|
||||
if (storedPassword)
|
||||
return {
|
||||
kind: E2eeType.SHARED_KEY,
|
||||
secret: storedPassword,
|
||||
};
|
||||
if (roomEncrypted) {
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
return { kind: E2eeType.PER_PARTICIPANT };
|
||||
}
|
||||
return { kind: E2eeType.NONE };
|
||||
}, [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],
|
||||
);
|
||||
}, [room, storedPassword]);
|
||||
return e2eeSystem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
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 { render } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { makeGridLayout } from "./GridLayout";
|
||||
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
|
||||
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
|
||||
import { type LayoutProps } from "./Grid";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type Alignment } from "../state/layout-types";
|
||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||
import {
|
||||
GridTileViewModel,
|
||||
SpotlightTileViewModel,
|
||||
type TileViewModel,
|
||||
} from "../state/TileViewModel";
|
||||
import { makeOneOnOneMobileLayout } from "./OneOnOneMobileLayout";
|
||||
import { makeSpotlightExpandedLayout } from "./SpotlightExpandedLayout";
|
||||
import { makeOneOnOneDesktopLayout } from "./OneOnOneDesktopLayout";
|
||||
|
||||
// Grid supplies these hooks to the layouts it renders; on their own the
|
||||
// layouts have no grid to report to
|
||||
vi.mock("./Grid", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
useUpdateLayout: (): void => {},
|
||||
useVisibleTiles: (): void => {},
|
||||
}));
|
||||
|
||||
const minBounds$ = constant({ width: 800, height: 800 });
|
||||
|
||||
const spotlight = {
|
||||
vm: new SpotlightTileViewModel(
|
||||
constant([]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
),
|
||||
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
|
||||
onDrag: () => {},
|
||||
};
|
||||
const grid = Array.from(
|
||||
{ length: 3 },
|
||||
() => new GridTileViewModel(constant({} as UserMediaViewModel)),
|
||||
);
|
||||
const pip = {
|
||||
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
|
||||
onDrag: () => {},
|
||||
vm: { id: "pip" } as GridTileViewModel,
|
||||
size$: constant("sm" as const),
|
||||
};
|
||||
|
||||
const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({
|
||||
id,
|
||||
model,
|
||||
onDrag,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<div data-testid={`slot-${id}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
function snapshotLayout<Model>(layout: CallLayout<Model>, model: Model): void {
|
||||
const {
|
||||
foreground,
|
||||
scrolling: Scrolling,
|
||||
fixed: Fixed,
|
||||
} = layout({ minBounds$ });
|
||||
const scrollingLayer = <Scrolling model={model} Slot={Slot} />;
|
||||
const fixedLayer = <Fixed model={model} Slot={Slot} />;
|
||||
const { container } = render(
|
||||
foreground === "fixed" ? (
|
||||
<>
|
||||
{scrollingLayer}
|
||||
{fixedLayer}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{fixedLayer}
|
||||
{scrollingLayer}
|
||||
</>
|
||||
),
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
}
|
||||
|
||||
test("grid layout", () =>
|
||||
snapshotLayout(makeGridLayout, {
|
||||
type: "grid",
|
||||
spotlight,
|
||||
grid,
|
||||
setVisibleTiles: () => {},
|
||||
}));
|
||||
|
||||
test("spotlight landscape layout", () =>
|
||||
snapshotLayout(makeSpotlightLandscapeLayout, {
|
||||
type: "spotlight-landscape",
|
||||
spotlight,
|
||||
grid,
|
||||
setVisibleTiles: () => {},
|
||||
}));
|
||||
|
||||
test("spotlight portrait layout", () =>
|
||||
snapshotLayout(makeSpotlightPortraitLayout, {
|
||||
type: "spotlight-portrait",
|
||||
spotlight,
|
||||
grid,
|
||||
setVisibleTiles: () => {},
|
||||
}));
|
||||
|
||||
test("spotlight expanded layout", () =>
|
||||
snapshotLayout(makeSpotlightExpandedLayout, {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: spotlight.vm,
|
||||
pip,
|
||||
}));
|
||||
|
||||
test("one-on-one desktop layout", () =>
|
||||
snapshotLayout(makeOneOnOneDesktopLayout, {
|
||||
type: "one-on-one-desktop",
|
||||
spotlight: { id: "spotlight" } as GridTileViewModel,
|
||||
pip,
|
||||
}));
|
||||
|
||||
test("one-on-one mobile layout", () =>
|
||||
snapshotLayout(makeOneOnOneMobileLayout, {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: spotlight.vm,
|
||||
pip,
|
||||
}));
|
||||
@@ -5,11 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Observable } from "rxjs";
|
||||
import { type ComponentType } from "react";
|
||||
|
||||
import { type LayoutProps } from "./Grid";
|
||||
import { type TileViewModel } from "../state/TileViewModel";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
|
||||
export interface Bounds {
|
||||
width: number;
|
||||
@@ -20,7 +20,7 @@ export interface CallLayoutInputs {
|
||||
/**
|
||||
* The minimum bounds of the layout area.
|
||||
*/
|
||||
minBounds$: Observable<Bounds>;
|
||||
minBounds$: Behavior<Bounds>;
|
||||
}
|
||||
|
||||
export interface CallLayoutOutputs<Model> {
|
||||
|
||||
+12
-29
@@ -40,6 +40,7 @@ import { useMergedRefs } from "../useMergedRefs";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { type DragCallback } from "../state/layout-types";
|
||||
|
||||
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
|
||||
|
||||
@@ -83,7 +84,10 @@ interface DragState {
|
||||
cursorY: number;
|
||||
}
|
||||
|
||||
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
|
||||
export interface SlotProps<Model> extends Omit<
|
||||
ComponentProps<"div">,
|
||||
"onDrag"
|
||||
> {
|
||||
id: string;
|
||||
model: Model;
|
||||
onDrag?: DragCallback;
|
||||
@@ -181,27 +185,6 @@ export interface TileProps<Model, R extends HTMLElement> {
|
||||
model: Model;
|
||||
}
|
||||
|
||||
interface Drag {
|
||||
/**
|
||||
* The X coordinate of the dragged tile in grid space.
|
||||
*/
|
||||
x: number;
|
||||
/**
|
||||
* The Y coordinate of the dragged tile in grid space.
|
||||
*/
|
||||
y: number;
|
||||
/**
|
||||
* The X coordinate of the dragged tile, as a scalar of the grid width.
|
||||
*/
|
||||
xRatio: number;
|
||||
/**
|
||||
* The Y coordinate of the dragged tile, as a scalar of the grid height.
|
||||
*/
|
||||
yRatio: number;
|
||||
}
|
||||
|
||||
export type DragCallback = (drag: Drag) => void;
|
||||
|
||||
interface LayoutMemoProps<
|
||||
LayoutModel,
|
||||
TileModel,
|
||||
@@ -464,13 +447,13 @@ export function Grid<
|
||||
)
|
||||
.catch(logger.error);
|
||||
|
||||
if (endOfGesture)
|
||||
callback({
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
xRatio: tileX / (gridBounds.width - tile.width),
|
||||
yRatio: tileY / (gridBounds.height - tile.height),
|
||||
});
|
||||
callback({
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
xRatio: tileX / (gridBounds.width - tile.width),
|
||||
yRatio: tileY / (gridBounds.height - tile.height),
|
||||
endOfGesture,
|
||||
});
|
||||
};
|
||||
|
||||
// Callback for useDrag. We could call useDrag here, but the default
|
||||
|
||||
+32
-33
@@ -8,17 +8,21 @@ Please see LICENSE in the repository root for full details.
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type FC,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { distinctUntilChanged } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
|
||||
import styles from "./GridLayout.module.css";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { type SlotProps, useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import {
|
||||
type SpotlightTileViewModel,
|
||||
type TileViewModel,
|
||||
} from "../state/TileViewModel.ts";
|
||||
import { type FloatingTile } from "../state/FloatingTile.ts";
|
||||
|
||||
interface GridCSSProperties extends CSSProperties {
|
||||
"--gap": string;
|
||||
@@ -26,6 +30,27 @@ interface GridCSSProperties extends CSSProperties {
|
||||
"--height": string;
|
||||
}
|
||||
|
||||
interface SpotlightSlotProps {
|
||||
model: FloatingTile & { vm: SpotlightTileViewModel };
|
||||
Slot: ComponentType<SlotProps<TileViewModel>>;
|
||||
}
|
||||
|
||||
const SpotlightSlot: FC<SpotlightSlotProps> = ({ model, Slot }) => {
|
||||
useUpdateLayout();
|
||||
const alignment = useBehavior(model.alignment$);
|
||||
|
||||
return (
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.vm}
|
||||
onDrag={model.onDrag}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* An implementation of the "grid" layout, in which all participants are shown
|
||||
* together in a scrolling grid.
|
||||
@@ -39,36 +64,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
// lives
|
||||
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
const alignment = useObservableEagerState(
|
||||
useInitial(() =>
|
||||
model.spotlightAlignment$.pipe(
|
||||
distinctUntilChanged(
|
||||
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const onDragSpotlight: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
model.spotlightAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[model.spotlightAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.fixed}>
|
||||
{model.spotlight && (
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight}
|
||||
onDrag={onDragSpotlight}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
<SpotlightSlot model={model.spotlight} Slot={Slot} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -78,7 +77,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
const { width, height: minHeight } = useObservableEagerState(minBounds$);
|
||||
const { width, height: minHeight } = useBehavior(minBounds$);
|
||||
const { gap, tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, minHeight, model.grid.length),
|
||||
[width, minHeight, model.grid.length],
|
||||
|
||||
@@ -6,14 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneDesktopLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
@@ -37,22 +36,13 @@ export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useObservableEagerState(minBounds$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const { width, height } = useBehavior(minBounds$);
|
||||
const pipAlignment = useBehavior(model.pip.alignment$);
|
||||
const { tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, height, 1),
|
||||
[width, height],
|
||||
);
|
||||
|
||||
const onDragLocalTile: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
@@ -63,9 +53,9 @@ export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
>
|
||||
<Slot
|
||||
className={classNames(styles.slot, styles.local)}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
id={model.pip.vm.id}
|
||||
model={model.pip.vm}
|
||||
onDrag={model.pip.onDrag}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
|
||||
@@ -6,14 +6,43 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { type ComponentType, type FC, type ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import styles from "./OneOnOneMobileLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { type SlotProps, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type FloatingTile } from "../state/FloatingTile.ts";
|
||||
import {
|
||||
type GridTileViewModel,
|
||||
type TileViewModel,
|
||||
} from "../state/TileViewModel.ts";
|
||||
import { type Behavior } from "../state/Behavior.ts";
|
||||
|
||||
interface PipSlotProps {
|
||||
model: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
|
||||
Slot: ComponentType<SlotProps<TileViewModel>>;
|
||||
}
|
||||
|
||||
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
|
||||
useUpdateLayout();
|
||||
const size = useBehavior(model.size$);
|
||||
const alignment = useBehavior(model.alignment$);
|
||||
|
||||
return (
|
||||
<Slot
|
||||
className={classNames(styles.pip)}
|
||||
id={model.vm.id}
|
||||
model={model.vm}
|
||||
onDrag={model.onDrag}
|
||||
data-size={size}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout for mobile platforms, in which
|
||||
@@ -44,30 +73,9 @@ export const makeOneOnOneMobileLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const pipSize = useBehavior(model.pipSize$);
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
const onDragLocalTile: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
{model.pip && (
|
||||
<Slot
|
||||
className={classNames(styles.pip)}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragLocalTile}
|
||||
data-size={pipSize}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
)}
|
||||
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,13 +5,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { type ReactNode, type ComponentType, type FC } from "react";
|
||||
|
||||
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { type SlotProps, useUpdateLayout } from "./Grid";
|
||||
import styles from "./SpotlightExpandedLayout.module.css";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type FloatingTile } from "../state/FloatingTile.ts";
|
||||
import {
|
||||
type GridTileViewModel,
|
||||
type TileViewModel,
|
||||
} from "../state/TileViewModel.ts";
|
||||
|
||||
interface PipSlotProps {
|
||||
model: FloatingTile & { vm: GridTileViewModel };
|
||||
Slot: ComponentType<SlotProps<TileViewModel>>;
|
||||
}
|
||||
|
||||
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
|
||||
useUpdateLayout();
|
||||
const alignment = useBehavior(model.alignment$);
|
||||
|
||||
return (
|
||||
<Slot
|
||||
className={styles.pip}
|
||||
id={model.vm.id}
|
||||
model={model.vm}
|
||||
onDrag={model.onDrag}
|
||||
data-block-alignment={alignment.block}
|
||||
data-inline-alignment={alignment.inline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* An implementation of the "expanded spotlight" layout, in which the spotlight
|
||||
@@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<Slot
|
||||
@@ -46,29 +71,9 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const pipAlignment = useBehavior(model.pipAlignment$);
|
||||
|
||||
const onDragPip: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
model.pipAlignment$.next({
|
||||
block: yRatio < 0.5 ? "start" : "end",
|
||||
inline: xRatio < 0.5 ? "start" : "end",
|
||||
}),
|
||||
[model.pipAlignment$],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
{model.pip && (
|
||||
<Slot
|
||||
className={styles.pip}
|
||||
id={model.pip.id}
|
||||
model={model.pip}
|
||||
onDrag={onDragPip}
|
||||
data-block-alignment={pipAlignment.block}
|
||||
data-inline-alignment={pipAlignment.inline}
|
||||
/>
|
||||
)}
|
||||
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, type ReactNode } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
@@ -33,7 +32,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
useObservableEagerState(minBounds$);
|
||||
useBehavior(minBounds$);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
@@ -41,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight}
|
||||
model={model.spotlight.vm}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.grid} />
|
||||
@@ -56,11 +55,11 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
useObservableEagerState(minBounds$);
|
||||
useBehavior(minBounds$);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<SpotlightSlot media$={model.spotlight.media$} />
|
||||
<SpotlightSlot media$={model.spotlight.vm.media$} />
|
||||
<div className={styles.grid}>
|
||||
{model.grid.map((m) => (
|
||||
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
|
||||
|
||||
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, type CSSProperties } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
@@ -44,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
<Slot
|
||||
className={styles.slot}
|
||||
id="spotlight"
|
||||
model={model.spotlight}
|
||||
model={model.spotlight.vm}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +57,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
const { width } = useObservableEagerState(minBounds$);
|
||||
const { width } = useBehavior(minBounds$);
|
||||
const { gap, tileWidth, tileHeight } = arrangeTiles(
|
||||
width,
|
||||
// TODO: We pretend that the minimum height is the width, because the
|
||||
@@ -66,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
width,
|
||||
model.grid.length,
|
||||
);
|
||||
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
|
||||
const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`grid layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_scrolling_e8555d"
|
||||
style="width: 800px; --gap: 20px; --width: 370px; --height: 277px;"
|
||||
>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-testid="slot-0"
|
||||
/>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-testid="slot-1"
|
||||
/>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-testid="slot-2"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_fixed_e8555d"
|
||||
>
|
||||
<div
|
||||
class="_slot_e8555d"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`one-on-one desktop layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_185815"
|
||||
>
|
||||
<div
|
||||
class="_container_185815"
|
||||
data-testid="slot-spotlight"
|
||||
style="width: 760px; height: 570px;"
|
||||
>
|
||||
<div
|
||||
class="_slot_185815 _local_185815"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-testid="slot-pip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`one-on-one mobile layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_c81c30"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_c81c30"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_c81c30"
|
||||
>
|
||||
<div
|
||||
class="_pip_c81c30"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-size="sm"
|
||||
data-testid="slot-pip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`spotlight expanded layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_e70191"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_e70191"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_e70191"
|
||||
>
|
||||
<div
|
||||
class="_pip_e70191"
|
||||
data-block-alignment="end"
|
||||
data-inline-alignment="end"
|
||||
data-testid="slot-pip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`spotlight landscape layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_29809c"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_29809c"
|
||||
>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_grid_29809c"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_29809c"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_29809c"
|
||||
/>
|
||||
<div
|
||||
class="_grid_29809c"
|
||||
>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-0"
|
||||
/>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-1"
|
||||
/>
|
||||
<div
|
||||
class="_slot_29809c"
|
||||
data-testid="slot-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`spotlight portrait layout 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="_layer_065bbc"
|
||||
style="--grid-gap: 20px; --grid-tile-width: 370px; --grid-tile-height: 277px;"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_065bbc"
|
||||
/>
|
||||
<div
|
||||
class="_grid_065bbc"
|
||||
>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-0"
|
||||
/>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-1"
|
||||
/>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_layer_065bbc"
|
||||
>
|
||||
<div
|
||||
class="_spotlight_065bbc"
|
||||
>
|
||||
<div
|
||||
class="_slot_065bbc"
|
||||
data-testid="slot-spotlight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -20,8 +20,7 @@ import {
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, map, type Observable } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { combineLatest } from "rxjs";
|
||||
|
||||
import {
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
@@ -31,6 +30,7 @@ import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type ObservableScope } from "../state/ObservableScope";
|
||||
import { platform } from "../Platform";
|
||||
import { useValueBehavior } from "../useValueBehavior";
|
||||
|
||||
//TODO-MULTI-SFU: This is not yet fully there.
|
||||
// it is a combination of exposing observable and react hooks.
|
||||
@@ -52,18 +52,8 @@ export function useTrackProcessor(): ProcessorState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function useTrackProcessorObservable$(): Observable<ProcessorState> {
|
||||
const state = use(ProcessorContext);
|
||||
if (state === undefined)
|
||||
throw new Error(
|
||||
"useTrackProcessor must be used within a ProcessorProvider",
|
||||
);
|
||||
const state$ = useObservable(
|
||||
(init$) => init$.pipe(map(([init]) => init)),
|
||||
[state],
|
||||
);
|
||||
|
||||
return state$;
|
||||
export function useTrackProcessorState$(): Behavior<ProcessorState> {
|
||||
return useValueBehavior(useTrackProcessor());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
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);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/*
|
||||
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(["🎉"]);
|
||||
});
|
||||
});
|
||||
@@ -1,250 +0,0 @@
|
||||
/*
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import waveSoundOgg from "../sound/reactions/wave.ogg?url";
|
||||
import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
|
||||
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
|
||||
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
|
||||
import bellSoundOgg from "../sound/reactions/bell.ogg?url";
|
||||
import bellSoundMp3 from "../sound/reactions/bell.mp3?url";
|
||||
|
||||
export const ElementCallReactionEventType = "io.element.call.reaction";
|
||||
|
||||
@@ -202,6 +204,15 @@ export const ReactionSet: ReactionOption[] = [
|
||||
mp3: baduntssSoundMp3,
|
||||
},
|
||||
},
|
||||
{
|
||||
emoji: "🛎️",
|
||||
name: "bell",
|
||||
alias: ["ding"],
|
||||
sound: {
|
||||
ogg: bellSoundOgg,
|
||||
mp3: bellSoundMp3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface RaisedHandInfo {
|
||||
|
||||
@@ -5,18 +5,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventType, type MatrixClient, RelationType } from "matrix-js-sdk";
|
||||
import { EventType, 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";
|
||||
@@ -42,108 +44,116 @@ 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 => {
|
||||
// A widget host may forbid reactions; without a client state (the
|
||||
// component, the crate path) there is nobody to forbid them.
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const clientState = useClientState();
|
||||
const supportsReactions =
|
||||
clientState === undefined ||
|
||||
(clientState.state === "valid" && clientState.supportedFeatures.reactions);
|
||||
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],
|
||||
);
|
||||
|
||||
const reactions = useBehavior(vm.reactions$);
|
||||
const myReaction = reactions[ownIdentifier];
|
||||
const myReaction = useMemo(
|
||||
() =>
|
||||
myMembershipIdentifier !== undefined
|
||||
? reactions[myMembershipIdentifier]
|
||||
: undefined,
|
||||
[myMembershipIdentifier, reactions],
|
||||
);
|
||||
|
||||
const handsRaised = useBehavior(vm.handsRaised$);
|
||||
const myRaisedHand = handsRaised[ownIdentifier];
|
||||
const myRaisedHand = useMemo(
|
||||
() =>
|
||||
myMembershipIdentifier !== undefined
|
||||
? handsRaised[myMembershipIdentifier]
|
||||
: undefined,
|
||||
[myMembershipIdentifier, handsRaised],
|
||||
);
|
||||
|
||||
const toggleRaisedHand = useCallback(async () => {
|
||||
if (!myMembershipIdentifier) {
|
||||
return;
|
||||
}
|
||||
const myReactionId = myRaisedHand?.reactionEventId;
|
||||
|
||||
if (!myReactionId) {
|
||||
try {
|
||||
if (!ownMembershipEventId) {
|
||||
if (!myMembershipEvent) {
|
||||
throw new Error("Cannot find own membership event");
|
||||
}
|
||||
const { eventId } = await timeline.sendRoomEvent(EventType.Reaction, {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: ownMembershipEventId,
|
||||
key: "🖐️",
|
||||
const reaction = await room.client.sendEvent(
|
||||
rtcSession.room.roomId,
|
||||
EventType.Reaction,
|
||||
{
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: myMembershipEvent,
|
||||
key: "🖐️",
|
||||
},
|
||||
},
|
||||
});
|
||||
logger.debug("Sent raise hand event", eventId);
|
||||
);
|
||||
logger.debug("Sent raise hand event", reaction.event_id);
|
||||
} catch (ex) {
|
||||
logger.error("Failed to send raised hand", ex);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await timeline.redactEvent(myReactionId);
|
||||
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
|
||||
logger.debug("Redacted raise hand event");
|
||||
} catch (ex) {
|
||||
logger.error("Failed to redact reaction event", myReactionId, ex);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
}, [ownMembershipEventId, myRaisedHand, timeline]);
|
||||
}, [
|
||||
myMembershipEvent,
|
||||
myMembershipIdentifier,
|
||||
myRaisedHand,
|
||||
rtcSession,
|
||||
room,
|
||||
]);
|
||||
|
||||
const sendReaction = useCallback(
|
||||
async (reaction: ReactionOption) => {
|
||||
if (myReaction) {
|
||||
if (!myMembershipIdentifier || myReaction) {
|
||||
// We're still reacting
|
||||
return;
|
||||
}
|
||||
if (!ownMembershipEventId) {
|
||||
if (!myMembershipEvent) {
|
||||
throw new Error("Cannot find own membership event");
|
||||
}
|
||||
await timeline.sendRoomEvent(ElementCallReactionEventType, {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Reference,
|
||||
event_id: ownMembershipEventId,
|
||||
await room.client.sendEvent(
|
||||
rtcSession.room.roomId,
|
||||
ElementCallReactionEventType,
|
||||
{
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Reference,
|
||||
event_id: myMembershipEvent,
|
||||
},
|
||||
emoji: reaction.emoji,
|
||||
name: reaction.name,
|
||||
},
|
||||
emoji: reaction.emoji,
|
||||
name: reaction.name,
|
||||
});
|
||||
);
|
||||
},
|
||||
[ownMembershipEventId, myReaction, timeline],
|
||||
[myMembershipEvent, myReaction, room, myMembershipIdentifier, rtcSession],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,12 +6,13 @@ 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 { useOwnProfile } from "../profile/useOwnProfile";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
@@ -21,6 +22,7 @@ import { LeaveToHomeLink } from "../button/LeaveToHomeLink";
|
||||
import { useLeaveToHome } from "../LeaveToHomeContext";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
hideHeader: boolean;
|
||||
confineToRoom: boolean;
|
||||
@@ -28,6 +30,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const CallEndedView: FC<Props> = ({
|
||||
client,
|
||||
isPasswordlessUser,
|
||||
hideHeader,
|
||||
confineToRoom,
|
||||
@@ -36,7 +39,7 @@ export const CallEndedView: FC<Props> = ({
|
||||
const { t } = useTranslation();
|
||||
const leaveToHome = useLeaveToHome();
|
||||
|
||||
const { displayName } = useOwnProfile();
|
||||
const { displayName } = useProfile(client);
|
||||
const [surveySubmitted, setSurveySubmitted] = useState(false);
|
||||
const [starRating, setStarRating] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
/*
|
||||
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,7 +11,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
type MockedFunction,
|
||||
onTestFinished,
|
||||
@@ -65,15 +64,6 @@ 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");
|
||||
@@ -148,23 +138,6 @@ 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,
|
||||
@@ -172,8 +145,6 @@ 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;
|
||||
@@ -223,15 +194,13 @@ function createCallView(
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
<MatrixDriverProvider value={options.drivers ?? defaultDrivers()}>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
||||
{callView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
callView
|
||||
)}
|
||||
</MatrixDriverProvider>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
||||
{callView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
callView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
@@ -455,50 +424,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
+72
-189
@@ -12,11 +12,11 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
type MatrixClient,
|
||||
JoinRule,
|
||||
type Room,
|
||||
UnsupportedStickyEventsEndpointError,
|
||||
} from "matrix-js-sdk";
|
||||
import {
|
||||
@@ -34,11 +34,7 @@ import { LobbyView } from "./LobbyView";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { CallEndedView } from "./CallEndedView";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import {
|
||||
mediaKeyStatisticsOf,
|
||||
NO_MEDIA_KEY_STATISTICS,
|
||||
} from "../analytics/PosthogEvents";
|
||||
import { useOwnProfile } from "../profile/useOwnProfile";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { findDeviceByName } from "../utils/media";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
@@ -46,9 +42,11 @@ import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import {
|
||||
saveKeyForRoom,
|
||||
useEncryptionSystemFor,
|
||||
useRoomEncryptionSystem,
|
||||
} from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomInfo } from "./useRoomInfo";
|
||||
import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useRoomName } from "./useRoomName";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
@@ -66,6 +64,7 @@ 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";
|
||||
@@ -73,20 +72,6 @@ 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
|
||||
@@ -97,26 +82,14 @@ 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 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 client to place the call with. */
|
||||
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.
|
||||
@@ -188,60 +161,7 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
||||
null,
|
||||
);
|
||||
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 memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const rootElement = useRootElement();
|
||||
const hostBridge = useHostBridge();
|
||||
// A host that can close us is a host that decides when we stop existing, so
|
||||
@@ -258,16 +178,12 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
muted: muteAllAudio,
|
||||
}),
|
||||
);
|
||||
// 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);
|
||||
// This should use `useEffectEvent` (only available in experimental versions)
|
||||
useEffect(() => {
|
||||
if (mutedForCallSize.current) return;
|
||||
if (useRustRtcSdk && !participationMemberships.value.length) return;
|
||||
mutedForCallSize.current = true;
|
||||
if (memberUserIds.length >= MUTE_PARTICIPANT_COUNT)
|
||||
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
|
||||
muteStates.audio.setEnabled$.value?.(false);
|
||||
}, [useRustRtcSdk, participationMemberships, memberUserIds, muteStates]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logger.info("[Lifecycle] CallView Component mounted");
|
||||
@@ -287,22 +203,18 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
}, [rootElement]);
|
||||
|
||||
useEffect(() => {
|
||||
// Storing in the window to access it for the rageshake summary.
|
||||
if (rtcSession !== undefined) window.rtcSession = rtcSession;
|
||||
if (rtcParticipationManager !== null)
|
||||
window.matrixRtc = { rtcParticipationManager };
|
||||
window.rtcSession = rtcSession;
|
||||
return (): void => {
|
||||
delete window.rtcSession;
|
||||
delete window.matrixRtc;
|
||||
};
|
||||
}, [rtcSession, rtcParticipationManager]);
|
||||
}, [rtcSession]);
|
||||
|
||||
// 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?
|
||||
// matrix-js-sdk only; the crate reports these through `fatalError$`.
|
||||
useEffect(() => {
|
||||
if (rtcSession === undefined) return;
|
||||
const onError = (error: unknown): void => {
|
||||
useTypedEventEmitter(
|
||||
rtcSession,
|
||||
MatrixRTCSessionEvent.MembershipManagerError,
|
||||
(error) => {
|
||||
// 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
|
||||
@@ -315,93 +227,79 @@ 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]);
|
||||
}, [client, rtcSession.room]);
|
||||
|
||||
const { displayName, avatarUrl } = useOwnProfile();
|
||||
const roomName = roomInfo.name;
|
||||
const roomAvatar = roomInfo.avatarUrl;
|
||||
const room = rtcSession.room as Room;
|
||||
const { displayName, avatarUrl } = useProfile(client);
|
||||
const roomName = useRoomName(room);
|
||||
const roomAvatar = useRoomAvatar(room);
|
||||
const {
|
||||
perParticipantE2EE,
|
||||
returnToLobby,
|
||||
password: passwordFromUrl,
|
||||
header,
|
||||
} = useUrlParams();
|
||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||
|
||||
// Save the password once we start the groupCallView
|
||||
useEffect(() => {
|
||||
if (passwordFromUrl) saveKeyForRoom(roomId, passwordFromUrl);
|
||||
}, [passwordFromUrl, roomId]);
|
||||
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
|
||||
}, [passwordFromUrl, room.roomId]);
|
||||
|
||||
useAppBarTitle(roomName);
|
||||
|
||||
const { userId: ownUserId } = drivers.clientDriver;
|
||||
const roomAlias = roomInfo.canonicalAlias;
|
||||
const matrixInfo = useMemo((): MatrixInfo => {
|
||||
return {
|
||||
userId: ownUserId,
|
||||
displayName: displayName ?? ownUserId,
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomId,
|
||||
userId: client.getUserId()!,
|
||||
displayName: displayName!,
|
||||
avatarUrl: avatarUrl!,
|
||||
roomId: room.roomId,
|
||||
roomName,
|
||||
roomAlias,
|
||||
roomAlias: room.getCanonicalAlias(),
|
||||
roomAvatar,
|
||||
e2eeSystem,
|
||||
};
|
||||
}, [
|
||||
ownUserId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
roomName,
|
||||
roomId,
|
||||
roomAlias,
|
||||
roomAvatar,
|
||||
e2eeSystem,
|
||||
]);
|
||||
}, [client, displayName, avatarUrl, roomName, room, roomAvatar, e2eeSystem]);
|
||||
|
||||
// Count each member only once, regardless of how many devices they use
|
||||
const participantCount = useMemo(
|
||||
() => new Set<string>(memberUserIds).size,
|
||||
[memberUserIds],
|
||||
() => new Set<string>(memberships.map((m) => m.userId!)).size,
|
||||
[memberships],
|
||||
);
|
||||
|
||||
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 (): 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 (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, [setJoined]);
|
||||
return Promise.resolve();
|
||||
},
|
||||
[setJoined],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultDeviceSetup = async ({
|
||||
@@ -511,14 +409,10 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
// queuing/batching of requests.
|
||||
const sendInstantly = hostControlsLifetime;
|
||||
PosthogAnalytics.instance.eventCallEnded.track(
|
||||
roomId,
|
||||
latestMemberUserIds.current.length,
|
||||
room.roomId,
|
||||
rtcSession.memberships.length,
|
||||
sendInstantly,
|
||||
rtcParticipationManager !== null
|
||||
? rtcParticipationManager.mediaKeyStatistics()
|
||||
: rtcSession === undefined
|
||||
? NO_MEDIA_KEY_STATISTICS
|
||||
: mediaKeyStatisticsOf(rtcSession),
|
||||
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
|
||||
@@ -563,10 +457,8 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
leaveSoundContext,
|
||||
hostBridge,
|
||||
hostControlsLifetime,
|
||||
roomId,
|
||||
latestMemberUserIds,
|
||||
room.roomId,
|
||||
rtcSession,
|
||||
rtcParticipationManager,
|
||||
isPasswordlessUser,
|
||||
confineToRoom,
|
||||
returnToLobby,
|
||||
@@ -582,7 +474,7 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
});
|
||||
}, [hostBridge, joined, rtcSession]);
|
||||
|
||||
const joinRule = roomInfo.joinRule;
|
||||
const joinRule = useJoinRule(room);
|
||||
|
||||
const [shareModalOpen, setInviteModalOpen] = useState(false);
|
||||
const onDismissInviteModal = useCallback(
|
||||
@@ -603,9 +495,7 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
|
||||
const shareModal = (
|
||||
<InviteModal
|
||||
roomId={roomId}
|
||||
roomName={roomName}
|
||||
e2eeSystem={e2eeSystem}
|
||||
room={room}
|
||||
open={shareModalOpen}
|
||||
onDismiss={onDismissInviteModal}
|
||||
/>
|
||||
@@ -614,7 +504,7 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
<>
|
||||
{shareModal}
|
||||
<LobbyView
|
||||
developerSettingsClient={client}
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => setJoined(true)}
|
||||
@@ -635,10 +525,6 @@ 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 = (
|
||||
<>
|
||||
@@ -646,9 +532,8 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
<ActiveCall
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
rtcSession={rtcSession}
|
||||
rtcParticipationManager={rtcParticipationManager}
|
||||
roomId={roomId}
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
matrixRoom={room}
|
||||
onLeft={onLeft}
|
||||
muteStates={muteStates}
|
||||
e2eeSystem={e2eeSystem}
|
||||
@@ -669,7 +554,8 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) {
|
||||
body = (
|
||||
<CallEndedView
|
||||
endedCallId={roomId}
|
||||
endedCallId={rtcSession.room.roomId}
|
||||
client={client}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
hideHeader={header === HeaderStyle.None}
|
||||
confineToRoom={confineToRoom}
|
||||
@@ -697,16 +583,13 @@ const LoadedCallView: FC<LoadedProps> = ({
|
||||
setExternalError(null);
|
||||
if (action == "reconnect") {
|
||||
setLeft(false);
|
||||
await enterRTCSessionOrError().catch((e) => {
|
||||
await enterRTCSessionOrError(rtcSession).catch((e) => {
|
||||
logger.error("Error re-entering RTC session", e);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onError={(_error) => {
|
||||
const joinedViaCrate =
|
||||
rtcParticipationManager !== null &&
|
||||
FfiStatus.Connected.instanceOf(rtcParticipationManager.status$.value);
|
||||
if (rtcSession?.isJoined() === true || joinedViaCrate) onLeft("error");
|
||||
if (rtcSession.isJoined()) 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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user