mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-23 00:02:26 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
535e0b0df1 |
@@ -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"
|
||||
|
||||
@@ -10,9 +10,6 @@ public/config.json
|
||||
backend/synapse_tmp/*
|
||||
backend/synapse_tmp_othersite/*
|
||||
/coverage
|
||||
|
||||
# Transient agent scratch files
|
||||
/agent-workspace/
|
||||
config.json
|
||||
|
||||
# Yarn
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 80,
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist"]
|
||||
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist", "FEATURES_SPEC"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# AGENTS.md — Element Call
|
||||
|
||||
MatrixRTC (MSC4143) + LiveKit video calling, shipped standalone, as a widget in
|
||||
Element Web and Element X, as embedded packages, and as a React component in a
|
||||
host's page. It is the MatrixRTC reference implementation.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- Features and UI changes need a pre-approved issue. No issue, no review.
|
||||
- Exactly one `PR-*` label per PR. CI enforces it; it drives the changelog.
|
||||
- New behaviour ships with unit tests, a story if it renders, and an e2e spec if it
|
||||
is user-facing. All three.
|
||||
- Reuse an existing component. If you genuinely cannot, say so in the PR body and
|
||||
name what you rejected — never add a new one silently.
|
||||
- Nothing reads the page. No `window.location`, `document.body`, `window.inner*`,
|
||||
`@media`, global `i18next` or the `widget` global — take it from a provider.
|
||||
- The change works standalone, as a widget and as a component. Say what you checked.
|
||||
- Every gate below is green before you push.
|
||||
- Hand off after the first implementation, before the quality pass. Then commit on
|
||||
the user's word where the setup allows it, or hand them the message.
|
||||
|
||||
## Read before you
|
||||
|
||||
| … | … |
|
||||
| -------------------------------- | ---------------------------------------------------------- |
|
||||
| start a task of any size | [docs/agents/workflow.md](docs/agents/workflow.md) |
|
||||
| touch call logic or a view model | [docs/agents/architecture.md](docs/agents/architecture.md) |
|
||||
| write any code | [docs/agents/code-style.md](docs/agents/code-style.md) |
|
||||
| write a test, story or e2e spec | [docs/agents/testing.md](docs/agents/testing.md) |
|
||||
| open a PR | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
||||
| implement from a feature spec | [FEATURES_SPEC/AGENTS.md](FEATURES_SPEC/AGENTS.md) |
|
||||
|
||||
## Gates
|
||||
|
||||
```sh
|
||||
pnpm lint # tsc, oxlint, knip, component externals
|
||||
pnpm format # oxfmt
|
||||
pnpm test # vitest: unit and storybook projects
|
||||
pnpm i18n:check
|
||||
```
|
||||
|
||||
`knip` fails on dead code; a file deliberately inert ahead of its consumer goes in
|
||||
`knip.ts` `ignoreFiles` with a reason.
|
||||
|
||||
## A good PR
|
||||
|
||||
- One slice, under ~400 changed lines, green on its own, linked to its issue.
|
||||
- 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.
|
||||
- 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.
|
||||
- Once review starts, fix forward. Never force-push a regeneration over a review.
|
||||
@@ -0,0 +1,415 @@
|
||||
# FEATURE_SPEC — Agent Instructions
|
||||
|
||||
This folder holds **feature specs distilled from experiments**. A spec is the durable
|
||||
artifact of an exploration; the exploration branch is not. Each spec must contain
|
||||
enough information for an agent to produce mergeable PRs against a *current* `main`
|
||||
without access to the original branch.
|
||||
|
||||
## What this folder is not
|
||||
|
||||
- Not a design-doc archive. Specs that are not intended to be implemented do not
|
||||
belong here.
|
||||
- Not a place for narrative. Prose that does not constrain the implementation or its
|
||||
verification should be cut.
|
||||
- Not a substitute for the PR description. The spec explains *why* and *what must be
|
||||
true*; the PR explains *this diff*.
|
||||
|
||||
## File naming
|
||||
|
||||
`YYYY-MM_Snake_Case_Title.md` — e.g. `2026-09_Raise_Hand_Improvements.md`
|
||||
|
||||
The filename is immutable once committed. Renames break links from PRs, issues and
|
||||
the drift log. If scope changes fundamentally, write a new spec and set
|
||||
`supersedes:` / `superseded_by:`.
|
||||
|
||||
A sidecar `YYYY-MM_Snake_Case_Title.product.md` holds the product spec — user stories,
|
||||
functional requirements, success criteria. Copy `_TEMPLATE.product.md`. Product owns
|
||||
it; see "Brainstorming: the product spec".
|
||||
|
||||
An optional sidecar `YYYY-MM_Snake_Case_Title.notes.md` holds lessons learned,
|
||||
narrative, dead-end logs and anything else that is worth keeping but does not
|
||||
constrain the implementation. Rules:
|
||||
|
||||
- The sidecar is **never load-bearing**. Agents do not read it to derive
|
||||
implementation, and nothing in it may contradict or extend the spec. If a note
|
||||
constrains the work, it belongs in `## Decisions`, `## Rejected alternatives` or
|
||||
`## Measurements` instead.
|
||||
- Agents may append to it (an observation from a drift check, a wrong turn during
|
||||
implementation) and must never rewrite or delete existing content.
|
||||
- An agent that is `blocked` may read the sidecar to draft an open question, but
|
||||
quotes what it found rather than acting on it.
|
||||
- No sidecar is required. An empty one is worse than none.
|
||||
|
||||
## Style: facts only
|
||||
|
||||
Applies to the spec file only. The product spec is prose by design and the notes
|
||||
sidecar is unconstrained. The spec is re-read on every regeneration, where prose costs context
|
||||
and dilutes the contract, so:
|
||||
|
||||
- Bullets, not paragraphs. Declarative present tense. No motivation, no background,
|
||||
no summary of what the code currently does beyond locating the change.
|
||||
- Name things instead of describing them — a path, a symbol, an event type, a command,
|
||||
an event id. A reference is shorter and more precise than a sentence about it. Which
|
||||
kind of reference is allowed depends on the section; see "Reference durability".
|
||||
- One line per decision plus at most one sentence of rationale. If the rationale needs
|
||||
more, the extra goes in the sidecar.
|
||||
- No judgement adjectives (clean, elegant, proper, obviously) and no hedging (should
|
||||
probably, might be nice). Either it is a constraint or it is not in the spec.
|
||||
- `## Problem` is at most five bullets.
|
||||
- Target under 200 lines for the whole spec; over 300 lines, split the feature or move
|
||||
material to the sidecar.
|
||||
|
||||
The test for any line: does it constrain the implementation, constrain its
|
||||
verification, or record a measured fact? If not, delete it or move it to the sidecar.
|
||||
|
||||
## Reference durability
|
||||
|
||||
The spec must stay implementable against a `main` nobody has seen yet, so which
|
||||
references are allowed depends on how fast they rot. Most to least durable:
|
||||
|
||||
1. Protocol identifiers — `m.reaction`, `m.call.member`, `m.relates_to`, a reaction
|
||||
key. MSC-bound; effectively permanent.
|
||||
2. Observable behaviour. Permanent.
|
||||
3. Concept names — membership, participation, session.
|
||||
4. Test names. Survive file moves and renames, because the test moves with the code.
|
||||
5. Exported symbols. Survive refactors sometimes.
|
||||
6. File paths. Rot fast.
|
||||
7. Line numbers. Never.
|
||||
|
||||
Per section:
|
||||
|
||||
- `## Problem`, `## Measurements`, `## Slicing plan`, `## Drift log` — any reference,
|
||||
paths and internal symbols included. These describe the codebase **as of `base`**. A
|
||||
reference going stale here is a finding for the drift check, not a defect, and
|
||||
removing the concrete references would leave the drift check with nothing to verify.
|
||||
- `## Decisions`, `## Acceptance criteria` — levels 1 to 4 only. No file paths, no
|
||||
internal symbols. This is the part that has to hold against a future `main`.
|
||||
- An acceptance criterion that is not user-observable is tagged `[internal]` and
|
||||
anchored by **its test name**, not by the symbol under test. The test name moves with
|
||||
a refactor; the symbol does not.
|
||||
|
||||
`[internal]` ACs are legitimate and expected: an invariant that no current consumer can
|
||||
observe is exactly what protects the consumer that does not exist yet. Tagging them
|
||||
keeps visible which ACs defend a product requirement and which defend a code
|
||||
invariant.
|
||||
|
||||
## Required frontmatter
|
||||
|
||||
```yaml
|
||||
---
|
||||
status: draft # see state machine below
|
||||
blocked_from: null # state to return to when leaving `blocked`
|
||||
title: Raise hand improvements
|
||||
owner: <human handle> # a human, always
|
||||
signoff: # join condition for leaving exploration.signoff
|
||||
eng: pending
|
||||
design: pending
|
||||
product: pending
|
||||
experiment_ref: <branch name or commit sha of the spike, if any>
|
||||
base: <sha of main the spec was validated against>
|
||||
repos: [matrix-js-sdk, element-call] # in dependency order
|
||||
specs: [MSC4143] # protocol documents touched, if any
|
||||
supersedes: []
|
||||
superseded_by: null
|
||||
---
|
||||
```
|
||||
|
||||
`base` is mandatory and is what makes drift detectable. `owner` is a human even when
|
||||
every section was drafted by an agent.
|
||||
|
||||
## Brainstorming: the product spec
|
||||
|
||||
The usual entry point is a human handing an agent a one-line feature idea. The output
|
||||
of that session is `<stem>.product.md`, not code and not a feature spec. Copy
|
||||
`_TEMPLATE.product.md` and fill it.
|
||||
|
||||
Ownership: an agent may create and fill this file during `brainstorm`. From `draft`
|
||||
onwards **product owns it and agents do not edit it** — not to fix wording, not to
|
||||
resolve a marker, not to reconcile it with the code. Agents raise mismatches in the
|
||||
feature spec's `## Open questions`.
|
||||
|
||||
If product's source of truth lives outside the repo (a tracker, a design doc), put its
|
||||
URL in the header `**Source**:` line and keep the requirement text and ids in the repo
|
||||
anyway. The ids are what the feature spec references; a link alone cannot be traced.
|
||||
|
||||
Agent behaviour during the session:
|
||||
|
||||
- **Ask, do not assume.** An unanswered question is recorded as
|
||||
`[NEEDS CLARIFICATION: <the question>]`, inline where the answer would go. Filling a
|
||||
gap with a plausible guess is the main failure mode of this state — the marker is a
|
||||
deliverable, not a defect.
|
||||
- Ask in one round of at most five questions, ordered by how much the answer changes
|
||||
the shape of the feature. Do not interview.
|
||||
- **No implementation in the product spec.** No file paths, symbols, libraries, event
|
||||
types, API shapes or framework names. If a requirement cannot be stated without them,
|
||||
it is not a product requirement.
|
||||
- Every user story must be independently deployable and demonstrable on its own. A
|
||||
story that only makes sense once another story ships is not a story; merge it.
|
||||
- More than four priority levels means more than one feature. Split before `draft`.
|
||||
- Never invent an SC number. Ask for the target, or mark it
|
||||
`[NEEDS CLARIFICATION]`.
|
||||
|
||||
For protocol and infrastructure work, success criteria may be expressed as observable
|
||||
client behaviour or a wire-level invariant ("a client joining mid-session converges
|
||||
within one sync"), but still never as a code path or an internal API.
|
||||
|
||||
`FR-###` and `SC-###` ids are immutable once assigned. Product may revise the document
|
||||
at any time, including after `in_production`, but a changed requirement gets a **new
|
||||
id** and the old one is marked superseded — never edited in place. Renumbering silently
|
||||
breaks every AC that references it, and there is no way to detect that afterwards.
|
||||
|
||||
A new or changed FR after `implement` does not amend the current spec's scope. It is
|
||||
either a new feature spec or the next iteration; the mapping table simply shows an
|
||||
unmapped FR, which is the intended signal.
|
||||
|
||||
## Traceability
|
||||
|
||||
The product spec states intent; the feature spec states what must be true and how it is
|
||||
checked. They are kept in sync by ids, not by duplication.
|
||||
|
||||
- Every `FR-###` maps to at least one acceptance criterion. Tag the AC with its
|
||||
origin: `AC2 [FR-003] — <observable behaviour>`.
|
||||
- Every `SC-###` maps to an entry in `## Measurements` with a method. An SC with no
|
||||
measurement blocks `exploration.signoff`.
|
||||
- Every user story maps to at least one slice. **Slice order follows story priority**
|
||||
(P1 first) unless a technical dependency overrides it, and the override is recorded
|
||||
in the slicing plan with its reason.
|
||||
- An AC with no `FR` origin is allowed and expected — implementation-level invariants
|
||||
have no counterpart in the product spec. The converse is not: an unmapped FR is an
|
||||
incomplete spec.
|
||||
- Agents do not resolve a mismatch between the two. The product spec wins on intent,
|
||||
the feature spec wins on verification; a genuine contradiction is `blocked`.
|
||||
|
||||
## Status state machine
|
||||
|
||||
Transitions are explicit; an agent may perform only the ones marked (agent).
|
||||
|
||||
```
|
||||
brainstorm ─► draft ─► exploration ─► implement ─► qa ─► in_production
|
||||
│ │ │
|
||||
└──────────────┴────────┴──► blocked ──► (blocked_from)
|
||||
│
|
||||
└──► abandoned
|
||||
```
|
||||
|
||||
Exploration has sub-states, written dotted in `status`:
|
||||
|
||||
```
|
||||
exploration.scoping ─► exploration.iterating ─► exploration.signoff
|
||||
▲ │ │
|
||||
└───────────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- `brainstorm` → `draft`: human. Requires `<stem>.product.md` to exist with at least one
|
||||
P1 story carrying acceptance scenarios, every FR and SC id assigned, and every
|
||||
remaining `[NEEDS CLARIFICATION]` copied into the feature spec's `## Open questions`.
|
||||
- `draft` → `exploration.scoping`: human. Requires `## Problem` and `## Out of scope`.
|
||||
Scoping before the branch exists is what keeps exploration from becoming the
|
||||
long-lived branch this folder replaces.
|
||||
- `exploration.scoping` → `.iterating`: human. `experiment_ref` must name the branch.
|
||||
- `.iterating` → `.signoff`: human. Requires `## Decisions`, `## Acceptance criteria`,
|
||||
`## Rejected alternatives` and `## Measurements` to be complete, no
|
||||
`[NEEDS CLARIFICATION]` left in the product spec, and full traceability (below).
|
||||
- `.signoff` → `implement`: **only when all three sign-offs in `signoff:` are `ok`.**
|
||||
This is a join, not a sequence — a single reviewer cannot advance it. On entering
|
||||
`implement`, the exploration branch stops being a base: it is reference material,
|
||||
and implementation branches off current `main` (see "Turning a spec into PRs").
|
||||
- `.signoff` → `.iterating`: human, on any sign-off returning changes. Reset the
|
||||
rejecting role to `pending`; leave the others as they are.
|
||||
- `implement` → `qa`: (agent) when every acceptance criterion is covered, as recorded
|
||||
in `## PRs`, and the feature flag is enabled in the QA environment.
|
||||
- `qa` → `in_production`: human. The flag is on by default in production.
|
||||
- `qa` → `implement`: (agent) when a QA finding needs code. See "QA feedback".
|
||||
- any active state → `blocked`: (agent) when drift or an unresolved question prevents
|
||||
progress. Requires a drift-log entry and `blocked_from: <state>`. Do not guess and
|
||||
continue. Leaving `blocked` returns to `blocked_from`, never to a fixed state.
|
||||
- any state → `abandoned`: human only.
|
||||
|
||||
`in_production` is terminal. A production regression does not reopen the spec: it
|
||||
either is an ordinary bug fix with no spec, or it is a new spec. The acceptance
|
||||
criteria remain as the regression contract (see "QA feedback").
|
||||
|
||||
## QA feedback
|
||||
|
||||
The point of the `qa` state is not to record that testing happened. It is to make the
|
||||
spec a better regeneration source than it was before implementation. So:
|
||||
|
||||
- Every reproduced QA finding becomes **either** a new acceptance criterion **or** a
|
||||
new entry in `## Rejected alternatives`. Never a prose note, never only a bug
|
||||
tracker link.
|
||||
- (agent) may **append** ACs during `qa`, with provenance:
|
||||
`AC7 [qa: <issue ref>] — <observable behaviour>` plus its check. Appending is
|
||||
allowed; rewriting or reordering existing ACs is not.
|
||||
- A finding that invalidates a decision does not become an AC. It sets `blocked` — a
|
||||
decision is human-owned in every state.
|
||||
- A finding that is out of scope goes to `## Out of scope` with its issue ref, so the
|
||||
next regeneration does not treat it as a gap.
|
||||
|
||||
This also applies after `in_production`: a production bug that reveals a missing AC is
|
||||
appended with `[prod: <ref>]` provenance without any state change.
|
||||
|
||||
## Required sections
|
||||
|
||||
Order matters — agents read top-down and the first three sections are the contract.
|
||||
|
||||
1. **`## Problem`** — up to five bullets. What breaks today, for whom, observably,
|
||||
with the file or symbol where it breaks.
|
||||
2. **`## Decisions`** — the choices the exploration settled, each with a one-line
|
||||
rationale. **Human-owned. An agent must never add, remove or reword a decision.**
|
||||
If a decision is missing, set `blocked` and ask.
|
||||
3. **`## Acceptance criteria`** — the load-bearing section. Each item must be
|
||||
mechanically checkable and name its check:
|
||||
- `AC1 [FR-002] — <observable behaviour>` / `check: yarn vitest run src/reactions/ReactionsReader.test.tsx -t "backfill"`
|
||||
- `AC2 [FR-002] — <observable behaviour>` / `check: manual, <exact steps>`
|
||||
- `AC3 [internal] — <invariant>` / `check: yarn vitest run <file> -t "<test name>"`
|
||||
An acceptance criterion without a check is a wish; reject the spec (`blocked`).
|
||||
4. **`## Rejected alternatives`** — what the experiment tried and why it failed,
|
||||
including measurements. This exists so regeneration does not re-walk dead ends. If
|
||||
this section is empty for a spec with an `experiment_ref`, the spec is incomplete.
|
||||
5. **`## Measurements`** — numbers from the experiment with the method used to obtain
|
||||
them (command, hardware, load profile). Numbers without a method are not usable as
|
||||
regression baselines and must be marked `unverified`. A baseline states its direction: a
|
||||
count that unrelated work grows — tests passing, test files — is a floor (`≥ n`); a
|
||||
cost — latency, CPU, bundle size — is a ceiling (`≤ n`). An exact count is stale as
|
||||
soon as `main` moves and is not a drift finding.
|
||||
6. **`## Out of scope`** — explicit non-goals, so agents stop expanding the diff.
|
||||
7. **`## Slicing plan`** — ordered, independently mergeable PRs (see below).
|
||||
8. **`## Drift log`** — append-only, agent-written.
|
||||
9. **`## PRs`** — append-only list of `#<n> — <state> — <ACs covered>`.
|
||||
|
||||
Optional: `## Open questions` (blocks `exploration.signoff`), `## Migration / compat` (mandatory
|
||||
whenever wire format, event schema, or a published API changes).
|
||||
|
||||
## Turning a spec into PRs
|
||||
|
||||
Run these steps in order. Do not skip step 2.
|
||||
|
||||
1. **Fetch and pin.** `git fetch origin` and record `origin/main`'s sha as `head`.
|
||||
Never start from the exploration branch, and never rebase it — the branch is
|
||||
reference material, not a base.
|
||||
2. **Drift check** against `base..head`:
|
||||
- Do all files, symbols and modules named in the spec still exist?
|
||||
- Have the invariants in `## Decisions` been contradicted by changes in main?
|
||||
- Do the commands in `## Acceptance criteria` still run?
|
||||
Write the result as a drift-log entry. On a contradicted decision: set `blocked`,
|
||||
do not implement.
|
||||
3. **Re-derive the slicing plan** if drift changed it. Each slice must:
|
||||
- build and pass CI on its own,
|
||||
- be reviewable in one sitting (target < 400 changed lines, hard stop at 800 —
|
||||
split further rather than exceeding it),
|
||||
- map to at least one acceptance criterion, and
|
||||
- land behind a flag or be inert if it is not the last slice of a user-visible
|
||||
change.
|
||||
4. **Implement one slice at a time**, branch `spec/<spec-slug>/<n>-<short>` off the
|
||||
pinned `head`. Tests for the slice's ACs go in the same PR as the code. A PR merges
|
||||
only when every check of every AC it lists passes on that PR: automated checks in
|
||||
CI, manual checks performed by a human reviewer with the result recorded in the PR
|
||||
description. An AC is *covered* once such a PR has merged.
|
||||
5. **Open the PR** linking the spec path and listing the ACs it covers. Update
|
||||
`## PRs` and `status`.
|
||||
6. **Do not touch merged or reviewed slices.** See the regeneration rule.
|
||||
|
||||
## The regeneration rule
|
||||
|
||||
Regeneration from spec is only allowed **before a human has reviewed the PR**.
|
||||
|
||||
Once review has started, the PR is a shared artifact: rebase, fix forward, push
|
||||
incremental commits. Regenerating discards the reviewer's context and silently
|
||||
invalidates their approvals. If regeneration is unavoidable after review, close the
|
||||
old PR with an explanation and open a new one — never force-push a fresh generation
|
||||
over a reviewed branch.
|
||||
|
||||
## Cross-repo and protocol work
|
||||
|
||||
When `repos` has more than one entry, PRs are opened in `repos` order and each PR
|
||||
body states which upstream PR it depends on. Merging out of order is a spec
|
||||
violation, not an optimisation.
|
||||
|
||||
When `specs` is non-empty:
|
||||
- the implementation PR references the section of the MSC it implements,
|
||||
- unmerged MSCs require the implementation to be feature-flagged and documented as
|
||||
unstable (unstable prefixes, not stable identifiers), and
|
||||
- any deviation from the MSC text is recorded in `## Drift log`, not silently
|
||||
resolved in favour of the code.
|
||||
|
||||
## Drift-log format
|
||||
|
||||
Append-only, newest last:
|
||||
|
||||
```
|
||||
### 2026-09-14 — drift check (base 4f2a1c9 → head 9b17e02)
|
||||
- `ReactionsReader.getLastReactionEvent` now takes a `CallMembership` instead of an
|
||||
event id → AC2 and AC3 check commands still valid, slice 3 needs rework
|
||||
- BLOCKING: decision D2 assumes a membership event change may keep a hand raised; main
|
||||
now clears all reaction relations on membership replacement. Needs owner decision.
|
||||
```
|
||||
|
||||
Drift entries are never rewritten or deleted, including entries that were later
|
||||
resolved. The log is the record of why the implementation diverged from the
|
||||
exploration.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Never fill a `[NEEDS CLARIFICATION]` marker with a guess, in any state.
|
||||
- Never put file paths, symbols or library names in the product spec.
|
||||
- Never reference a file path or an internal symbol in `## Decisions` or
|
||||
`## Acceptance criteria`. Behaviour, protocol identifiers, concepts and test names
|
||||
only.
|
||||
- Never edit `<stem>.product.md` after `draft`, and never renumber an FR or SC id.
|
||||
- Never add a line that does not constrain the implementation, constrain its
|
||||
verification, or record a measured fact. It goes in the sidecar or nowhere.
|
||||
- Never modify `## Decisions`, `## Problem`, or `## Acceptance criteria`. These are
|
||||
the human contract. Propose changes in `## Open questions` and set `blocked`.
|
||||
- Never invent measurements or acceptance checks. Absent is better than plausible.
|
||||
- Never widen scope past `## Out of scope`, even when the fix looks trivial.
|
||||
- Never open a PR while `status` is `draft`, any `exploration.*`, or `blocked`.
|
||||
- Never delete a spec file. Terminal states are `in_production` or `abandoned`.
|
||||
- If the spec and current `main` disagree about how the system works, **main wins as
|
||||
description, the spec wins as intent** — record the difference and stop.
|
||||
|
||||
## Spec template
|
||||
|
||||
```markdown
|
||||
---
|
||||
status: draft
|
||||
blocked_from: null
|
||||
title:
|
||||
owner:
|
||||
signoff:
|
||||
eng: pending
|
||||
design: pending
|
||||
product: pending
|
||||
experiment_ref:
|
||||
base:
|
||||
repos: []
|
||||
specs: []
|
||||
supersedes: []
|
||||
superseded_by: null
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
## Decisions
|
||||
- D1 — <decision> — <why>
|
||||
|
||||
## Acceptance criteria
|
||||
- AC1 [FR-###] — <observable behaviour>
|
||||
- check: <command or exact manual steps>
|
||||
- AC2 [internal] — <invariant>
|
||||
- check: <command, anchored by test name>
|
||||
|
||||
## Rejected alternatives
|
||||
- <approach> — <why it failed> — <evidence>
|
||||
|
||||
## Measurements
|
||||
- <metric>: <value> (method: <command / setup>)
|
||||
|
||||
## Out of scope
|
||||
|
||||
## Slicing plan
|
||||
1. <slice> — covers AC1 — depends on: none
|
||||
|
||||
## Drift log
|
||||
|
||||
## PRs
|
||||
```
|
||||
@@ -0,0 +1,117 @@
|
||||
# Feature Specification: [FEATURE NAME]
|
||||
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
**Created**: [DATE]
|
||||
**Feature Spec**: `[YYYY-MM_Title.md]` <!-- status lives in the feature spec's frontmatter, not here -->
|
||||
**Owner**: [product handle]
|
||||
**Source**: [URL of the tracker/doc this mirrors, or "this file"]
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - [Brief Title] (Priority: P1)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - [Brief Title] (Priority: P2)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - [Brief Title] (Priority: P3)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
[Add more user stories as needed, each with an assigned priority]
|
||||
|
||||
### Edge Cases
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right edge cases.
|
||||
-->
|
||||
|
||||
- What happens when [boundary condition]?
|
||||
- How does system handle [error scenario]?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right functional requirements.
|
||||
-->
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
|
||||
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
|
||||
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
|
||||
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
||||
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
||||
|
||||
*Example of marking unclear requirements:*
|
||||
|
||||
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
||||
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||
- **[Entity 2]**: [What it represents, relationships to other entities]
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Define measurable success criteria.
|
||||
These must be technology-agnostic and measurable.
|
||||
-->
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
|
||||
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
||||
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
|
||||
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||
@@ -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
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
id: "lk-jwt-service-othersite"
|
||||
as_token: "ec_dev_as_token_othersite_0123456789ab"
|
||||
hs_token: "ec_dev_hs_token_othersite_0123456789ab"
|
||||
sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:homeserver-1" # Cover all local users
|
||||
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
|
||||
url: "http://auth-server-1:16080"
|
||||
# Enable membership look-ups via /is_joined.
|
||||
io.element.msc4502.scopes:
|
||||
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
|
||||
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
|
||||
# service.
|
||||
io.element.msc4512.proxy_prefix: "rtc/livekit"
|
||||
io.element.msc4512.proxy_url: "http://auth-server-1:16080"
|
||||
@@ -1,17 +0,0 @@
|
||||
id: "lk-jwt-service"
|
||||
as_token: "ec_dev_as_token_main_0123456789ab"
|
||||
hs_token: "ec_dev_hs_token_main_0123456789ab"
|
||||
sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:homeserver" # Cover all local users
|
||||
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
|
||||
url: "http://auth-server:6080"
|
||||
# Enable membership look-ups via /is_joined.
|
||||
io.element.msc4502.scopes:
|
||||
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
|
||||
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
|
||||
# service.
|
||||
io.element.msc4512.proxy_prefix: "rtc/livekit"
|
||||
io.element.msc4512.proxy_url: "http://auth-server:6080"
|
||||
@@ -42,13 +42,6 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,13 +42,6 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,13 +42,6 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,13 +42,6 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
+4
-28
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -15,14 +15,6 @@ services:
|
||||
# a self-signed certificate
|
||||
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
|
||||
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
|
||||
# Registers this instance as an application service on the main homeserver
|
||||
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
|
||||
- LIVEKIT_HS_SERVER_NAME=synapse.m.localhost
|
||||
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
|
||||
# location has to be given explicitly.
|
||||
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
|
||||
volumes:
|
||||
- ./backend/app-service.yaml:/etc/lk-jwt-service/app-service.yaml:Z
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
@@ -33,7 +25,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
@@ -45,14 +37,6 @@ services:
|
||||
# a self-signed certificate
|
||||
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
|
||||
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
|
||||
# Registers this instance as an application service on the other homeserver.
|
||||
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
|
||||
- LIVEKIT_HS_SERVER_NAME=synapse.othersite.m.localhost
|
||||
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
|
||||
# location has to be given explicitly.
|
||||
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
|
||||
volumes:
|
||||
- ./backend/app-service-othersite.yaml:/etc/lk-jwt-service/app-service.yaml:Z
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
@@ -110,10 +94,7 @@ services:
|
||||
|
||||
synapse:
|
||||
hostname: homeserver
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -126,16 +107,12 @@ services:
|
||||
volumes:
|
||||
- ./backend/synapse_tmp:/data:Z
|
||||
- ./backend/dev_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
- ./backend/app-service.yaml:/data/cfg/app-service.yaml:Z
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
synapse-1:
|
||||
hostname: homeserver-1
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -148,7 +125,6 @@ services:
|
||||
volumes:
|
||||
- ./backend/synapse_tmp_othersite:/data:Z
|
||||
- ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
- ./backend/app-service-othersite.yaml:/data/cfg/app-service.yaml:Z
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
|
||||
@@ -3,18 +3,12 @@
|
||||
services:
|
||||
synapse:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
synapse-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
|
||||
@@ -9,13 +9,3 @@ 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)
|
||||
|
||||
Entry point: [AGENTS.md](../AGENTS.md).
|
||||
|
||||
- [Agent workflow](./agents/workflow.md)
|
||||
- [Architecture](./agents/architecture.md)
|
||||
- [Code style](./agents/code-style.md)
|
||||
- [Testing](./agents/testing.md)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# Architecture
|
||||
|
||||
## Logic lives in view models; components render
|
||||
|
||||
- Call logic lives in `src/state/`. A component that derives call state is
|
||||
misplaced logic.
|
||||
- Composing behaviors: a factory taking `(scope, ...deps$)`, returning named
|
||||
behaviors plus callbacks. Copy `src/state/LayoutSwitchViewModel.ts`. Prefer this
|
||||
for anything new.
|
||||
- Owning a resource: a class taking the scope in its constructor — `MediaDevices`,
|
||||
`MuteStates`, `TileStore`, `Publisher`, `Connection`. Both styles are current;
|
||||
don't convert one to the other in passing.
|
||||
- `foo$` is an observable. `Behavior<T>`, an observable with a current value, is
|
||||
the default for anything a view reads.
|
||||
|
||||
## The view model / view contract
|
||||
|
||||
- Components take `vm: ViewModel<Snapshot>` and read state through `useBehavior`.
|
||||
See `CallFooter`, `InCallView`, `LobbyView`, `SettingsModal`.
|
||||
- A snapshot is `Actions & State`; every field becomes a `foo$` behavior, and none
|
||||
is optional.
|
||||
- An unavailable action is `undefined`, not a separate `canDoThing` flag — the
|
||||
presence of the callback drives the rendering.
|
||||
- Subscribe in an effect only to drive a side effect off an event stream, as
|
||||
`ReactionAudioRenderer` does; never to read state a behavior already holds.
|
||||
|
||||
## Scopes own lifetimes
|
||||
|
||||
- `ObservableScope` bounds every subscription a view model creates.
|
||||
- Reference only the scope defined in the same function; one captured from an
|
||||
enclosing scope outlives its owner, and `no-observablescope-leak` rejects it.
|
||||
|
||||
## Nothing reads the page
|
||||
|
||||
Element Call can be mounted several times inside a host's React tree, so it owns
|
||||
neither window, URL, document nor router. Each seam defaults to the old standalone
|
||||
and widget behaviour.
|
||||
|
||||
| Never | Use |
|
||||
| --------------------------------------------- | ------------------------------------------------------------- |
|
||||
| the `widget` global | `useHostBridge()` — `src/HostBridge.ts` |
|
||||
| `getUrlParams()`, `window.location` | `useUrlParams()` |
|
||||
| `useNavigate("/")`, `<Link to="/">` | `useLeaveToHome()`, `LeaveToHomeLink` |
|
||||
| `document.body` | `useRootElement()` |
|
||||
| `window.innerWidth/Height`, `useMediaQuery` | `useRootSizeMatches()` in views, `windowSize$` in view models |
|
||||
| global `i18next` | the instance in `src/utils/i18n.ts` |
|
||||
| config or analytics reading their environment | `Config.initWith()`, `PosthogAnalytics.configure()` |
|
||||
|
||||
- View models take values as options, never `getUrlParams()`
|
||||
(`callViewModelOptionsFromParams`, `CallViewModelOptions.hostBridge`).
|
||||
- The host bridge is the only channel to the host: `createWidgetHostBridge(widget)`,
|
||||
`nullHostBridge` standalone, `useComponentHostBridge`. State a capability
|
||||
(`supportsReactions`, `supportsProfileChanges`); never infer it from being a
|
||||
widget.
|
||||
- Shortcuts and portals attach to the root element, so two instances don't fight.
|
||||
- Known debt, not precedent: `Grid` measures `window.innerHeight`; `ErrorView`'s
|
||||
reload and `getAbsoluteRoomUrl` use `window.location`; recaptcha appends to
|
||||
`document.body` on the standalone login path.
|
||||
|
||||
## Context differences are options, not checks
|
||||
|
||||
- Named options with per-intent defaults (`configurationForIntent`), never a
|
||||
runtime check for who is hosting.
|
||||
- `controlledAudioDevices`, not the platform, is what makes `MediaDevices` pick
|
||||
`AndroidControlledAudioOutput` / `IOSControlledAudioOutput` over the web
|
||||
`AudioOutput`. Intent presets set it on non-desktop; standalone leaves it off.
|
||||
- URL params are a published contract: change one, update `docs/url_params.md`.
|
||||
|
||||
## Build targets
|
||||
|
||||
Code that builds in only one is a bug.
|
||||
|
||||
- `build:full` — standalone app, also widget mode.
|
||||
- `build:embedded` — `@element-hq/element-call-embedded`.
|
||||
- `build:sdk` — SDK library, entry `sdk/main.ts`.
|
||||
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
|
||||
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
|
||||
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
|
||||
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
|
||||
@@ -1,74 +0,0 @@
|
||||
# Code style
|
||||
|
||||
_Clean Code_ (Robert C. Martin): small functions, intention-revealing names, low
|
||||
noise.
|
||||
|
||||
## Reuse before you build
|
||||
|
||||
Look before you write, in this order:
|
||||
|
||||
1. **Compound Web** — `@vector-im/compound-web` for buttons, tooltips, alerts,
|
||||
dialogs, menus, form controls, avatars; icons in
|
||||
`@vector-im/compound-design-tokens/assets/web/icons`.
|
||||
2. **This repo** — `src/button/`, `src/components/`, `src/input/`, `src/form/`,
|
||||
`src/tabs/`, and `Modal`, `Avatar`, `Toast`, `Slider`, `ErrorView`,
|
||||
`FullScreenView` at the root of `src/`.
|
||||
3. **The nearest existing feature** — grep for the same shape first. A hook is
|
||||
often what you want to extract, not a component.
|
||||
|
||||
If nothing fits, name the new shared component in the PR body and say what you
|
||||
rejected and why — never add one silently. Extend or parameterise before forking,
|
||||
and extract shared logic into a hook both callers use before copying it; if you
|
||||
copy, the PR says so.
|
||||
|
||||
## Ordering
|
||||
|
||||
- **Newspaper order.** Headline first, detail down. A file opens with what it
|
||||
exists to provide.
|
||||
- **Stepdown rule.** Caller above callee. A helper used by one function sits
|
||||
beneath it, which needs a `function` declaration, not a `const` arrow. Existing
|
||||
arrow components above their callers are not a pattern to extend — nor to churn.
|
||||
- **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.
|
||||
|
||||
## TypeScript, React and RxJS idiom win on a clash
|
||||
|
||||
- Hooks stay unconditional at the top of a component.
|
||||
- `useCallback` / `useMemo` dependency arrays sometimes force inlining.
|
||||
- An RxJS pipeline stays one expression. Name the behavior, not each operator.
|
||||
- A long factory is fine when it reads as a list of named behaviors.
|
||||
`CallViewModel.ts` is long because the domain is.
|
||||
- Marble test tables are dense on purpose.
|
||||
|
||||
## Enforced by lint
|
||||
|
||||
- Copyright header on every file: `Copyright <current year> Element Creations Ltd.`
|
||||
plus the AGPL / commercial SPDX line.
|
||||
- `logger` from `matrix-js-sdk/lib/logger`, never `console`, and no top-level
|
||||
`logger.getChild()`. The `console` ban is only enforced under `src/*/**`.
|
||||
- No floating or misused promises; async functions typed `Promise<T>`.
|
||||
- Inline type imports (`import { type Foo }`), so matrix-js-sdk stays lazily
|
||||
loadable.
|
||||
- Deep-import `matrix-js-sdk/lib/<module>` as the codebase does. Banned is the bare
|
||||
`matrix-js-sdk/lib`, `lib/index` and anything under `src/`.
|
||||
|
||||
## CSS
|
||||
|
||||
- Compound components and `--cpd-*` tokens in CSS modules. A hardcoded colour or px
|
||||
spacing is a design question, not a licence to inline a hex.
|
||||
- Size against the root, not the window: `@container element-call (…)` and
|
||||
`cqw` / `cqh`, never `@media (width)` or `vw` / `vh`. Media queries stay correct
|
||||
only in standalone-only views — home, login.
|
||||
- Style `[data-element-call-root]`, never `body` or `:root`; the component build
|
||||
makes those stand for the root (`component/build/scopeStylesToRoot.ts`).
|
||||
|
||||
## Strings and a11y
|
||||
|
||||
- Strings through `t()`: add the key, run `pnpm i18n`, fill `locales/en/app.json`.
|
||||
Other locales come from Localazy; never hand-edit them.
|
||||
- `t` from `useTranslation()`, or `src/utils/i18n.ts` outside React. Never the
|
||||
`i18next` global — several instances share a page.
|
||||
- Accessible names on controls, `aria-pressed` on toggles, keyboard reachability.
|
||||
`jsx-a11y` rules are errors; `vitest-axe` is available.
|
||||
@@ -1,53 +0,0 @@
|
||||
# Testing
|
||||
|
||||
Three layers; a user-facing feature needs all three. Unit proves the logic,
|
||||
Storybook proves the rendering, e2e proves the feature. Codecov gates 80% on the
|
||||
lines a PR touches.
|
||||
|
||||
## Unit
|
||||
|
||||
- View model tests use `withTestScheduler` + `testScope()` from `src/utils/test.ts`
|
||||
and read as ASCII timelines. Copy `LayoutSwitchViewModel.test.ts`.
|
||||
- Component tests use Testing Library, queried by role and accessible name.
|
||||
- Reuse the factories in `src/utils/test.ts` — `mockRemoteParticipant`,
|
||||
`mockMatrixRoom`, `mockLivekitRoom`, `MockRTCSession`. Hand-rolled mocks drift.
|
||||
- Snapshots live in `__snapshots__/`; update with `pnpm test <Name> -u`.
|
||||
- How often something redraws is testable: drive the frames and count commits, not
|
||||
render calls. An effect with no dependency array runs once per commit.
|
||||
- `component/**/*.test.ts` runs in the same jsdom project as `src`.
|
||||
|
||||
## Storybook
|
||||
|
||||
A deliverable, not documentation: `pnpm test:storybook` runs every story as a real
|
||||
browser test in the same CI job as the unit suite. **A UI change without a story is
|
||||
incomplete.** Only three stories exist so far, so copy `CallFooter.stories.tsx`.
|
||||
|
||||
- Drive the component from a snapshot via `useStaticViewModel`, never internals.
|
||||
- One named story per state that matters — loading, error, empty, denied, mobile.
|
||||
- Assert in a `play` function with `userEvent` / `expect` from `storybook/test`.
|
||||
- Mobile is `globals: { viewport: { value: "mobile2" } }`, not a second component.
|
||||
- Expose interesting props through `argTypes`.
|
||||
|
||||
`.storybook/preview.tsx` supplies `TooltipProvider`, `src/index.css` and English
|
||||
translations. The browser is Playwright's, so a fresh clone needs
|
||||
`pnpm playwright install` once.
|
||||
|
||||
## End-to-end
|
||||
|
||||
- `playwright/*.spec.ts` — standalone, on Chromium and Firefox.
|
||||
- `playwright/widget/` — widget mode, where cross-context bugs surface.
|
||||
- `playwright/component/` — the component in a host page, via the harness on port
|
||||
3001 that Playwright starts as a second web server. Catches container-relative
|
||||
layout, styles escaping the root, two instances on a page, host-bridge reports.
|
||||
- `playwright/mobile/` — Pixel 7, `mobile` project only.
|
||||
|
||||
Test what a user observes: the peer sees the change, it survives a reconnect, it is
|
||||
right after a reload. Reuse `playwright/spa-helpers.ts`,
|
||||
`playwright/widget/test-helpers.ts`, `playwright/fixtures/`.
|
||||
|
||||
```sh
|
||||
pnpm test # unit + storybook
|
||||
pnpm backend # Synapse + LiveKit, required for e2e
|
||||
pnpm test:playwright # or :open
|
||||
pnpm dev:component # component harness, port 3001
|
||||
```
|
||||
@@ -1,50 +0,0 @@
|
||||
# Agent workflow
|
||||
|
||||
## Scratch files live in `agent-workspace/`
|
||||
|
||||
Git-ignored. One kebab-case subfolder per task, matching the branch topic:
|
||||
`agent-workspace/<slug>/`.
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `plan.md` | The concrete approach — files, symbols, edit order — once direction is confirmed |
|
||||
| `commit-msg.txt` | `git commit -F agent-workspace/<slug>/commit-msg.txt` |
|
||||
| `pr-body.md` | `gh pr create --body-file agent-workspace/<slug>/pr-body.md` |
|
||||
| `implementation-summary.md` | What was built, decisions, trade-offs |
|
||||
| `NN-prompt.md` | Raw prompt text, numbered, when worth keeping |
|
||||
|
||||
- Nothing here is durable. The folder is ignored, so anything worth keeping is
|
||||
promoted into the PR body, a doc, or a feature spec before the task ends.
|
||||
- Never write scratch files to the repo root. Stray `load_test_summary.md` and
|
||||
`config.json_` files are what this folder prevents.
|
||||
- Start `pr-body.md` from `.github/PULL_REQUEST_TEMPLATE.md` and fill every
|
||||
section.
|
||||
- Commit subjects are plain imperative English. No conventional-commits prefixes.
|
||||
- A `plan.md` and a feature spec sit at different altitudes; neither replaces the
|
||||
other. A spec in `FEATURES_SPEC/` is durable and deliberately abstract —
|
||||
behaviour, decisions, acceptance criteria — so the feature can be rebuilt against
|
||||
a `main` nobody has seen yet. A plan is one slice of it landing on today's
|
||||
`main`: the paths, symbols and edit order the spec's decisions and criteria must
|
||||
not name. `FEATURES_SPEC/AGENTS.md` wins wherever the two genuinely overlap.
|
||||
|
||||
## Hand off before the quality pass
|
||||
|
||||
- Implement the change, run the narrowest check that rules out an obviously broken
|
||||
handoff, then stop and ask whether the direction is right.
|
||||
- Full `pnpm lint`, the whole suite, coverage and benchmarks come after the
|
||||
direction is confirmed.
|
||||
- Wider refactors, extra tests and documentation polish are follow-up work, not
|
||||
part of the first handoff.
|
||||
- If a check is needed before feedback, keep it to the touched code and say why.
|
||||
|
||||
## Commit and PR readiness
|
||||
|
||||
- Commit once the user confirms direction, or asks for one. Not before. Where the
|
||||
setup has no git identity, a sandbox included, write the message to
|
||||
`commit-msg.txt` and hand it over instead.
|
||||
- Ready means every gate in [AGENTS.md](../../AGENTS.md#gates) is green and the
|
||||
change is covered at the layers [testing.md](./testing.md) asks for. Read the diff
|
||||
against [code-style.md](./code-style.md) first.
|
||||
- Re-run the whole checklist after any fix. Only on green, either way.
|
||||
- Once a human has started reviewing, fix forward — never force-push a
|
||||
regeneration over a review in progress.
|
||||
@@ -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
|
||||
|
||||
+1
-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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,8 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers";
|
||||
|
||||
// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
@@ -19,12 +17,6 @@ test("can only interact with header and footer while reconnecting", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// The reconnecting state is entered via the probablyLeft timer, which
|
||||
// mirrors the delayed leave event's timeout. With delegation that timeout
|
||||
// is one hour, putting it out of reach of the clock fast-forward below.
|
||||
// Keep the leave client-managed so its short timeout applies.
|
||||
await SpaHelpers.disableLeaveDelegation(page);
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill("Test call");
|
||||
await page.getByTestId("home_displayName").click();
|
||||
|
||||
@@ -19,7 +19,6 @@ async function setupTwoUserSpaCall(
|
||||
browser: Browser,
|
||||
page: Page,
|
||||
browserName: string,
|
||||
opts: { disableGuestLeaveDelegation?: boolean } = {},
|
||||
): Promise<{ guestPage: Page }> {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
@@ -53,9 +52,6 @@ async function setupTwoUserSpaCall(
|
||||
|
||||
await guestPage.goto("/");
|
||||
|
||||
if (opts.disableGuestLeaveDelegation)
|
||||
await SpaHelpers.disableLeaveDelegation(guestPage);
|
||||
|
||||
let pevaraHasSentStickyEvent = false;
|
||||
|
||||
const pevaraResolver = Promise.withResolvers<void>();
|
||||
@@ -106,13 +102,7 @@ test("One to One rejoin after improper leave does not crash EC", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
// With delegation, the backend sends the guest's delayed leave event within
|
||||
// moments of the improper leave, so the stale membership this test needs
|
||||
// would be cleaned up before the rejoin. Keep the guest's leave
|
||||
// client-managed so the stale membership lingers.
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName, {
|
||||
disableGuestLeaveDelegation: true,
|
||||
});
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
|
||||
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
@@ -111,24 +111,6 @@ async function setRtcModeFromSettings(
|
||||
await page.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the delayed-leave delegation support probes fail so that the client
|
||||
* manages its delayed leave event itself instead of delegating it to the
|
||||
* backend.
|
||||
*
|
||||
* Must be installed before the page joins a call.
|
||||
*/
|
||||
async function disableLeaveDelegation(page: Page): Promise<void> {
|
||||
// Covers both the transport probe (<livekit_service_url>/delegate_delayed_leave)
|
||||
// and the homeserver probe (MSC4195, .../rtc/livekit/delegate_delayed_leave).
|
||||
await page.route("**/delegate_delayed_leave", async (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect a certain number of video tiles to be present and visible.
|
||||
*/
|
||||
@@ -151,6 +133,5 @@ export const SpaHelpers = {
|
||||
createCall,
|
||||
getCallInviteLink,
|
||||
joinCallFromInviteLink,
|
||||
disableLeaveDelegation,
|
||||
expectVideoTilesCount,
|
||||
};
|
||||
|
||||
Generated
+14
-34
@@ -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
|
||||
@@ -42,13 +39,13 @@ importers:
|
||||
version: 11.7.12
|
||||
'@livekit/components-core':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
version: 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-react':
|
||||
specifier: ^2.0.0
|
||||
version: 2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
version: 2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
'@livekit/track-processors':
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))
|
||||
'@mediapipe/tasks-vision':
|
||||
specifier: ^0.10.18
|
||||
version: 0.10.35
|
||||
@@ -186,7 +183,7 @@ importers:
|
||||
version: 5.88.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(typescript@5.9.3)
|
||||
livekit-client:
|
||||
specifier: ^2.18.1
|
||||
version: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
version: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.18.1
|
||||
@@ -412,13 +409,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 +3218,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==}
|
||||
|
||||
@@ -4618,8 +4605,8 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
livekit-client@2.22.3:
|
||||
resolution: {integrity: sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==}
|
||||
livekit-client@2.22.2:
|
||||
resolution: {integrity: sha512-o3yLYoH2eHVR/DmIZzgC96FMvtR4+g/DjeqNwS5zp12EjqrqiRULxm6Oq/YsTE97WnA6fhy23+a2hGkbce5SPA==}
|
||||
peerDependencies:
|
||||
'@types/dom-mediacapture-record': ^1
|
||||
|
||||
@@ -6372,11 +6359,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': {}
|
||||
@@ -7097,21 +7079,21 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@livekit/components-core@0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||
'@livekit/components-core@0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
loglevel: 1.9.1
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
clsx: 2.1.1
|
||||
events: 3.3.0
|
||||
jose: 6.2.9
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tslib: 2.8.1
|
||||
@@ -7123,11 +7105,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 1.10.1
|
||||
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))':
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))':
|
||||
dependencies:
|
||||
'@mediapipe/tasks-vision': 0.10.35
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
|
||||
|
||||
@@ -8704,8 +8686,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': {}
|
||||
@@ -10200,7 +10180,7 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22):
|
||||
livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22):
|
||||
dependencies:
|
||||
'@livekit/mutex': 1.1.1
|
||||
'@livekit/protocol': 1.50.4
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import {
|
||||
setLogExtension as setLKLogExtension,
|
||||
setLogLevel as setLKLogLevel,
|
||||
LoggerNames as LKLoggerNames,
|
||||
} from "livekit-client";
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
@@ -227,9 +226,6 @@ export class Initializer {
|
||||
|
||||
enableExtendedLivekitLogs.value$.subscribe((enabled) => {
|
||||
setLKLogLevel(enabled ? "trace" : "info");
|
||||
// ICE candidate types and connection states are what you need to diagnose
|
||||
// "could not establish pc connection", so always keep those in the rageshake
|
||||
setLKLogLevel("debug", LKLoggerNames.ICE);
|
||||
});
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
@@ -24,12 +24,10 @@ import { testAudioContext } from "../useAudioContext.test";
|
||||
import * as MediaDevicesContext from "../MediaDevicesContext";
|
||||
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
|
||||
import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRemoteParticipant,
|
||||
mockTrack,
|
||||
} from "../utils/test";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
export const TestAudioContextConstructor = vi.fn(
|
||||
@@ -133,57 +131,6 @@ it("should render for member", () => {
|
||||
expect(queryAllByTestId("audio")).toHaveLength(1);
|
||||
});
|
||||
|
||||
function spyOnWarn(): ReturnType<typeof vi.fn> {
|
||||
const warn = vi.fn();
|
||||
vi.spyOn(rootLogger, "getChild").mockReturnValue({
|
||||
warn,
|
||||
} as unknown as typeof rootLogger);
|
||||
return warn;
|
||||
}
|
||||
|
||||
it("should not render or warn for the local participant", () => {
|
||||
const warn = spyOnWarn();
|
||||
const local = mockLocalParticipant({ identity: "@alice:DEV0" });
|
||||
vi.mocked(useTracks).mockReturnValue([mockTrack(local)]);
|
||||
const { queryAllByTestId } = render(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<LivekitRoomAudioRenderer
|
||||
validIdentities={[]}
|
||||
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
|
||||
url={""}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(queryAllByTestId("audio")).toHaveLength(0);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should warn only once per unexpected participant", () => {
|
||||
const warn = spyOnWarn();
|
||||
const { rerender } = renderTestComponent(
|
||||
[{ userId: "@bob", deviceId: "DEV0" }],
|
||||
["@alice:DEV0"],
|
||||
[
|
||||
{
|
||||
participantId: "@alice:DEV0",
|
||||
kind: Track.Kind.Audio,
|
||||
source: Track.Source.Microphone,
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
rerender(
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<LivekitRoomAudioRenderer
|
||||
validIdentities={[]}
|
||||
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
|
||||
url={""}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not render without member", () => {
|
||||
const { container, queryAllByTestId } = renderTestComponent(
|
||||
[{ userId: "@bob", deviceId: "DEV0" }],
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { getTrackReferenceId } from "@livekit/components-core";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { type RemoteAudioTrack, Track } from "livekit-client";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useTracks,
|
||||
AudioTrack,
|
||||
@@ -60,9 +60,6 @@ export function LivekitRoomAudioRenderer({
|
||||
muted,
|
||||
}: MatrixAudioRendererProps): ReactNode {
|
||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||
// Identities we have already warned about, so that re-renders (which happen
|
||||
// on every active speaker update) don't repeat the warning.
|
||||
const warnedIdentities = useRef(new Set<string>());
|
||||
const tracks = useTracks(
|
||||
[
|
||||
Track.Source.Microphone,
|
||||
@@ -75,27 +72,22 @@ export function LivekitRoomAudioRenderer({
|
||||
room: livekitRoom,
|
||||
},
|
||||
)
|
||||
// Only keep remote audio tracks (we never render our own audio)
|
||||
.filter(
|
||||
(ref) =>
|
||||
ref.publication.kind === Track.Kind.Audio && !ref.participant.isLocal,
|
||||
)
|
||||
// Only keep audio tracks
|
||||
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
|
||||
// Only keep tracks from participants that are in the validIdentities list
|
||||
.filter((ref) => {
|
||||
const { identity } = ref.participant;
|
||||
const isValid = validIdentities.includes(identity);
|
||||
if (!isValid && !warnedIdentities.current.has(identity)) {
|
||||
warnedIdentities.current.add(identity);
|
||||
const isValid = validIdentities.includes(ref.participant.identity);
|
||||
if (!isValid) {
|
||||
// TODO make sure to also skip the warn logging for the local identity
|
||||
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
|
||||
logger.warn(
|
||||
`Audio track ${identity} from ${url} has no matching matrix call member`,
|
||||
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
|
||||
`current members: ${validIdentities.join()}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
} else if (isValid) {
|
||||
warnedIdentities.current.delete(identity);
|
||||
return false;
|
||||
}
|
||||
return isValid;
|
||||
return true;
|
||||
});
|
||||
|
||||
// This component is also (in addition to the "only play audio for connected members" logic above)
|
||||
|
||||
@@ -1,87 +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 { type LocalVideoTrack } from "livekit-client";
|
||||
import {
|
||||
type BackgroundOptions,
|
||||
type ProcessorWrapper,
|
||||
} from "@livekit/track-processors";
|
||||
|
||||
import { applyProcessor, trackProcessorSync } from "./TrackProcessorContext";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { flushPromises, testScope } from "../utils/test";
|
||||
|
||||
const processor = {} as ProcessorWrapper<BackgroundOptions>;
|
||||
|
||||
function mockTrack(
|
||||
readyState: MediaStreamTrackState,
|
||||
current?: ProcessorWrapper<BackgroundOptions>,
|
||||
): LocalVideoTrack {
|
||||
return {
|
||||
mediaStreamTrack: { readyState },
|
||||
getProcessor: vi.fn().mockReturnValue(current),
|
||||
setProcessor: vi.fn().mockResolvedValue(undefined),
|
||||
stopProcessor: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as LocalVideoTrack;
|
||||
}
|
||||
|
||||
describe("applyProcessor", () => {
|
||||
it("attaches the processor to a live track", () => {
|
||||
const track = mockTrack("live");
|
||||
applyProcessor(track, processor);
|
||||
expect(track.setProcessor).toHaveBeenCalledWith(processor);
|
||||
});
|
||||
|
||||
it("does not attach the processor to an ended track", () => {
|
||||
const track = mockTrack("ended");
|
||||
applyProcessor(track, processor);
|
||||
expect(track.setProcessor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not surface a rejected setProcessor", async () => {
|
||||
const track = mockTrack("live");
|
||||
vi.mocked(track.setProcessor).mockRejectedValue(
|
||||
new TypeError("Input track cannot be ended"),
|
||||
);
|
||||
const unhandled = vi.fn();
|
||||
process.on("unhandledRejection", unhandled);
|
||||
applyProcessor(track, processor);
|
||||
await flushPromises();
|
||||
process.off("unhandledRejection", unhandled);
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops the processor when none is wanted", () => {
|
||||
const track = mockTrack("live", processor);
|
||||
applyProcessor(track, undefined);
|
||||
expect(track.stopProcessor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not surface a rejected stopProcessor", async () => {
|
||||
const track = mockTrack("live", processor);
|
||||
vi.mocked(track.stopProcessor).mockRejectedValue(new Error("nope"));
|
||||
const unhandled = vi.fn();
|
||||
process.on("unhandledRejection", unhandled);
|
||||
applyProcessor(track, undefined);
|
||||
await flushPromises();
|
||||
process.off("unhandledRejection", unhandled);
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trackProcessorSync", () => {
|
||||
it("applies the processor to the current track", () => {
|
||||
const track = mockTrack("live");
|
||||
trackProcessorSync(
|
||||
testScope(),
|
||||
constant(track),
|
||||
constant({ supported: true, processor }),
|
||||
);
|
||||
expect(track.setProcessor).toHaveBeenCalledWith(processor);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, map, type Observable } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
|
||||
@@ -66,34 +65,6 @@ export function useTrackProcessorObservable$(): Observable<ProcessorState> {
|
||||
return state$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches or detaches the processor so that the track matches the desired
|
||||
* state, without throwing.
|
||||
*/
|
||||
export function applyProcessor(
|
||||
videoTrack: LocalVideoTrack,
|
||||
processor: ProcessorWrapper<BackgroundOptions> | undefined,
|
||||
): void {
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
// A MediaStreamTrackProcessor cannot be constructed on an ended track
|
||||
// (e.g. the camera was stopped while the processor was being applied),
|
||||
// and setProcessor rejects with a TypeError. The track is going away
|
||||
// anyway, so there is nothing to attach to.
|
||||
if (videoTrack.mediaStreamTrack.readyState === "ended") {
|
||||
logger.debug("Not attaching video processor to an ended track");
|
||||
return;
|
||||
}
|
||||
videoTrack.setProcessor(processor).catch((e) => {
|
||||
logger.warn("Failed to attach video processor", e);
|
||||
});
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
videoTrack.stopProcessor().catch((e) => {
|
||||
logger.warn("Failed to stop video processor", e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates your video tracks to always use the given processor.
|
||||
*/
|
||||
@@ -107,7 +78,13 @@ export const trackProcessorSync = (
|
||||
.subscribe(([videoTrack, processorState]) => {
|
||||
if (!processorState) return;
|
||||
if (!videoTrack) return;
|
||||
applyProcessor(videoTrack, processorState.processor);
|
||||
const { processor } = processorState;
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
void videoTrack.setProcessor(processor);
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
void videoTrack.stopProcessor();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -117,7 +94,12 @@ export const useTrackProcessorSync = (
|
||||
const { processor } = useTrackProcessor();
|
||||
useEffect(() => {
|
||||
if (!videoTrack) return;
|
||||
applyProcessor(videoTrack, processor);
|
||||
if (processor && !videoTrack.getProcessor()) {
|
||||
void videoTrack.setProcessor(processor);
|
||||
}
|
||||
if (!processor && videoTrack.getProcessor()) {
|
||||
void videoTrack.stopProcessor();
|
||||
}
|
||||
}, [processor, videoTrack]);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -42,10 +42,7 @@ import {
|
||||
type CallViewModelOptions,
|
||||
} from "../state/CallViewModel/CallViewModel";
|
||||
import { alice, local } from "../utils/test-fixtures";
|
||||
import {
|
||||
jsSdkReactionsTimeline,
|
||||
ReactionsSenderProvider,
|
||||
} from "../reactions/useReactionsSender";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
@@ -163,7 +160,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
footerVm={footerVm}
|
||||
developerSettingsVm={developerSettingsVm}
|
||||
matrixInfo={matrixInfo}
|
||||
roomId={room.roomId}
|
||||
matrixRoom={room}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
@@ -175,9 +172,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
ownIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
||||
ownMembershipEventId={undefined}
|
||||
timeline={jsSdkReactionsTimeline(client, room.roomId)}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<RoomContext value={livekitRoom}>{content}</RoomContext>
|
||||
@@ -253,8 +248,7 @@ describe("ActiveCall", () => {
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
rtcParticipationManager={null}
|
||||
roomId={matrixRoom.roomId}
|
||||
matrixRoom={matrixRoom}
|
||||
muteStates={mockMuteStates()}
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
@@ -304,8 +298,7 @@ describe("ActiveCall", () => {
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
rtcParticipationManager={null}
|
||||
roomId={matrixRoom.roomId}
|
||||
matrixRoom={matrixRoom}
|
||||
muteStates={mockMuteStates()}
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
|
||||
+40
-132
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
type CallViewModel,
|
||||
callViewModelOptionsFromParams,
|
||||
createCallViewModel$,
|
||||
createJsClientCallViewModel$,
|
||||
} from "../state/CallViewModel/CallViewModel.ts";
|
||||
import { Grid, type TileProps } from "../grid/Grid";
|
||||
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
@@ -87,21 +86,12 @@ import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||
import { SettingsIconButton } from "../button/Button.tsx";
|
||||
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
|
||||
import { type RtcParticipationManager } from "../state/rtc/RtcParticipationManager.ts";
|
||||
import { useOptionalMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
|
||||
import { ParticipationReactionsReader } from "../reactions/ParticipationReactionsReader.ts";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
|
||||
import { jsSdkReactionsTimeline } from "../reactions/useReactionsSender.tsx";
|
||||
import { CallViewModelImplementation } from "../config/ConfigOptions.ts";
|
||||
import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel.ts";
|
||||
import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab.tsx";
|
||||
import { type ViewModel } from "../state/ViewModel.ts";
|
||||
import { RingingStatus } from "../tile/RingingStatus.tsx";
|
||||
import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
|
||||
|
||||
/** What `ownMembership$` reads as when matrix-js-sdk carries the call. */
|
||||
const NO_OWN_MEMBERSHIP = constant(null);
|
||||
|
||||
declare module "react" {
|
||||
interface CSSProperties {
|
||||
"--call-view-safe-area-inset-top"?: string;
|
||||
@@ -114,12 +104,6 @@ export interface ActiveCallProps extends Omit<
|
||||
"vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm"
|
||||
> {
|
||||
e2eeSystem: EncryptionSystem;
|
||||
/**
|
||||
* The crate's participation manager for the session when the Rust
|
||||
* implementation carries this call (see `CallViewModelImplementation`); null when
|
||||
* matrix-js-sdk's `rtcSession` does.
|
||||
*/
|
||||
rtcParticipationManager: RtcParticipationManager | null;
|
||||
// TODO refactor those reasons into an enum
|
||||
onLeft: (
|
||||
reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error",
|
||||
@@ -141,77 +125,34 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
// The element we have to draw the call in: the page, or the container a host
|
||||
// gave us. Its size, not the window's, decides how the call is laid out.
|
||||
const rootElement = useRootElement();
|
||||
// The drivers, where a host provided them; required with a participation manager.
|
||||
const drivers = useOptionalMatrixDrivers();
|
||||
const { rtcParticipationManager, rtcSession, client, roomId } = props;
|
||||
if (rtcParticipationManager !== null && drivers === null)
|
||||
throw new Error(
|
||||
"A call over the matrix-rtc crate needs the Matrix drivers to be provided",
|
||||
);
|
||||
if (
|
||||
rtcParticipationManager === null &&
|
||||
(rtcSession === undefined || client === undefined)
|
||||
)
|
||||
throw new Error(
|
||||
"A call needs either a participation (the crate) or a matrix-js-sdk client and session",
|
||||
);
|
||||
useEffect(() => {
|
||||
rootLogger.info("START CALL VIEW SCOPE");
|
||||
const scope = new ObservableScope();
|
||||
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
|
||||
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
||||
urlParams;
|
||||
const options = {
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
hostBridge,
|
||||
autoLeaveWhenOthersLeft,
|
||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||
// We merely sample the current mode here, so the user would need to
|
||||
// manually rejoin to switch to a different one.
|
||||
matrixRTCMode: matrixRTCModeSetting.value$.value,
|
||||
windowSize$: scope.behavior(observeElementSize$(rootElement)),
|
||||
};
|
||||
|
||||
let vm: CallViewModel;
|
||||
if (rtcParticipationManager !== null && drivers !== null) {
|
||||
rootLogger.info(
|
||||
`Call view model implementation: ${CallViewModelImplementation.MatrixRtc}`,
|
||||
);
|
||||
const reactionsReader = new ParticipationReactionsReader(
|
||||
scope,
|
||||
rtcParticipationManager,
|
||||
drivers.clientDriver,
|
||||
);
|
||||
vm = createCallViewModel$(
|
||||
scope,
|
||||
rtcParticipationManager,
|
||||
drivers.clientDriver,
|
||||
mediaDevices,
|
||||
props.muteStates,
|
||||
options,
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
scope.behavior(trackProcessorState$),
|
||||
);
|
||||
} else if (rtcSession !== undefined) {
|
||||
rootLogger.info(
|
||||
`Call view model implementation: ${CallViewModelImplementation.MatrixJsSdk}`,
|
||||
);
|
||||
const reactionsReader = new ReactionsReader(scope, rtcSession);
|
||||
vm = createJsClientCallViewModel$(
|
||||
scope,
|
||||
rtcSession,
|
||||
rtcSession.room,
|
||||
mediaDevices,
|
||||
props.muteStates,
|
||||
options,
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
scope.behavior(trackProcessorState$),
|
||||
);
|
||||
} else {
|
||||
return; // unreachable: checked above
|
||||
}
|
||||
const vm = createCallViewModel$(
|
||||
scope,
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
mediaDevices,
|
||||
props.muteStates,
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
hostBridge,
|
||||
autoLeaveWhenOthersLeft,
|
||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||
// We merely sample the current mode here, so the user would need to
|
||||
// manually rejoin to switch to a different one.
|
||||
matrixRTCMode: matrixRTCModeSetting.value$.value,
|
||||
windowSize$: scope.behavior(observeElementSize$(rootElement)),
|
||||
},
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
scope.behavior(trackProcessorState$),
|
||||
);
|
||||
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
|
||||
vm.join();
|
||||
setVm(vm);
|
||||
@@ -222,8 +163,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
scope.end();
|
||||
};
|
||||
}, [
|
||||
rtcSession,
|
||||
client,
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
props.muteStates,
|
||||
props.e2eeSystem,
|
||||
props.onLeft,
|
||||
@@ -231,39 +172,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
hostBridge,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
props.client,
|
||||
rootElement,
|
||||
rtcParticipationManager,
|
||||
drivers,
|
||||
]);
|
||||
|
||||
// Who we are, as the tiles, hands and reactions are keyed. Both drivers
|
||||
// and client name the same identity; the drivers are always there.
|
||||
const ownIdentifier =
|
||||
drivers !== null
|
||||
? `${drivers.clientDriver.userId}:${drivers.clientDriver.deviceId}`
|
||||
: `${client?.getUserId()}:${client?.getDeviceId()}`;
|
||||
|
||||
// Reactions relate to our current membership event, wherever that lives.
|
||||
const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const ownParticipationMembership = useBehavior(
|
||||
rtcParticipationManager?.ownMembership$ ?? NO_OWN_MEMBERSHIP,
|
||||
);
|
||||
const ownMembershipEventId =
|
||||
rtcParticipationManager !== null
|
||||
? ownParticipationMembership?.member.eventId
|
||||
: jsSdkMemberships.find(
|
||||
(m) =>
|
||||
m.userId === client?.getUserId() &&
|
||||
m.deviceId === client?.getDeviceId(),
|
||||
)?.eventId;
|
||||
const reactionsTimeline = useMemo(
|
||||
() =>
|
||||
rtcParticipationManager === null && client !== undefined
|
||||
? jsSdkReactionsTimeline(client, roomId)
|
||||
: drivers!.clientDriver,
|
||||
[rtcParticipationManager, drivers, client, roomId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (vm === null) return;
|
||||
|
||||
@@ -273,7 +185,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
vm,
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
ownIdentifier,
|
||||
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
||||
{ showControls: urlParams.showControls, header: urlParams.header },
|
||||
);
|
||||
setFooterVm(footerVm);
|
||||
@@ -283,15 +195,15 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
scope.end();
|
||||
};
|
||||
}, [
|
||||
rtcSession,
|
||||
client,
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
props.muteStates,
|
||||
props.e2eeSystem,
|
||||
props.onLeft,
|
||||
urlParams,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
ownIdentifier,
|
||||
props.client,
|
||||
vm,
|
||||
]);
|
||||
|
||||
@@ -300,12 +212,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
if (developerSettingsVm === null) return null;
|
||||
|
||||
return (
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
ownIdentifier={ownIdentifier}
|
||||
ownMembershipEventId={ownMembershipEventId}
|
||||
timeline={reactionsTimeline}
|
||||
>
|
||||
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||
<InCallView
|
||||
{...props}
|
||||
vm={vm}
|
||||
@@ -317,15 +224,13 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
};
|
||||
|
||||
export interface InCallViewProps {
|
||||
/** The matrix-js-sdk client, when that implementation carries the call. */
|
||||
client?: MatrixClient;
|
||||
client: MatrixClient;
|
||||
vm: CallViewModel;
|
||||
footerVm: ViewModel<FooterSnapshot>;
|
||||
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
|
||||
matrixInfo: MatrixInfo;
|
||||
/** The matrix-js-sdk session, when that implementation carries the call. */
|
||||
rtcSession?: MatrixRTCSession;
|
||||
roomId: string;
|
||||
rtcSession: MatrixRTCSession;
|
||||
matrixRoom: MatrixRoom;
|
||||
muteStates: MuteStates;
|
||||
onShareClick: (() => void) | null;
|
||||
}
|
||||
@@ -336,7 +241,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
footerVm,
|
||||
developerSettingsVm,
|
||||
matrixInfo,
|
||||
roomId,
|
||||
matrixRoom,
|
||||
muteStates,
|
||||
onShareClick,
|
||||
}) => {
|
||||
@@ -505,6 +410,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
[styles.hidden]: !showHeader,
|
||||
})}
|
||||
ref={headerRef}
|
||||
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
|
||||
>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
@@ -728,7 +634,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(roomId);
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
matrixRoom.roomId,
|
||||
);
|
||||
|
||||
useAppBarSecondaryButton(
|
||||
<SettingsIconButton
|
||||
@@ -784,7 +692,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
<SettingsModal
|
||||
client={client}
|
||||
roomId={roomId}
|
||||
roomId={matrixRoom.roomId}
|
||||
open={settingsOpen}
|
||||
onDismiss={(): void => setSettingsOpen(false)}
|
||||
tab={settingsTab}
|
||||
|
||||
@@ -7,27 +7,25 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { type Room } from "matrix-js-sdk";
|
||||
import { axe } from "vitest-axe";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
// Used by copy-to-clipboard
|
||||
window.prompt = (): null => null;
|
||||
|
||||
test("InviteModal is accessible", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = {
|
||||
roomId: "!a:example.org",
|
||||
name: "Mission Control",
|
||||
} as unknown as Room;
|
||||
const onDismiss = vi.fn();
|
||||
const { container } = render(
|
||||
<InviteModal
|
||||
roomId="!a:example.org"
|
||||
roomName="Mission Control"
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
open={true}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
<InviteModal room={room} open={true} onDismiss={onDismiss} />,
|
||||
{ wrapper: BrowserRouter },
|
||||
);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Room } from "matrix-js-sdk";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
import {
|
||||
LinkIcon,
|
||||
@@ -24,29 +25,22 @@ import { Modal } from "../Modal";
|
||||
import { getAbsoluteRoomUrl } from "../utils/matrix";
|
||||
import styles from "./InviteModal.module.css";
|
||||
import { Toast } from "../Toast";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { QrCode } from "../QrCode";
|
||||
|
||||
interface Props {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
e2eeSystem: EncryptionSystem;
|
||||
room: Room;
|
||||
open: boolean;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const InviteModal: FC<Props> = ({
|
||||
roomId,
|
||||
roomName,
|
||||
e2eeSystem,
|
||||
open,
|
||||
onDismiss,
|
||||
}) => {
|
||||
export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
|
||||
const { t } = useTranslation();
|
||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||
|
||||
const url = useMemo(
|
||||
() => getAbsoluteRoomUrl(roomId, e2eeSystem, roomName),
|
||||
[e2eeSystem, roomName, roomId],
|
||||
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
|
||||
[e2eeSystem, room.name, room.roomId],
|
||||
);
|
||||
const [toastOpen, setToastOpen] = useState(false);
|
||||
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
|
||||
|
||||
@@ -64,7 +64,7 @@ export const KnockLobbyView: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<LobbyView
|
||||
developerSettingsClient={client}
|
||||
client={client}
|
||||
matrixInfo={{
|
||||
userId: client.getUserId() ?? "",
|
||||
displayName: profile.displayName,
|
||||
|
||||
@@ -84,7 +84,7 @@ function renderLobbyView(
|
||||
const hideHeader = withAppBar ? true : false;
|
||||
const lobbyView = (
|
||||
<LobbyView
|
||||
developerSettingsClient={mockClient}
|
||||
client={mockClient}
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => {}}
|
||||
|
||||
+11
-10
@@ -53,8 +53,7 @@ import { type ViewModel } from "../state/ViewModel";
|
||||
import { useAppBarPrimaryButtonIconKind } from "../AppBar";
|
||||
|
||||
interface Props {
|
||||
/** The matrix-js-sdk client, for what the developer settings still read from it. */
|
||||
developerSettingsClient?: MatrixClient;
|
||||
client: MatrixClient;
|
||||
matrixInfo: MatrixInfo;
|
||||
muteStates: MuteStates;
|
||||
onEnter: () => void;
|
||||
@@ -67,7 +66,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const LobbyView: FC<Props> = ({
|
||||
developerSettingsClient,
|
||||
client,
|
||||
matrixInfo,
|
||||
muteStates,
|
||||
onEnter,
|
||||
@@ -259,13 +258,15 @@ export const LobbyView: FC<Props> = ({
|
||||
</CallFooter>
|
||||
)}
|
||||
</div>
|
||||
<SettingsModal
|
||||
client={developerSettingsClient}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
tab={settingsTab}
|
||||
onTabChange={setSettingsTab}
|
||||
/>
|
||||
{client && (
|
||||
<SettingsModal
|
||||
client={client}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
tab={settingsTab}
|
||||
onTabChange={setSettingsTab}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-31
@@ -6,14 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { type FC, useEffect, useState, type ReactNode, useRef } from "react";
|
||||
import { type MatrixError } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -23,10 +16,6 @@ import { useClientLegacy } from "../ClientContext";
|
||||
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
||||
import { RoomAuthView } from "./RoomAuthView";
|
||||
import { CallView } from "./CallView";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { MatrixDriverProvider } from "../driver/MatrixDriverContext";
|
||||
import { useJsSdkDrivers } from "../driver/jsSdk/useJsSdkDrivers";
|
||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||
import { HomePage } from "../home/HomePage";
|
||||
@@ -113,7 +102,7 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
<LoadedCall
|
||||
<CallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
@@ -201,21 +190,3 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
if (!roomIdOrAlias) return <HomePage />;
|
||||
return groupCallView();
|
||||
};
|
||||
|
||||
/**
|
||||
* The call, with the matrix-js-sdk drivers over this page's client provided
|
||||
* for the Rust implementation (see `CallViewModelImplementation`).
|
||||
*/
|
||||
const LoadedCall: FC<
|
||||
ComponentProps<typeof CallView> & {
|
||||
client: MatrixClient;
|
||||
rtcSession: MatrixRTCSession;
|
||||
}
|
||||
> = (props) => {
|
||||
const drivers = useJsSdkDrivers(props.client, props.rtcSession.room);
|
||||
return (
|
||||
<MatrixDriverProvider value={drivers}>
|
||||
<CallView {...props} />
|
||||
</MatrixDriverProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { JoinRule, Room } from "matrix-js-sdk";
|
||||
import { useRoomState } from "./useRoomState";
|
||||
|
||||
export function useJoinRule(room: Room): JoinRule {
|
||||
return useRoomState(
|
||||
room,
|
||||
useCallback((state) => state.getJoinRule(), []),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { type Room } from "matrix-js-sdk";
|
||||
|
||||
import { useRoomState } from "./useRoomState";
|
||||
|
||||
export function useRoomAvatar(room: Room): string | null {
|
||||
return useRoomState(
|
||||
room,
|
||||
useCallback(() => room.getMxcAvatarUrl(), [room]),
|
||||
);
|
||||
}
|
||||
@@ -1,64 +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 { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { MatrixDriverProvider } from "../driver/MatrixDriverContext";
|
||||
import { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
|
||||
import { MockRtcMatrixDriver } from "../driver/MockRtcMatrixDriver";
|
||||
import { useOwnProfile } from "../profile/useOwnProfile";
|
||||
import { useRoomInfo } from "./useRoomInfo";
|
||||
|
||||
function providing(clientDriver: MockElementCallMatrixClientDriver) {
|
||||
return ({ children }: { children: ReactNode }): ReactNode => (
|
||||
<MatrixDriverProvider
|
||||
value={{ rtcDriver: new MockRtcMatrixDriver(), clientDriver }}
|
||||
>
|
||||
{children}
|
||||
</MatrixDriverProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useRoomInfo", () => {
|
||||
it("reads the room from the client driver and follows its changes", () => {
|
||||
const clientDriver = new MockElementCallMatrixClientDriver({
|
||||
roomInfo: { name: "Mission Control", joinRule: "invite" },
|
||||
});
|
||||
const { result } = renderHook(() => useRoomInfo(), {
|
||||
wrapper: providing(clientDriver),
|
||||
});
|
||||
expect(result.current).toMatchObject({
|
||||
name: "Mission Control",
|
||||
joinRule: "invite",
|
||||
});
|
||||
act(() => clientDriver.setRoomInfo({ name: "Launch Control" }));
|
||||
expect(result.current.name).toBe("Launch Control");
|
||||
});
|
||||
|
||||
it("throws without drivers", () => {
|
||||
expect(() => renderHook(() => useRoomInfo())).toThrow(/No Matrix drivers/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useOwnProfile", () => {
|
||||
it("reads our own profile and follows its changes", () => {
|
||||
const clientDriver = new MockElementCallMatrixClientDriver({
|
||||
ownProfile: { displayName: "Me", avatarUrl: "mxc://x/me" },
|
||||
});
|
||||
const { result } = renderHook(() => useOwnProfile(), {
|
||||
wrapper: providing(clientDriver),
|
||||
});
|
||||
expect(result.current).toEqual({
|
||||
displayName: "Me",
|
||||
avatarUrl: "mxc://x/me",
|
||||
});
|
||||
act(() => clientDriver.setOwnProfile({ displayName: "Myself" }));
|
||||
expect(result.current.displayName).toBe("Myself");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +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 RoomDriver,
|
||||
type RoomInfo,
|
||||
} from "../driver/ElementCallMatrixClientDriver";
|
||||
import { useMatrixDrivers } from "../driver/MatrixDriverContext";
|
||||
|
||||
/**
|
||||
* The room's name, alias, avatar, join rule, encryption and whether we may
|
||||
* open its slot, from the given room driver, kept current.
|
||||
*/
|
||||
export function useRoomInfoFrom(room: RoomDriver): RoomInfo {
|
||||
const [info, setInfo] = useState(() => room.getRoomInfo());
|
||||
useEffect(() => {
|
||||
setInfo(room.getRoomInfo());
|
||||
return room.subscribeRoomInfo(setInfo);
|
||||
}, [room]);
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link useRoomInfoFrom} for the call's room: the client driver the host
|
||||
* provided. Replaces `useRoomName`, `useRoomAvatar` and `useJoinRule` under
|
||||
* `CallView`.
|
||||
*/
|
||||
export function useRoomInfo(): RoomInfo {
|
||||
return useRoomInfoFrom(useMatrixDrivers().clientDriver);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user