mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-13 01:12:29 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39d6a0b690 | ||
|
|
682edd25a9 | ||
|
|
14dc93c078 | ||
|
|
6c5779f5bf | ||
|
|
ae12c5fbd8 |
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
prevent-blocked:
|
||||
name: Prevent blocked
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
|
||||
@@ -17,7 +17,7 @@ env:
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
name: Build & publish docker
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write # needed for publishing packages to GHCR
|
||||
|
||||
@@ -29,7 +29,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build Element Call
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
# Deploy main branch to call.element.dev after build completes
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build_full_element_call
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to call.element.dev
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
build_storybook:
|
||||
name: Build Storybook
|
||||
if: contains(github.event.pull_request.labels.*.name, 'storybook build')
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pr-changelog-label:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Review
|
||||
# Only run on labelling and skip forks.
|
||||
if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -32,7 +32,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
deployments: write
|
||||
environment: Netlify
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint, format & type check
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_number: ${{ steps.prdetails.outputs.pr_id }}
|
||||
pr_data_json: ${{ steps.prdetails.outputs.data }}
|
||||
|
||||
@@ -17,7 +17,7 @@ permissions: {}
|
||||
jobs:
|
||||
versioning:
|
||||
name: Versioning
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
|
||||
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
publish_npm:
|
||||
needs: versioning
|
||||
name: Publish NPM
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
needs: [versioning, publish_npm]
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
name: Update release notes
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
# Shared with publish-embedded-packages.yaml, which appends to the same release: two
|
||||
# appends at once would each write back the body without the other's section
|
||||
concurrency:
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
jobs:
|
||||
versioning:
|
||||
name: Versioning
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
|
||||
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish tarball
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
steps:
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish NPM
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish Android AAR
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish SwiftPM Library
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
needs: [versioning, publish_npm, publish_android, publish_ios]
|
||||
if: always()
|
||||
name: Update release notes
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
# Shared with publish-component.yaml, which appends to the same release: two appends
|
||||
# at once would each write back the body without the other's section
|
||||
concurrency:
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
needs: build_element_call
|
||||
if: always()
|
||||
name: Publish tarball
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
add_docker_release_note:
|
||||
needs: publish_docker
|
||||
name: Add docker release note
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add release note
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
jobs:
|
||||
vitest:
|
||||
name: Run unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
@@ -71,27 +71,10 @@ jobs:
|
||||
flags: unittests
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests (${{ matrix.part }})
|
||||
name: Run end-to-end tests
|
||||
if: ${{ !inputs.disable_e2e_tests }}
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-24.04
|
||||
# We manually shard the tests across runners. Playwright's own shards cut
|
||||
# the list of tests into equal counts in file order, which would leave the
|
||||
# widget suite, the slowest tests by far, together at the end.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- part: chromium-1
|
||||
args: --project=chromium --shard=1/2
|
||||
- part: chromium-2
|
||||
args: --project=chromium --shard=2/2
|
||||
- part: widget-1
|
||||
args: --project=widget --shard=1/2
|
||||
- part: widget-2
|
||||
args: --project=widget --shard=2/2
|
||||
- part: firefox-mobile
|
||||
args: --project=firefox --project=mobile
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
@@ -124,64 +107,17 @@ jobs:
|
||||
run: pnpm playwright install --with-deps
|
||||
- name: Run backend components
|
||||
run: |
|
||||
# Make sure the directory for the embedded build exists as Docker might
|
||||
# create it with wrong access rights otherwise.
|
||||
mkdir -p embedded/web/dist
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml pull
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml up -d
|
||||
docker ps
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
USE_DOCKER: 1
|
||||
ARGS: ${{ matrix.args }}
|
||||
# Unquoted on purpose: the part's arguments are several words
|
||||
run: pnpm exec playwright test $ARGS
|
||||
run: pnpm exec playwright test
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: blob-report-${{ matrix.part }}
|
||||
path: blob-report
|
||||
if-no-files-found: error
|
||||
# Only read by the merge below.
|
||||
retention-days: 1
|
||||
|
||||
e2e_report:
|
||||
# Combines the blobs into a single HTML report and blocks PRs on failure.
|
||||
name: Run end-to-end tests
|
||||
needs: playwright
|
||||
if: ${{ always() && !inputs.disable_e2e_tests }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
|
||||
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: scripts/layered.sh --ignore-pnpmfile
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
||||
- name: Download the parts' reports
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: blob-report-*
|
||||
path: all-blob-reports
|
||||
merge-multiple: true
|
||||
- name: Merge into one HTML report
|
||||
run: pnpm exec playwright merge-reports --reporter html ./all-blob-reports
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: html-report
|
||||
path: playwright-report
|
||||
if-no-files-found: error
|
||||
retention-days: 4
|
||||
- name: Fail on a failed part
|
||||
if: ${{ needs.playwright.result != 'success' }}
|
||||
run: exit 1
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
download:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions: {}
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor 🌈
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
|
||||
@@ -221,68 +221,119 @@ See also:
|
||||
|
||||
- [Developing with linked packages](./docs/linking.md)
|
||||
|
||||
#### Element Call as a component
|
||||
#### Element Call as a component (experimental)
|
||||
|
||||
Element Call can be rendered inside another React app instead of an iframe.
|
||||
The host supplies its own Matrix client, and the call stays inside the element
|
||||
it is mounted in.
|
||||
Element Call can also be embedded directly into another React application
|
||||
rather than being loaded in an iframe as a widget. `pnpm build:component`
|
||||
builds it as a library into `component/dist` (the bundle, its stylesheet and
|
||||
type declarations), and
|
||||
|
||||
##### Install
|
||||
```sh
|
||||
pnpm dev:component
|
||||
```
|
||||
|
||||
serves a harness on port 3001 that stands in for such an application: it signs
|
||||
in twice against the development backend and shows two calls side by side, in
|
||||
resizable boxes, with page furniture of its own around them. Use it to see how
|
||||
Element Call behaves when it does not own the page — the size it is given,
|
||||
whether it stays inside its container, and what it says to its host, which is
|
||||
logged along the bottom. The harness is served with the same development
|
||||
certificate as the app, so unless the development CA is trusted, the browser
|
||||
needs a certificate exception for `https://localhost:3001` as well (see the
|
||||
note under [Backend](#backend)). It reads the same `public/config.json` as
|
||||
`pnpm dev` if one exists, and runs with Element Call's defaults otherwise.
|
||||
|
||||
The call lays itself out for the size of the element it is mounted in, not the
|
||||
window: a host that shrinks the container to a corner of its page gets the
|
||||
picture-in-picture layout, just as a host that shrank the whole iframe used to.
|
||||
The breakpoints in the stylesheets the component uses are
|
||||
`@container element-call` queries against its root element for the same reason;
|
||||
for the standalone app the root is the page, so they mean what the media queries
|
||||
they replaced did. (The standalone-only views, such as the home and login pages,
|
||||
still use plain media queries, since the component never shows them.)
|
||||
|
||||
The component's stylesheet is confined to the element it is mounted in: the
|
||||
build rewrites every selector so that it matches only Element Call's root or
|
||||
what is inside it, with `html`, `body` and `:root` standing for that root (see
|
||||
`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles,
|
||||
and Element Call brings its own fonts and design tokens along.
|
||||
|
||||
The component speaks every language the app does. English is bundled in; the
|
||||
other locales are split into chunks the host's bundler loads the first time
|
||||
they are needed. It starts in the browser's language, and follows the host's
|
||||
own language setting through the `language` prop (`supportedLanguages` lists
|
||||
the tags it accepts, and anything else falls back to its base language or to
|
||||
English). The `theme` prop works the same way and takes the same values as the
|
||||
widget's `theme` URL parameter: `light`, `dark`, `light-high-contrast` or
|
||||
`dark-high-contrast`. Both can change while a call is running without
|
||||
disturbing it.
|
||||
|
||||
A host must call and await `initializeElementCall(config)` once before
|
||||
rendering the component: it loads the `Intl` polyfills, applies the
|
||||
deployment-wide `config.json`-style configuration and sets up translations.
|
||||
The component itself takes the host's `client` and the `roomId` to call in, an
|
||||
`intent` saying what the user asked for (which decides whether to show the
|
||||
lobby, ring, and so on), an optional `config` overriding what the intent
|
||||
implies, and an optional `hostBridge` through which Element Call tells the host
|
||||
that the user has joined or hung up, that it wants to stay on screen, and so
|
||||
on. The host makes its own requests (`join`, `hangUp`, `setDeviceMute`) through
|
||||
the handle exposed on `ref`. The full API is documented in the type declarations
|
||||
(`component/index.tsx` and `component/host.ts`).
|
||||
|
||||
A few things differ from the widget on purpose: the component draws a solid
|
||||
background rather than a gradient unless told otherwise, never offers to edit
|
||||
the user's profile (the account is the host's), scopes its keyboard shortcuts to
|
||||
its own root element so that several instances can share a page, and only shows
|
||||
its own post-call and error screens when the host has not supplied a `close()`
|
||||
callback; with one, it asks the host to unmount it instead. The
|
||||
[global JS controls](./docs/controls.md) on `window` are unchanged and remain
|
||||
page-wide, so with several instances on one page they apply to all of them.
|
||||
|
||||
Each release publishes the package to npm, with the same version as the release:
|
||||
|
||||
```sh
|
||||
npm install @element-hq/element-call-component
|
||||
```
|
||||
|
||||
Releases publish the package with the same version as Element Call. For an
|
||||
unreleased commit, depend on this repository directly:
|
||||
To use an unreleased commit, a host can instead install it as a git dependency
|
||||
on the `component` directory of this repository,
|
||||
|
||||
```json
|
||||
"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component"
|
||||
```
|
||||
|
||||
This builds on install, so the host needs pnpm (via Corepack) and must allow
|
||||
the build (`allowBuilds` in its `pnpm-workspace.yaml`).
|
||||
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,
|
||||
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
|
||||
directory target it rather than the repository; run them from the repository
|
||||
root. The host imports the component from
|
||||
`@element-hq/element-call-component` and the stylesheet from
|
||||
`@element-hq/element-call-component/style.css`, and has to provide `react`,
|
||||
`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
|
||||
`@vector-im/compound-design-tokens` itself, since the bundle leaves them
|
||||
external.
|
||||
Compound's stylesheet are the host's to load too: the component shares
|
||||
the host's copy of Compound, (required for example for tooltips)
|
||||
|
||||
##### Use
|
||||
The component is large, and a host will usually load it lazily, only once a
|
||||
call is shown. Everything a host needs in order to talk about a call before
|
||||
then is also exported
|
||||
from `@element-hq/element-call-component/api` (a small entry point that does not
|
||||
load the component).
|
||||
|
||||
1. Provide the externals yourself: `react`, `react-dom`, `matrix-js-sdk`,
|
||||
`@vector-im/compound-web`, `@vector-im/compound-design-tokens`, and
|
||||
Compound's stylesheet.
|
||||
2. Import `@element-hq/element-call-component/style.css`.
|
||||
3. Await `initializeElementCall(config)` once before rendering.
|
||||
4. Render the component with `client`, `roomId` and an `intent`; optionally
|
||||
pass `config`, `hostBridge`, `language` and `theme` (both can change
|
||||
mid-call). Control it through the `ref` handle (`join`, `hangUp`,
|
||||
`setDeviceMute`).
|
||||
|
||||
Load the component lazily. Types, enums and `configurationForIntent` are
|
||||
available without it from `@element-hq/element-call-component/api`. The full
|
||||
API is in `component/index.tsx`, `component/api.ts` and `component/host.ts`.
|
||||
|
||||
##### How it differs from the widget
|
||||
|
||||
- It lays out for its container, not the window: a small container gets the
|
||||
picture-in-picture layout.
|
||||
- Its styles and keyboard shortcuts are scoped to its root, so the host page is
|
||||
untouched and several instances can share a page. The
|
||||
[global JS controls](./docs/controls.md) stay page-wide.
|
||||
- It draws a solid background by default and never offers profile editing.
|
||||
- If the host bridge implements `close()`, it asks to be unmounted instead of
|
||||
showing its own post-call and error screens.
|
||||
- Sounds, images and the MediaPipe runtime are separate files the host's
|
||||
bundler copies (webpack 5, Vite, Rollup); MediaPipe is fetched only when a
|
||||
background effect is first used.
|
||||
|
||||
##### Develop
|
||||
|
||||
- `pnpm build:component` builds the library into `component/dist`.
|
||||
- `pnpm dev:component` serves a test host on `https://localhost:3001`: two
|
||||
calls side by side in resizable boxes, with host messages logged at the
|
||||
bottom. It uses the development backend, `public/config.json` if present,
|
||||
and needs the same certificate exception as `pnpm dev` (see
|
||||
[Backend](#backend)).
|
||||
- Run pnpm commands from the repository root; `component/` is its own pnpm
|
||||
project.
|
||||
- props and the types they are made of
|
||||
- the handle
|
||||
- the host bridge
|
||||
- `UserIntent`
|
||||
- `HeaderStyle`
|
||||
- `BackgroundStyle` enums
|
||||
- `configurationForIntent`, the defaults each intent implies
|
||||
Import from it wherever a value such as
|
||||
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
|
||||
entry point re-exports all of it too.
|
||||
|
||||
### Backend
|
||||
|
||||
|
||||
+7
-13
@@ -73,7 +73,6 @@ import {
|
||||
} from "../src/UrlParams";
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { AppViewModel } from "../src/state/AppViewModel";
|
||||
import { hostClientSession } from "../src/state/ClientSession";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { BackgroundEffectsProvider } from "../src/tracks/BackgroundEffectsContext";
|
||||
import { Config } from "../src/config/Config";
|
||||
@@ -190,25 +189,20 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
[roomId, intent, stableConfig],
|
||||
);
|
||||
|
||||
// Created in an effect so that the scope it lives in ends when the component
|
||||
// is unmounted (or these options change), rather than keeping its device
|
||||
// observers running for the rest of the page's life. Null until then, which
|
||||
// is one render.
|
||||
const { controlledAudioDevices, callIntent } = params;
|
||||
const [vm, setVm] = useState<AppViewModel | null>(null);
|
||||
useEffect(() => {
|
||||
const scope = new ObservableScope();
|
||||
setVm(
|
||||
new AppViewModel(
|
||||
scope,
|
||||
{ controlledAudioDevices, callIntent },
|
||||
// The host owns the client. We create a clientSession wrapping the host client.
|
||||
hostClientSession(client),
|
||||
// The host owns the session too, so there is nothing to manage here
|
||||
null,
|
||||
),
|
||||
);
|
||||
setVm(new AppViewModel(scope, { controlledAudioDevices, callIntent }));
|
||||
return (): void => {
|
||||
setVm(null);
|
||||
scope.end();
|
||||
};
|
||||
}, [controlledAudioDevices, callIntent, client]);
|
||||
}, [controlledAudioDevices, callIntent]);
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
const rtcSession = useMemo(
|
||||
@@ -256,7 +250,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider session={vm.clientSession}>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider
|
||||
effects={vm.backgroundEffects}
|
||||
|
||||
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
|
||||
@@ -19,12 +19,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
|
||||
volumes:
|
||||
# The other site opens the embedded package under test in place of the
|
||||
# copy its image bundles (the first site opens the dev server's app.
|
||||
# Built by the Playwright run (see build.setup.ts).
|
||||
- ./embedded/web/dist:/app/widgets/element-call:ro,Z
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
|
||||
|
||||
@@ -34,10 +34,6 @@ translations. The browser is Playwright's, so a fresh clone needs
|
||||
|
||||
## End-to-end
|
||||
|
||||
The Element Web instance `HOST1` opens the app the dev server serves. The instance
|
||||
`HOST2` (the other site) opens the embedded package as a host ships it. This package
|
||||
is built by the `build-embedded` setup project and mounted in `docker-compose-playwright.yml`.
|
||||
|
||||
- `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
|
||||
|
||||
@@ -78,6 +78,11 @@
|
||||
"device_id": "Device ID: {{id}}",
|
||||
"duplicate_tiles_label": "Number of additional tile copies per participant",
|
||||
"environment_variables": "Environment variables",
|
||||
"full_mesh_capabilities": "Take part in full mesh (MSC4575)",
|
||||
"full_mesh_capabilities_description": "Receive members publishing on full mesh. As a widget, this asks the host for the signalling permissions.",
|
||||
"full_mesh_peer_connections": "Full mesh peer connections",
|
||||
"full_mesh_publishing": "Publish on full mesh instead of LiveKit",
|
||||
"full_mesh_transport": "Full mesh: {{device}}",
|
||||
"hostname": "Hostname: {{hostname}}",
|
||||
"livekit_server_info": "LiveKit Server Info",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
+21
-33
@@ -26,19 +26,6 @@ process.env.NODE_EXTRA_CA_CERTS = join(
|
||||
"backend/dev_tls_local-ca.crt",
|
||||
);
|
||||
|
||||
const desktopChrome = {
|
||||
...devices["Desktop Chrome"],
|
||||
permissions: ["clipboard-write", "clipboard-read", "microphone", "camera"],
|
||||
ignoreHTTPSErrors: true,
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--mute-audio",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
@@ -56,11 +43,11 @@ export default defineConfig({
|
||||
* Reporter to use. See https://playwright.dev/docs/test-reporters
|
||||
*
|
||||
* On CI, `list` prints a line per test as it finishes, which keeps the job
|
||||
* log moving; `github` annotates failures on the PR; the `blob` format is
|
||||
* uploaded by sharded CI runs and combined into a HTML report by the workflow.
|
||||
* log moving; `github` annotates failures on the PR; `html` is uploaded as
|
||||
* an artifact and must never try to open a browser there.
|
||||
*/
|
||||
reporter: process.env.CI
|
||||
? [["list"], ["github"], ["blob"]]
|
||||
? [["list"], ["github"], ["html", { open: "never" }]]
|
||||
: [["list"], ["html"]],
|
||||
/* Stop a broken run early on CI instead of waiting out every retry. */
|
||||
maxFailures: process.env.CI ? 5 : undefined,
|
||||
@@ -76,28 +63,29 @@ export default defineConfig({
|
||||
},
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
// Builds the embedded package into the directory the other site's
|
||||
// Element Web container serves it from (see docker-compose-playwright.yml).
|
||||
name: "build-embedded",
|
||||
testMatch: "**/widget/embedded/build.setup.ts",
|
||||
},
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: ["**/mobile/**", "**/widget/**"],
|
||||
use: desktopChrome,
|
||||
},
|
||||
{
|
||||
// Element Call as a widget in Element Web, on Chromium. This uses its
|
||||
// own project so that it can be sharded on CI.
|
||||
name: "widget",
|
||||
dependencies: ["build-embedded"],
|
||||
testMatch: "**/widget/**/*.@(spec|test).ts",
|
||||
use: desktopChrome,
|
||||
testIgnore: "**/mobile/**",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
permissions: [
|
||||
"clipboard-write",
|
||||
"clipboard-read",
|
||||
"microphone",
|
||||
"camera",
|
||||
],
|
||||
ignoreHTTPSErrors: true,
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--mute-audio",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
dependencies: ["build-embedded"],
|
||||
testIgnore: "**/mobile/**",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
|
||||
@@ -106,66 +106,6 @@ async function drawsPerSecond(page: Page): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("the camera menu's preview", () => {
|
||||
test("shows the camera with the effect in force", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Preview user", "Preview in the menu");
|
||||
const camera = await cameraColour(page.locator("video").first());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const preview = page.getByRole("menu").locator("video");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), camera), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeLessThan(30);
|
||||
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
|
||||
test("shows the call's own camera once joined", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(
|
||||
page,
|
||||
"Call preview user",
|
||||
"Preview in the call's menu",
|
||||
true,
|
||||
);
|
||||
await expect(page.getByTestId("incall_videomute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const camera = await cameraColour(page.locator("video").first());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const preview = page.getByRole("menu").locator("video");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), camera), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeLessThan(30);
|
||||
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("background effects section", () => {
|
||||
test("section stays within the call area at every size", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -211,46 +151,6 @@ test.describe("background effects section", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("a pipeline that fails to build", () => {
|
||||
test("offers no effects for the session, and keeps the choice", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Failing user", "Pipeline fails");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
await page.route("**/*.tflite*", async (route) => route.abort());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
await expect(
|
||||
page.getByText("Background effects are not supported on this platform."),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(tile).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
// The camera and microphone carry on without it.
|
||||
expect(distance(await cameraColour(preview), camera)).toBeLessThan(30);
|
||||
await expect(page.getByTestId("incall_mute")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
|
||||
await page.unroute("**/*.tflite*");
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the chosen effect", () => {
|
||||
test("chosen effect survives leaving and rejoining", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
@@ -127,14 +127,13 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
* Provide a way to add additional users within a test.
|
||||
* The returned user will be registered on the default homeserver, the name will be made unique by appending a timestamp.
|
||||
*/
|
||||
addUser: async ({ browser }, use, testInfo) => {
|
||||
addUser: async ({ browser }, use) => {
|
||||
await use(
|
||||
async (
|
||||
username: string,
|
||||
host: string = HOST1,
|
||||
): Promise<UserBaseFixture> => {
|
||||
// Tests in parallel workers can reach this in the same millisecond.
|
||||
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
|
||||
const uniqueSuffix = Date.now();
|
||||
const { page, clientHandle, mxId } = await TestHelpers.registerUser(
|
||||
browser,
|
||||
`${username.toLowerCase()}_${uniqueSuffix}`,
|
||||
|
||||
@@ -1,20 +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 { test as setup } from "@playwright/test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const EMBEDDED_DIST = "embedded/web/dist";
|
||||
|
||||
setup("build the embedded package", () => {
|
||||
setup.setTimeout(10 * 60 * 1000);
|
||||
mkdirSync(EMBEDDED_DIST, { recursive: true });
|
||||
execFileSync("pnpm", ["build:embedded", "--outDir", EMBEDDED_DIST], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
});
|
||||
@@ -1,56 +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 { expect, type Route } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../../fixtures/widget-user.ts";
|
||||
import { HOST2, TestHelpers } from "../test-helpers.ts";
|
||||
|
||||
/** Where the other site's Element Web serves the embedded package from */
|
||||
const CONFIG_JSON = "**/widgets/element-call/config.json";
|
||||
|
||||
const HOST_ANSWERS: Record<string, (route: Route) => Promise<void>> = {
|
||||
"a 404": async (route) => route.fulfill({ status: 404 }),
|
||||
"a failed fetch": async (route) => route.abort("failed"),
|
||||
"an HTML page": async (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: "<!doctype html><title>Element</title>",
|
||||
}),
|
||||
};
|
||||
|
||||
widgetTest.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"The widget suite does not run on Firefox, see the other specs here",
|
||||
);
|
||||
|
||||
for (const [answer, respond] of Object.entries(HOST_ANSWERS)) {
|
||||
widgetTest(
|
||||
`reaches the lobby when config.json gets ${answer}`,
|
||||
async ({ addUser }) => {
|
||||
const { page } = await addUser("Brooks", HOST2);
|
||||
await TestHelpers.createRoom("Config Room", page);
|
||||
|
||||
let answered = 0;
|
||||
await page.route(CONFIG_JSON, async (route) => {
|
||||
answered++;
|
||||
await respond(route);
|
||||
});
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(page);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByTestId("lobby_joinCall"),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
expect(answered).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user";
|
||||
import { HOST1, TestHelpers } from "./test-helpers";
|
||||
|
||||
/**
|
||||
* Records the LiveKit signalling websockets a page opens: the SFU is reached
|
||||
* with an access token in the url, which full mesh never needs.
|
||||
*/
|
||||
function recordSfuSockets(page: Page): string[] {
|
||||
const urls: string[] = [];
|
||||
page.on("websocket", (ws) => {
|
||||
if (ws.url().includes("access_token")) urls.push(ws.url());
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts recording the types of the to-device messages the widget on a page
|
||||
* asks its host to send, and returns a function reading them. On the wire
|
||||
* they are encrypted, so the type is only visible in the widget API.
|
||||
*/
|
||||
async function recordWidgetToDevice(
|
||||
page: Page,
|
||||
): Promise<() => Promise<string[]>> {
|
||||
await page.evaluate(() => {
|
||||
const types: string[] = [];
|
||||
Object.assign(window, { widgetToDeviceTypes: types });
|
||||
window.addEventListener("message", ({ data }) => {
|
||||
const {
|
||||
action,
|
||||
response,
|
||||
data: request,
|
||||
} = (data ?? {}) as {
|
||||
action?: string;
|
||||
response?: unknown;
|
||||
data?: { type?: string };
|
||||
};
|
||||
if (action === "send_to_device" && response === undefined)
|
||||
types.push(request?.type ?? "");
|
||||
});
|
||||
});
|
||||
return async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { widgetToDeviceTypes: string[] })
|
||||
.widgetToDeviceTypes,
|
||||
);
|
||||
}
|
||||
|
||||
const cases: {
|
||||
name: string;
|
||||
/** Who publishes on full mesh; both have the capabilities unless said. */
|
||||
fullMesh: [boolean, boolean];
|
||||
capabilities?: [boolean, boolean];
|
||||
}[] = [
|
||||
// Both publish on full mesh: the media flows without any SFU
|
||||
{ name: "both on full mesh", fullMesh: [true, true] },
|
||||
// One on each: Florian receives Valere over LiveKit without publishing
|
||||
// there, and Valere receives Florian over full mesh without publishing there
|
||||
{ name: "full mesh and LiveKit mixed", fullMesh: [true, false] },
|
||||
// Without the capabilities a widget can't be subscribed to full mesh
|
||||
{
|
||||
name: "LiveKit side without the full mesh capabilities",
|
||||
fullMesh: [true, false],
|
||||
capabilities: [true, false],
|
||||
},
|
||||
];
|
||||
|
||||
cases.forEach(({ name, fullMesh, capabilities = [true, true] }) => {
|
||||
const [florianMesh, valereMesh] = fullMesh;
|
||||
const [florianCaps, valereCaps] = capabilities;
|
||||
widgetTest(`Full mesh call: ${name}`, async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
test.slow();
|
||||
|
||||
// Distinct per case: the cases run in parallel and register at once
|
||||
const suffix = `${Number(florianMesh)}${Number(valereMesh)}${Number(florianCaps)}${Number(valereCaps)}`;
|
||||
const [florian, valere] = await Promise.all([
|
||||
addUser(`florian${suffix}`, HOST1),
|
||||
addUser(`valere${suffix}`, HOST1),
|
||||
]);
|
||||
// Element Web doesn't auto-approve the full mesh signalling for Element
|
||||
// Call yet, so it asks for it whenever a widget with full mesh opens
|
||||
for (const { page } of [florian, valere])
|
||||
await page.addLocatorHandler(
|
||||
page.getByRole("heading", { name: "Approve widget permissions" }),
|
||||
async () => page.getByRole("button", { name: "Approve" }).click(),
|
||||
);
|
||||
const sockets = {
|
||||
florian: recordSfuSockets(florian.page),
|
||||
valere: recordSfuSockets(valere.page),
|
||||
};
|
||||
|
||||
const roomName = "Full Mesh Room";
|
||||
await TestHelpers.createRoom(roomName, florian.page, [valere.mxId]);
|
||||
await TestHelpers.acceptRoomInvite(roomName, valere.page);
|
||||
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
"2_0",
|
||||
{ capabilities: florianCaps, publish: florianMesh },
|
||||
);
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
valere.page,
|
||||
"2_0",
|
||||
{ capabilities: valereCaps, publish: valereMesh },
|
||||
);
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(florian.page, false);
|
||||
await TestHelpers.joinCallFromLobby(florian.page);
|
||||
await TestHelpers.joinCallInCurrentRoom(valere.page);
|
||||
|
||||
// The other's media arrives if it is on LiveKit, or we can signal full mesh
|
||||
const receives = {
|
||||
florian: !valereMesh || florianCaps,
|
||||
valere: !florianMesh || valereCaps,
|
||||
};
|
||||
// In parallel: assertions on a page also run its permission handler
|
||||
await Promise.all(
|
||||
(["florian", "valere"] as const).map(async (name) => {
|
||||
const frame = { florian, valere }[name].page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
|
||||
timeout: 15000,
|
||||
});
|
||||
const waiting = frame.getByText("Waiting for media...");
|
||||
if (receives[name]) {
|
||||
// Over whichever transport carries it
|
||||
await expect(waiting).not.toBeVisible({ timeout: 20000 });
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
} else {
|
||||
await expect(waiting).toBeVisible({ timeout: 20000 });
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (florianMesh && valereMesh) {
|
||||
// Nobody published on LiveKit, so nobody connected to an SFU
|
||||
expect(sockets.florian).toEqual([]);
|
||||
expect(sockets.valere).toEqual([]);
|
||||
} else {
|
||||
// Florian receives Valere's media from the SFU without publishing there
|
||||
expect(sockets.florian.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Florian sees Valere's camera go off: over the track metadata for full
|
||||
// mesh, without renegotiating
|
||||
const toDevice = await recordWidgetToDevice(valere.page);
|
||||
const valereFrame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await valereFrame.getByRole("switch", { name: "Stop video" }).click();
|
||||
const florianFrame = florian.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await TestHelpers.expectVisibleVideoCount(florianFrame, 1);
|
||||
// On the connection's control channel, not through the homeserver.
|
||||
// Candidates may still trickle there until the channel is up
|
||||
if (florianMesh && valereMesh)
|
||||
expect(
|
||||
(await toDevice()).filter(
|
||||
(type) => type.includes("full_mesh") && !type.endsWith(".candidates"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,14 @@ export const HOST2 = "https://app.othersite.m.localhost/#/welcome";
|
||||
|
||||
export type RtcMode = "compat" | "2_0";
|
||||
|
||||
/** Full mesh (MSC4575) in the widget's developer settings. */
|
||||
export interface FullMeshOptions {
|
||||
/** Ask the host for the signalling capabilities, to receive full mesh. */
|
||||
capabilities?: boolean;
|
||||
/** Publish on full mesh instead of LiveKit; needs the capabilities. */
|
||||
publish?: boolean;
|
||||
}
|
||||
|
||||
export class TestHelpers {
|
||||
public static async startCallInCurrentRoom(
|
||||
page: Page,
|
||||
@@ -144,11 +152,7 @@ export class TestHelpers {
|
||||
|
||||
await this.dismissStartupToasts(page);
|
||||
|
||||
// The other site's Element Web opens the embedded package it serves itself
|
||||
// (see docker-compose-playwright.yml), not the dev server's app.
|
||||
if (host !== HOST2) {
|
||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||
}
|
||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||
|
||||
const clientHandle = await page.evaluateHandle(() =>
|
||||
window.mxMatrixClientPeg.get(),
|
||||
@@ -310,6 +314,7 @@ export class TestHelpers {
|
||||
public static async openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
fullMesh: FullMeshOptions = {},
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Video call" }).click({
|
||||
timeout: 5000,
|
||||
@@ -318,7 +323,7 @@ export class TestHelpers {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode, fullMesh);
|
||||
await page.getByRole("button", { name: "Close lobby" }).click();
|
||||
}
|
||||
|
||||
@@ -331,6 +336,7 @@ export class TestHelpers {
|
||||
public static async setEmbeddedElementCallRtcMode(
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
{ capabilities = false, publish = false }: FullMeshOptions = {},
|
||||
): Promise<void> {
|
||||
const iframe = page.locator('iframe[title="Element Call"]').contentFrame();
|
||||
|
||||
@@ -348,6 +354,14 @@ export class TestHelpers {
|
||||
// compat
|
||||
await iframe.getByText("Compatibility: state events").click();
|
||||
}
|
||||
// Full mesh (MSC4575): the capabilities first, as publishing needs them
|
||||
await iframe
|
||||
.getByText("Take part in full mesh", { exact: false })
|
||||
.setChecked(capabilities);
|
||||
if (capabilities)
|
||||
await iframe
|
||||
.getByText("Publish on full mesh", { exact: false })
|
||||
.setChecked(publish);
|
||||
await iframe.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
|
||||
Generated
+9
-16
@@ -191,8 +191,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a
|
||||
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.20.0
|
||||
@@ -4734,8 +4734,8 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
resolution: {gitHosted: true, integrity: sha512-Ldt/VkCzBtre6m1cn0FqgrzRV0GrgWIoARgQUQO58ef2Edu2ERFCINPzp/psFS5RwoN3RIy9Rm4oPVc1ATkEWA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a}
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
|
||||
version: 45.0.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
@@ -4946,8 +4946,8 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-retry@9.0.0:
|
||||
resolution: {integrity: sha512-HuEats8gYn/UUYa5uyveyQGv75mQmwdFtuMgnZlS12dj/7bdnJSO6keaTjgcZUhvkANTj5EO3lACUmV3xmgj0Q==}
|
||||
p-retry@8.0.1:
|
||||
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
p-try@2.2.0:
|
||||
@@ -5318,10 +5318,6 @@ packages:
|
||||
randomfill@1.0.4:
|
||||
resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==}
|
||||
|
||||
re2js@2.9.1:
|
||||
resolution: {integrity: sha512-JIT3bEEUZTOR2YS1walNEIfH4cF/cQeZcBUFq7ldqO6lR6g0X5CBq/S8rsa9omzZcsNf8qc36jCkWpKKtncrZg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
react-docgen-typescript@2.4.0:
|
||||
resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
|
||||
peerDependencies:
|
||||
@@ -10413,7 +10409,7 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
@@ -10423,8 +10419,7 @@ snapshots:
|
||||
loglevel: 1.9.2
|
||||
matrix-events-sdk: 0.0.1
|
||||
matrix-widget-api: 1.20.0
|
||||
p-retry: 9.0.0
|
||||
re2js: 2.9.1
|
||||
p-retry: 8.0.1
|
||||
sdp-transform: 3.0.0
|
||||
unhomoglyph: 1.0.6
|
||||
|
||||
@@ -10746,7 +10741,7 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-retry@9.0.0:
|
||||
p-retry@8.0.1:
|
||||
dependencies:
|
||||
is-network-error: 1.3.2
|
||||
|
||||
@@ -11170,8 +11165,6 @@ snapshots:
|
||||
randombytes: 2.1.0
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
re2js@2.9.1: {}
|
||||
|
||||
react-docgen-typescript@2.4.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
@@ -66,9 +66,14 @@ media backend: whatever carries the media, behind the `MediaBackend` interface i
|
||||
`sdk/src/media-backend/api.ts`. The client is the join of the two: a member exists
|
||||
once its membership does, and gets its media once the backend has something
|
||||
for that membership on its transport; `state$` is the membership state combined
|
||||
with the local media connection. LiveKit is the one backend today; a
|
||||
cascading SFU or full mesh is another folder under `sdk/src/media-backend/`, handed
|
||||
in through `options.backend`, and nothing in the left box changes.
|
||||
with the local media connection. Two backends exist, LiveKit (`livekit/`) and
|
||||
full mesh (`full-mesh/`, MSC4575), and the client always runs both, combined
|
||||
(`combineBackends.ts`). It publishes on the backend of the type of
|
||||
`options.transport`, LiveKit without one, and hands every remote member to the
|
||||
backend of that member's transport, so members on either are received. A
|
||||
backend nobody is on connects nowhere. Another transport is another folder
|
||||
under `sdk/src/media-backend/`, added to the combination, and nothing in the
|
||||
left box changes; `options.backend` replaces the combination altogether.
|
||||
|
||||
### How Element Call uses it
|
||||
|
||||
@@ -154,7 +159,7 @@ the sections below still describe `MatrixRTCClient` until that lands.
|
||||
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
|
||||
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
|
||||
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
|
||||
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
|
||||
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation: LiveKit and full mesh combined, publishing on the type of `transport` in the join options, unless `backend` replaces them | "SFU", a LiveKit room, a peer connection |
|
||||
|
||||
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
|
||||
`RTCMember`, `matrixRTCMode`.
|
||||
|
||||
@@ -61,6 +61,14 @@ export {
|
||||
createLivekitBackend,
|
||||
type LivekitBackendOptions,
|
||||
} from "./src/media-backend/livekit/LivekitBackend";
|
||||
// Join with `transport: { type: FULL_MESH_TRANSPORT_TYPE }` to publish on full mesh
|
||||
export {
|
||||
createFullMeshBackend,
|
||||
FULL_MESH_TRANSPORT_TYPE,
|
||||
type FullMeshBackendOptions,
|
||||
} from "./src/media-backend/full-mesh/FullMeshBackend";
|
||||
// For a widget host to allow the full mesh signalling
|
||||
export { signallingEventTypes as fullMeshSignallingEventTypes } from "./src/media-backend/full-mesh/signalling";
|
||||
// Tracks for a lobby, captured ahead of the join and handed to it
|
||||
export { createLocalMedia } from "./src/LocalMedia";
|
||||
// For a host whose processor is a livekit-client one, such as @livekit/track-processors
|
||||
|
||||
@@ -162,7 +162,6 @@ describe("createLocalMedia", () => {
|
||||
media.release("camera");
|
||||
expect(trackBehind(camera).stop).toHaveBeenCalled();
|
||||
expect(media.tracks$.value).toEqual([microphone]);
|
||||
expect(media.requests.map((r) => r.source)).toEqual(["microphone"]);
|
||||
|
||||
media.stop();
|
||||
expect(trackBehind(microphone).stop).toHaveBeenCalled();
|
||||
|
||||
@@ -107,7 +107,6 @@ export function createLocalMedia(
|
||||
if (source !== "microphone" && source !== "camera") return;
|
||||
capturedTracks.get(source)?.stop();
|
||||
capturedTracks.delete(source);
|
||||
requests.delete(source);
|
||||
publishCapturedTracks();
|
||||
};
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ import {
|
||||
type MediaBackendContext,
|
||||
type MediaKey,
|
||||
} from "./media-backend/api";
|
||||
import { combineBackends } from "./media-backend/combineBackends";
|
||||
import {
|
||||
completeFullMeshTransport,
|
||||
createFullMeshBackend,
|
||||
} from "./media-backend/full-mesh/FullMeshBackend";
|
||||
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
|
||||
import { findRemotePublishLivekitTransport } from "./media-backend/livekit/RemotePublishTransport";
|
||||
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
|
||||
@@ -121,6 +126,13 @@ export function createRTCParticipation(
|
||||
: `${userId}:${deviceId}`,
|
||||
};
|
||||
|
||||
// The host names full mesh by its type alone; the device is ours
|
||||
if (options.transport)
|
||||
options = {
|
||||
...options,
|
||||
transport: completeFullMeshTransport(options.transport, deviceId),
|
||||
};
|
||||
|
||||
const backend = createBackend(scope, client, options, {
|
||||
roomId: room.roomId,
|
||||
slotId: jsSdkSession.slotId,
|
||||
@@ -184,7 +196,6 @@ export function createRTCParticipation(
|
||||
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
|
||||
matrixRTCMode,
|
||||
sendNotificationType: options.sendNotificationType,
|
||||
callIntent: options.callIntent,
|
||||
applicationData: options.applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
@@ -312,15 +323,26 @@ export function createRTCParticipation(
|
||||
};
|
||||
}
|
||||
|
||||
/** The host's backend, or LiveKit configured from the participation options. */
|
||||
/**
|
||||
* The host's backend, or every backend the SDK has, combined: we publish on
|
||||
* the type of the transport the host named (LiveKit without one) and receive
|
||||
* from members on any of them.
|
||||
*/
|
||||
function createBackend(
|
||||
scope: ObservableScope,
|
||||
client: MatrixClient,
|
||||
{ backend, mediaQuality }: RTCParticipationOptions,
|
||||
{ backend, mediaQuality, transport }: RTCParticipationOptions,
|
||||
context: MediaBackendContext,
|
||||
): MediaBackend {
|
||||
if (backend) return backend(scope, context);
|
||||
return createLivekitBackend(scope, context, { client, mediaQuality });
|
||||
return combineBackends(
|
||||
scope,
|
||||
[
|
||||
createLivekitBackend(scope, context, { client, mediaQuality }),
|
||||
createFullMeshBackend(scope, context, { client }),
|
||||
],
|
||||
transport?.type ?? "m.livekit",
|
||||
);
|
||||
}
|
||||
|
||||
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
|
||||
|
||||
+4
-8
@@ -12,7 +12,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -104,14 +103,11 @@ export interface RTCParticipationOptions {
|
||||
* to a notification (ringing, timeouts, declines) is the application's job.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* The kind of call the local member intends to take part in: `"m.audio"`
|
||||
* or `"m.video"`. Carried by the membership as a field of its own.
|
||||
*/
|
||||
callIntent?: RTCCallIntent;
|
||||
/**
|
||||
* Whatever the application wants to say about itself in the membership,
|
||||
* under namespaced keys. Opaque to the SDK.
|
||||
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
|
||||
* (`"audio"` or `"video"`), which the membership format carries as a
|
||||
* field of its own.
|
||||
*/
|
||||
applicationData?: Record<string, unknown>;
|
||||
/** Timings the host has configured; the defaults otherwise. */
|
||||
@@ -291,7 +287,7 @@ export interface RTCMember {
|
||||
memberId: string;
|
||||
/** Which transport this member is on; undefined when the membership has none. */
|
||||
transport$: Behavior<TransportMetadata | undefined>;
|
||||
/** What the member's application says about itself, e.g. `intent`. */
|
||||
/** What the member's application says about itself, e.g. `m.call.intent`. */
|
||||
applicationData$: Behavior<Record<string, unknown>>;
|
||||
|
||||
displayName$: Behavior<string>;
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("joinJsSdkSession", () => {
|
||||
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transportWithAlias], {
|
||||
notificationType: "ring",
|
||||
callIntent: undefined,
|
||||
applicationData: undefined,
|
||||
applicationData: {},
|
||||
manageMediaKeys: true,
|
||||
delayedLeaveEventRestartMs: 300_000,
|
||||
delayedLeaveEventDelayMs: 3_600_000,
|
||||
@@ -95,14 +95,13 @@ describe("joinJsSdkSession", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the call intent and application data through and sends sticky events in Matrix 2.0 mode", () => {
|
||||
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
|
||||
const s = session();
|
||||
|
||||
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
@@ -111,7 +110,7 @@ describe("joinJsSdkSession", () => {
|
||||
ownMemberMock,
|
||||
[transportWithAlias],
|
||||
expect.objectContaining({
|
||||
callIntent: "m.audio",
|
||||
callIntent: "audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
@@ -125,8 +124,7 @@ describe("joinJsSdkSession", () => {
|
||||
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
@@ -135,7 +133,7 @@ describe("joinJsSdkSession", () => {
|
||||
ownMemberMock,
|
||||
[transport],
|
||||
expect.objectContaining({
|
||||
callIntent: "m.audio",
|
||||
callIntent: "audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
|
||||
@@ -23,7 +23,6 @@ interface Options {
|
||||
encryptMedia: boolean;
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
callIntent?: RTCCallIntent;
|
||||
applicationData?: Record<string, unknown>;
|
||||
timings: SessionTimings;
|
||||
/** The delayed leave timings to use: delegated to the SFU or not. */
|
||||
@@ -44,12 +43,15 @@ export function joinJsSdkSession(
|
||||
encryptMedia,
|
||||
matrixRTCMode,
|
||||
sendNotificationType,
|
||||
callIntent,
|
||||
applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
}: Options,
|
||||
): void {
|
||||
// The membership format carries the intent as a field of its own
|
||||
const { "m.call.intent": intent, ...rest } = applicationData ?? {};
|
||||
const callIntent =
|
||||
typeof intent === "string" ? (intent as RTCCallIntent) : undefined;
|
||||
// Give up on the network as soon as either the sync has been down for the
|
||||
// grace period or the delayed leave has probably been sent, whichever is
|
||||
// sooner
|
||||
@@ -76,7 +78,7 @@ export function joinJsSdkSession(
|
||||
{
|
||||
notificationType: sendNotificationType,
|
||||
callIntent,
|
||||
applicationData,
|
||||
applicationData: rest,
|
||||
manageMediaKeys: encryptMedia,
|
||||
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
|
||||
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
|
||||
|
||||
@@ -125,8 +125,15 @@ export interface MediaBackend {
|
||||
* session found it; the backend does whatever has to succeed before it is
|
||||
* advertised in the membership. Rejects with the error the session reports
|
||||
* as fatal. Called once per session.
|
||||
*
|
||||
* `null` tells the backend that the local member publishes on another
|
||||
* backend: it only subscribes from then on. Until either call, a backend
|
||||
* that would connect for its publisher connects nowhere, so that it doesn't
|
||||
* subscribe to a transport it is about to publish on.
|
||||
*/
|
||||
prepareLocalTransport(transport: Transport): Promise<TransportCapabilities>;
|
||||
prepareLocalTransport(
|
||||
transport: Transport | null,
|
||||
): Promise<TransportCapabilities>;
|
||||
|
||||
/**
|
||||
* Hands the delayed leave event to the backend, which restarts it from
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
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 { BehaviorSubject, NEVER } from "rxjs";
|
||||
|
||||
import { type RTCMember, type TransportMetadata } from "../api";
|
||||
import { type MemberMedia } from "../media-api";
|
||||
import { constant } from "../reactive/Behavior";
|
||||
import { ObservableScope } from "../reactive/ObservableScope";
|
||||
import { type MediaBackend } from "./api";
|
||||
import { combineBackends } from "./combineBackends";
|
||||
|
||||
function fakeBackend(
|
||||
type: string,
|
||||
...moreTypes: string[]
|
||||
): MediaBackend & { ended: string[] } {
|
||||
const ended: string[] = [];
|
||||
return {
|
||||
ended,
|
||||
transportTypes: [type, ...moreTypes],
|
||||
prepareLocalTransport: vi.fn(async () =>
|
||||
Promise.resolve({ canDelegateDelayedLeave: false }),
|
||||
),
|
||||
delegateDelayedLeave: vi.fn(),
|
||||
local: {} as MediaBackend["local"],
|
||||
mediaFor: (scope, member): MemberMedia => {
|
||||
scope.onEnd(() => ended.push(member.memberId));
|
||||
return {
|
||||
tracks$: constant([{ id: `${type}:${member.memberId}` }] as never),
|
||||
encryptionError$: NEVER,
|
||||
};
|
||||
},
|
||||
connections$: constant([
|
||||
{ transport: { type }, state: "connected" as never, resolved: undefined },
|
||||
]),
|
||||
setAudioOutputDeviceId: vi.fn(),
|
||||
sendData: vi.fn(),
|
||||
data$: NEVER,
|
||||
};
|
||||
}
|
||||
|
||||
function transport(type: string): TransportMetadata {
|
||||
return { type, id: type, raw: { type }, resolved$: constant(undefined) };
|
||||
}
|
||||
|
||||
describe("combineBackends", () => {
|
||||
it("routes each member to the backend of its transport type", () => {
|
||||
const scope = new ObservableScope();
|
||||
const livekit = fakeBackend("m.livekit", "livekit");
|
||||
const other = fakeBackend("other");
|
||||
const combined = combineBackends(scope, [livekit, other], "livekit");
|
||||
expect(combined.transportTypes).toEqual(["m.livekit", "livekit"]);
|
||||
expect(combined.connections$.value).toHaveLength(2);
|
||||
|
||||
const transport$ = new BehaviorSubject<TransportMetadata | undefined>(
|
||||
transport("other"),
|
||||
);
|
||||
const memberScope = new ObservableScope();
|
||||
const media = combined.mediaFor(memberScope, {
|
||||
memberId: "m1",
|
||||
transport$,
|
||||
} as unknown as RTCMember);
|
||||
expect(media.tracks$.value).toEqual([{ id: "other:m1" }]);
|
||||
|
||||
// Either type of the publishing backend stays with it
|
||||
transport$.next(transport("livekit"));
|
||||
expect(other.ended).toEqual(["m1"]);
|
||||
expect(media.tracks$.value).toEqual([{ id: "m.livekit:m1" }]);
|
||||
transport$.next(transport("m.livekit"));
|
||||
expect(media.tracks$.value).toEqual([{ id: "m.livekit:m1" }]);
|
||||
|
||||
transport$.next(transport("unknown"));
|
||||
expect(media.tracks$.value).toBeNull();
|
||||
// The publishing backend keeps every member for as long as it is one
|
||||
expect(livekit.ended).toEqual([]);
|
||||
memberScope.end();
|
||||
expect(livekit.ended).toEqual(["m1"]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("publishes on the backend of the publish type, telling the others", async () => {
|
||||
const scope = new ObservableScope();
|
||||
const livekit = fakeBackend("livekit");
|
||||
const other = fakeBackend("other");
|
||||
const combined = combineBackends(scope, [livekit, other], "other");
|
||||
expect(combined.transportTypes).toEqual(["other"]);
|
||||
const transport = { type: "other" };
|
||||
await combined.prepareLocalTransport(transport);
|
||||
expect(other.prepareLocalTransport).toHaveBeenCalledExactlyOnceWith(
|
||||
transport,
|
||||
);
|
||||
expect(livekit.prepareLocalTransport).toHaveBeenCalledExactlyOnceWith(null);
|
||||
expect(() => combineBackends(scope, [livekit, other], "unknown")).toThrow(
|
||||
"No media backend for transport unknown",
|
||||
);
|
||||
scope.end();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, merge, NEVER, of, switchMap } from "rxjs";
|
||||
|
||||
import { type RTCMember } from "../api";
|
||||
import { MatrixRTCError } from "../errors";
|
||||
import { type MemberMedia } from "../media-api";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { mapScoped } from "../utils/mapScoped";
|
||||
import { type MediaBackend } from "./api";
|
||||
|
||||
/**
|
||||
* Several backends as one: we publish on the backend of `publishType`, and
|
||||
* receive from every member on a transport any of the backends serves. A
|
||||
* member is handed to the backend of its current transport type. A backend
|
||||
* nobody is on costs nothing: it connects only for the members handed to it.
|
||||
*/
|
||||
export function combineBackends(
|
||||
scope: ObservableScope,
|
||||
all: MediaBackend[],
|
||||
publishType: string,
|
||||
): MediaBackend {
|
||||
const byType = new Map(
|
||||
all.flatMap((b) => b.transportTypes.map((type) => [type, b] as const)),
|
||||
);
|
||||
const publish = byType.get(publishType);
|
||||
if (publish === undefined)
|
||||
throw new MatrixRTCError(`No media backend for transport ${publishType}`);
|
||||
|
||||
const mediaFor = (
|
||||
memberScope: ObservableScope,
|
||||
member: RTCMember,
|
||||
): MemberMedia => {
|
||||
// The publishing backend sees every member, whatever its transport, so
|
||||
// that it can tell members from strangers when a backend is asked for our
|
||||
// media by members on other transports. It connects nowhere for them
|
||||
const publishMedia = publish.mediaFor(memberScope, member);
|
||||
const type$ = memberScope.behavior(
|
||||
member.transport$.pipe(map((transport) => transport?.type)),
|
||||
);
|
||||
const media$ = mapScoped(memberScope, type$, (s, type) =>
|
||||
type !== undefined && publish.transportTypes.includes(type)
|
||||
? publishMedia
|
||||
: byType.get(type)?.mediaFor(s, member),
|
||||
);
|
||||
return {
|
||||
tracks$: memberScope.behavior(
|
||||
media$.pipe(switchMap((media) => media?.tracks$ ?? of(null))),
|
||||
),
|
||||
encryptionError$: media$.pipe(
|
||||
switchMap((media) => media?.encryptionError$ ?? NEVER),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
transportTypes: publish.transportTypes,
|
||||
// The others learn that they only subscribe
|
||||
prepareLocalTransport: async (transport) => {
|
||||
const [capabilities] = await Promise.all([
|
||||
publish.prepareLocalTransport(transport),
|
||||
...all
|
||||
.filter((b) => b !== publish)
|
||||
.map(async (b) => b.prepareLocalTransport(null)),
|
||||
]);
|
||||
return capabilities;
|
||||
},
|
||||
delegateDelayedLeave: async (delayId) =>
|
||||
publish.delegateDelayedLeave(delayId),
|
||||
local: publish.local,
|
||||
mediaFor,
|
||||
connections$: scope.behavior(
|
||||
combineLatest(all.map((b) => b.connections$)).pipe(
|
||||
map((lists) => lists.flat()),
|
||||
),
|
||||
),
|
||||
setAudioOutputDeviceId: async (deviceId) => {
|
||||
await Promise.all(
|
||||
all.map(async (b) => b.setAudioOutputDeviceId(deviceId)),
|
||||
);
|
||||
},
|
||||
sendData: async (topic, text) => publish.sendData(topic, text),
|
||||
data$: merge(...all.map((b) => b.data$)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
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 control channel of a full mesh connection (MSC4575): a negotiated data
|
||||
* channel both sides create, which carries every message of the connection
|
||||
* once it is open, apart from `close`.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ConsumptionMetadata,
|
||||
parseConsumptionMetadata,
|
||||
parseTrackMetadata,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
|
||||
export const CONTROL_CHANNEL_LABEL = "org.matrix.msc4575.rtc.full_mesh.control";
|
||||
const CONTROL_CHANNEL_ID = 0;
|
||||
|
||||
export type ControlMessage =
|
||||
| {
|
||||
type: "description";
|
||||
description: { type: "offer" | "answer"; sdp: string };
|
||||
track_metadata?: TrackMetadata;
|
||||
}
|
||||
| { type: "candidates"; candidates: RTCIceCandidateInit[] }
|
||||
| { type: "track_metadata"; track_metadata: TrackMetadata }
|
||||
| { type: "consumption_metadata"; consumption_metadata: ConsumptionMetadata };
|
||||
|
||||
export function createControlChannel(pc: RTCPeerConnection): RTCDataChannel {
|
||||
return pc.createDataChannel(CONTROL_CHANNEL_LABEL, {
|
||||
negotiated: true,
|
||||
id: CONTROL_CHANNEL_ID,
|
||||
});
|
||||
}
|
||||
|
||||
/** A received control message, or null for one to ignore: malformed, or of a type we don't know. */
|
||||
export function parseControlMessage(data: unknown): ControlMessage | null {
|
||||
if (typeof data !== "string") return null;
|
||||
let m: Record<string, unknown>;
|
||||
try {
|
||||
m = JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof m !== "object" || m === null) return null;
|
||||
switch (m.type) {
|
||||
case "description": {
|
||||
const d = m.description as { type?: unknown; sdp?: unknown } | undefined;
|
||||
if (
|
||||
(d?.type !== "offer" && d?.type !== "answer") ||
|
||||
typeof d.sdp !== "string"
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
type: "description",
|
||||
description: { type: d.type, sdp: d.sdp },
|
||||
track_metadata: parseTrackMetadata(m.track_metadata) ?? undefined,
|
||||
};
|
||||
}
|
||||
case "candidates":
|
||||
if (!Array.isArray(m.candidates)) return null;
|
||||
return {
|
||||
type: "candidates",
|
||||
candidates: m.candidates as RTCIceCandidateInit[],
|
||||
};
|
||||
case "track_metadata": {
|
||||
const metadata = parseTrackMetadata(m.track_metadata);
|
||||
return metadata && { type: "track_metadata", track_metadata: metadata };
|
||||
}
|
||||
case "consumption_metadata": {
|
||||
const metadata = parseConsumptionMetadata(m.consumption_metadata);
|
||||
return (
|
||||
metadata && {
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: metadata,
|
||||
}
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
type ITurnServer,
|
||||
MatrixEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, NEVER } from "rxjs";
|
||||
|
||||
import { type RTCMember, type TransportMetadata } from "../../api";
|
||||
import { defaultSessionTimings } from "../../config";
|
||||
import { E2eeType } from "../../encryption";
|
||||
import { constant } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
type DataPacket,
|
||||
type MediaBackend,
|
||||
MediaConnectionState,
|
||||
} from "../api";
|
||||
import {
|
||||
completeFullMeshTransport,
|
||||
createFullMeshBackend,
|
||||
type FullMeshBackendOptions,
|
||||
} from "./FullMeshBackend";
|
||||
import { DATA_CHANNEL_LABEL, DATA_PROTOCOL } from "./metadata";
|
||||
import { FULL_MESH_TRANSPORT_TYPE, SignallingEventType } from "./signalling";
|
||||
import {
|
||||
FakePeerConnection,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
const slotId = "m.call#ROOM";
|
||||
const own = { userId: "@me:example.org", deviceId: "MYDEVICE", memberId: "me" };
|
||||
|
||||
const fullMesh = (deviceId: string): Transport => ({
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
device_id: deviceId,
|
||||
});
|
||||
const livekit: Transport = { type: "livekit", livekit_service_url: "x" };
|
||||
|
||||
function metadata(raw: Transport): TransportMetadata {
|
||||
return {
|
||||
type: raw.type,
|
||||
id: JSON.stringify(raw),
|
||||
raw,
|
||||
resolved$: constant(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createFullMeshBackend", () => {
|
||||
let scope: ObservableScope;
|
||||
let client: EventEmitter & FullMeshBackendOptions["client"];
|
||||
let send: ReturnType<typeof vi.fn>;
|
||||
let turnServers: ITurnServer[];
|
||||
let backend: MediaBackend;
|
||||
const seq = new Map<string, number>();
|
||||
|
||||
/** A to-device message from `peer` (or another sender), as the sync would deliver it. */
|
||||
function deliver(
|
||||
type: string,
|
||||
connectionId: string,
|
||||
content: Record<string, unknown> = {},
|
||||
from = peer,
|
||||
): void {
|
||||
const next = seq.get(from.memberId) ?? 0;
|
||||
seq.set(from.memberId, next + 1);
|
||||
const event = new MatrixEvent({
|
||||
type,
|
||||
sender: from.userId,
|
||||
content: {
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: from.memberId,
|
||||
target_member_id: own.memberId,
|
||||
claimed_device_id: from.deviceId,
|
||||
connection_id: connectionId,
|
||||
seq: next,
|
||||
...content,
|
||||
},
|
||||
});
|
||||
vi.spyOn(event, "getWireType").mockReturnValue(
|
||||
EventType.RoomMessageEncrypted,
|
||||
);
|
||||
client.emit(ClientEvent.ToDeviceEvent, event);
|
||||
}
|
||||
|
||||
/** What we sent, as [type, recipient device, content]. */
|
||||
function sent(type?: string): [string, string, Record<string, unknown>][] {
|
||||
return send.mock.calls
|
||||
.filter(([t]) => type === undefined || t === type)
|
||||
.map(([t, [target], content]) => [t, target.deviceId, content]);
|
||||
}
|
||||
|
||||
/** A member of the slot, as the session hands it to the backend. */
|
||||
function addMember(
|
||||
transport: Transport,
|
||||
who = peer,
|
||||
): { scope: ObservableScope; tracks: () => unknown } {
|
||||
const memberScope = new ObservableScope();
|
||||
const media = backend.mediaFor(memberScope, {
|
||||
...who,
|
||||
rtcBackendIdentity: `${who.userId}:${who.deviceId}`,
|
||||
transport$: new BehaviorSubject(metadata(transport)),
|
||||
} as unknown as RTCMember);
|
||||
return { scope: memberScope, tracks: () => media.tracks$.value };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
installWebRtcFakes();
|
||||
seq.clear();
|
||||
scope = new ObservableScope();
|
||||
send = vi.fn(async () => Promise.resolve());
|
||||
turnServers = [];
|
||||
client = Object.assign(new EventEmitter(), {
|
||||
encryptAndSendToDevice: send,
|
||||
getTurnServers: (): ITurnServer[] => turnServers,
|
||||
checkTurnServers: vi.fn(async () => {
|
||||
turnServers = [{ urls: ["turn:x"], username: "u", credential: "p" }];
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
}) as unknown as EventEmitter & FullMeshBackendOptions["client"];
|
||||
backend = createFullMeshBackend(
|
||||
scope,
|
||||
{
|
||||
roomId,
|
||||
slotId,
|
||||
ownMembershipIdentity: own,
|
||||
publish: [],
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
mediaKeys$: NEVER,
|
||||
timings: defaultSessionTimings,
|
||||
logger,
|
||||
},
|
||||
{ client },
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("only prepares a full mesh transport", async () => {
|
||||
await expect(backend.prepareLocalTransport(livekit)).rejects.toThrow(
|
||||
"Not a full mesh transport: livekit",
|
||||
);
|
||||
await expect(
|
||||
backend.prepareLocalTransport(fullMesh(own.deviceId)),
|
||||
).resolves.toEqual({ canDelegateDelayedLeave: false });
|
||||
expect(backend.local.connectionState$.value).toBe(
|
||||
MediaConnectionState.Connected,
|
||||
);
|
||||
});
|
||||
|
||||
it("subscribes all the same when told that another backend publishes", async () => {
|
||||
await expect(backend.prepareLocalTransport(null)).resolves.toEqual({
|
||||
canDelegateDelayedLeave: false,
|
||||
});
|
||||
addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.subscribe)).toHaveLength(1);
|
||||
// Still not publishing
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.close)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fetches TURN servers when the client has none yet", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
expect(client.checkTurnServers).toHaveBeenCalledOnce();
|
||||
addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
expect(lastPeerConnection().config.iceServers).toEqual([
|
||||
{ urls: ["turn:x"], username: "u", credential: "p" },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("as a publisher", () => {
|
||||
it("turns subscribers away while it doesn't publish", async () => {
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
reason: "not_publishing",
|
||||
connection_id: "c1",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers to a member on any transport", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.description)).toEqual([
|
||||
[
|
||||
SignallingEventType.description,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
connection_id: "c1",
|
||||
target_member_id: peer.memberId,
|
||||
description: { type: "offer", sdp: "offer-1" },
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits for the membership of a subscriber that is ahead of it", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(sent()).toEqual([]);
|
||||
addMember(livekit);
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.description)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a subscriber that never becomes a member", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({ reason: "not_a_member" }),
|
||||
],
|
||||
]);
|
||||
expect(FakePeerConnection.instances).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a subscriber claiming another device of the member", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(
|
||||
SignallingEventType.subscribe,
|
||||
"c1",
|
||||
{},
|
||||
{
|
||||
...peer,
|
||||
deviceId: "OTHERDEVICE",
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
"OTHERDEVICE",
|
||||
expect.objectContaining({ reason: "not_a_member" }),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("only serves the newest of several waiting subscribe requests", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
deliver(SignallingEventType.subscribe, "c2");
|
||||
addMember(livekit);
|
||||
await settle();
|
||||
expect(
|
||||
sent(SignallingEventType.description).map(([, , c]) => c.connection_id),
|
||||
).toEqual(["c2"]);
|
||||
});
|
||||
|
||||
it("replaces the connection of a member that subscribes again", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
const first = lastPeerConnection();
|
||||
deliver(SignallingEventType.subscribe, "c2");
|
||||
await settle();
|
||||
expect(first.closed).toBe(true);
|
||||
expect(lastPeerConnection()).not.toBe(first);
|
||||
});
|
||||
|
||||
it("hands the subscriber's messages to its connection", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
deliver(SignallingEventType.description, "c1", {
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
});
|
||||
deliver(SignallingEventType.candidates, "c1", {
|
||||
candidates: [{ candidate: "x" }],
|
||||
});
|
||||
// A late message of an earlier connection goes nowhere
|
||||
deliver(SignallingEventType.candidates, "c0", {
|
||||
candidates: [{ candidate: "stale" }],
|
||||
});
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.remoteDescription).toEqual({ type: "answer", sdp: "answer" });
|
||||
expect(pc.addedCandidates).toEqual([{ candidate: "x" }]);
|
||||
});
|
||||
|
||||
it("drops a message of a connection from another device", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
deliver(
|
||||
SignallingEventType.description,
|
||||
"c1",
|
||||
{ description: { type: "answer", sdp: "answer" } },
|
||||
{ ...peer, deviceId: "OTHERDEVICE" },
|
||||
);
|
||||
await settle();
|
||||
expect(lastPeerConnection().remoteDescription).toBeNull();
|
||||
});
|
||||
|
||||
it("stops sending to a member that leaves", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
const member = addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
member.scope.end();
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(sent(SignallingEventType.close)).toEqual([
|
||||
[
|
||||
SignallingEventType.close,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({ connection_id: "c1" }),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists its subscribers for the developer tools", async () => {
|
||||
expect(backend.connections$.value).toEqual([]);
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
expect(backend.connections$.value).toEqual([
|
||||
{
|
||||
transport: fullMesh(own.deviceId),
|
||||
state: MediaConnectionState.Connected,
|
||||
resolved: {
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
role: "publisher",
|
||||
peerConnections: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
expect(backend.connections$.value[0].resolved).toMatchObject({
|
||||
peerConnections: [
|
||||
{
|
||||
memberId: peer.memberId,
|
||||
connectionId: "c1",
|
||||
connectionState: "new",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("as a subscriber", () => {
|
||||
it("subscribes to a member publishing on full mesh", async () => {
|
||||
const member = addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
expect(sent()).toEqual([
|
||||
[
|
||||
SignallingEventType.subscribe,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: own.memberId,
|
||||
claimed_device_id: own.deviceId,
|
||||
target_member_id: peer.memberId,
|
||||
seq: 0,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
expect(member.tracks()).toBeNull();
|
||||
expect(backend.connections$.value).toEqual([
|
||||
expect.objectContaining({
|
||||
transport: fullMesh(peer.deviceId),
|
||||
resolved: expect.objectContaining({ role: "subscriber" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a full mesh transport advertising another device", async () => {
|
||||
addMember(fullMesh("OTHERDEVICE"));
|
||||
await settle();
|
||||
expect(sent()).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves members on other transports alone", async () => {
|
||||
const member = addMember(livekit);
|
||||
await settle();
|
||||
expect(sent()).toEqual([]);
|
||||
expect(member.tracks()).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the publisher's messages to the subscription", async () => {
|
||||
addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
const [[, , subscribe]] = sent();
|
||||
lastPeerConnection().receive("video", "1");
|
||||
deliver(
|
||||
SignallingEventType.description,
|
||||
subscribe.connection_id as string,
|
||||
{
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: { "1": { purpose: "m.usermedia" } },
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
expect(sent(SignallingEventType.description)).toEqual([
|
||||
[
|
||||
SignallingEventType.description,
|
||||
peer.deviceId,
|
||||
expect.objectContaining({
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes the subscription when the member leaves", async () => {
|
||||
const member = addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
member.scope.end();
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(backend.connections$.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't take over the delayed leave", async () => {
|
||||
await expect(backend.delegateDelayedLeave("id")).rejects.toThrow();
|
||||
});
|
||||
|
||||
describe("data", () => {
|
||||
it("can't be sent while not publishing", async () => {
|
||||
await expect(backend.sendData("topic", "text")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("is sent to every subscriber on its data channel", async () => {
|
||||
await backend.prepareLocalTransport(fullMesh(own.deviceId));
|
||||
addMember(livekit);
|
||||
deliver(SignallingEventType.subscribe, "c1");
|
||||
await settle();
|
||||
const data = lastPeerConnection().dataChannels.find(
|
||||
(c) => c.label === DATA_CHANNEL_LABEL,
|
||||
)!;
|
||||
data.open();
|
||||
await backend.sendData("greeting", "hello");
|
||||
expect(data.sent.map((m) => JSON.parse(m) as unknown)).toEqual([
|
||||
{ topic: "greeting", text: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is received from a publisher, named by its member", async () => {
|
||||
const received: DataPacket[] = [];
|
||||
backend.data$.subscribe((p) => received.push(p));
|
||||
addMember(fullMesh(peer.deviceId));
|
||||
await settle();
|
||||
const [[, , subscribe]] = sent();
|
||||
deliver(
|
||||
SignallingEventType.description,
|
||||
subscribe.connection_id as string,
|
||||
{
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: {},
|
||||
},
|
||||
);
|
||||
await settle();
|
||||
const channel = lastPeerConnection().announceDataChannel(
|
||||
DATA_CHANNEL_LABEL,
|
||||
DATA_PROTOCOL,
|
||||
);
|
||||
channel.receive({ topic: "greeting", text: "hello" });
|
||||
channel.receive({ topic: 1 });
|
||||
expect(received).toEqual([
|
||||
{
|
||||
senderId: `${peer.userId}:${peer.deviceId}`,
|
||||
topic: "greeting",
|
||||
text: "hello",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeFullMeshTransport", () => {
|
||||
it("adds our device to a full mesh transport only", () => {
|
||||
expect(
|
||||
completeFullMeshTransport({ type: FULL_MESH_TRANSPORT_TYPE }, "DEV"),
|
||||
).toEqual({ type: FULL_MESH_TRANSPORT_TYPE, device_id: "DEV" });
|
||||
expect(completeFullMeshTransport(livekit, "DEV")).toBe(livekit);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
/*
|
||||
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 full mesh transport (MSC4575): one WebRTC connection per subscriber
|
||||
* and publisher, signalled over to-device messages, through the TURN server
|
||||
* of each side's homeserver. Works subscribe-only too: without
|
||||
* `prepareLocalTransport` it publishes nothing and still receives from every
|
||||
* member on full mesh.
|
||||
*/
|
||||
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
of,
|
||||
share,
|
||||
Subject,
|
||||
switchMap,
|
||||
timeout,
|
||||
} from "rxjs";
|
||||
|
||||
import { type RTCMember } from "../../api";
|
||||
import { MatrixRTCError } from "../../errors";
|
||||
import { type MemberMedia } from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { mapScoped } from "../../utils/mapScoped";
|
||||
import {
|
||||
type BackendConnection,
|
||||
type DataPacket,
|
||||
type MediaBackend,
|
||||
type MediaBackendContext,
|
||||
MediaConnectionState,
|
||||
} from "../api";
|
||||
import { createFullMeshLocalMedia } from "./LocalMedia";
|
||||
import { type PeerConnectionBase } from "./PeerConnection";
|
||||
import {
|
||||
createPublisherConnection,
|
||||
type PublisherConnection,
|
||||
} from "./PublisherConnection";
|
||||
import {
|
||||
createSignalling,
|
||||
FULL_MESH_TRANSPORT_TYPE,
|
||||
type Peer,
|
||||
type SignallingClient,
|
||||
type SignallingMessage,
|
||||
} from "./signalling";
|
||||
import {
|
||||
createSubscriberConnection,
|
||||
type SubscriberConnection,
|
||||
} from "./SubscriberConnection";
|
||||
|
||||
export { FULL_MESH_TRANSPORT_TYPE } from "./signalling";
|
||||
|
||||
/** How long a subscribe request waits for the subscriber's membership to arrive. */
|
||||
const SUBSCRIBE_GRACE_MS = 10_000;
|
||||
|
||||
export interface FullMeshBackendOptions {
|
||||
client: SignallingClient &
|
||||
Pick<MatrixClient, "getTurnServers" | "checkTurnServers">;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport object a member publishing on full mesh advertises: a host
|
||||
* passes `{ type: FULL_MESH_TRANSPORT_TYPE }`, and the device is filled in.
|
||||
* Any other transport is returned as it is.
|
||||
*/
|
||||
export function completeFullMeshTransport(
|
||||
transport: Transport,
|
||||
deviceId: string,
|
||||
): Transport {
|
||||
return transport.type === FULL_MESH_TRANSPORT_TYPE
|
||||
? { ...transport, device_id: deviceId }
|
||||
: transport;
|
||||
}
|
||||
|
||||
export function createFullMeshBackend(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
roomId,
|
||||
slotId,
|
||||
ownMembershipIdentity,
|
||||
publish: requestsOrMedia,
|
||||
logger: parentLogger,
|
||||
}: MediaBackendContext,
|
||||
{ client }: FullMeshBackendOptions,
|
||||
): MediaBackend {
|
||||
const logger = parentLogger.getChild("[FullMeshBackend]");
|
||||
const [publish, media] = Array.isArray(requestsOrMedia)
|
||||
? [requestsOrMedia, undefined]
|
||||
: [requestsOrMedia.requests, requestsOrMedia];
|
||||
const signalling = createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId,
|
||||
slotId,
|
||||
own: ownMembershipIdentity,
|
||||
logger,
|
||||
});
|
||||
|
||||
const iceServers = async (): Promise<RTCIceServer[]> => {
|
||||
if (client.getTurnServers().length === 0)
|
||||
await client
|
||||
.checkTurnServers()
|
||||
.catch((e: unknown) => logger.warn("No TURN servers", e));
|
||||
return client.getTurnServers().map(({ urls, username, credential }) => ({
|
||||
urls,
|
||||
username,
|
||||
credential,
|
||||
}));
|
||||
};
|
||||
|
||||
let localTransport: Transport | null = null;
|
||||
const prepared$ = new BehaviorSubject(false);
|
||||
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const data$ = new Subject<DataPacket>();
|
||||
scope.onEnd(() => data$.complete());
|
||||
|
||||
const members = new Map<string, RTCMember>();
|
||||
const memberAdded$ = new Subject<RTCMember>();
|
||||
const subscribers = new Map<
|
||||
string,
|
||||
{ transport: Transport; peer: Peer; connection: SubscriberConnection }
|
||||
>();
|
||||
const publishers = new Map<
|
||||
string,
|
||||
{ connection: PublisherConnection; peer: Peer; scope: ObservableScope }
|
||||
>();
|
||||
scope.onEnd(() => {
|
||||
for (const { scope: s } of Array.from(publishers.values())) s.end();
|
||||
});
|
||||
/** The connection id of the newest subscribe request per member. */
|
||||
const latestSubscribe = new Map<string, string>();
|
||||
|
||||
// For the developer tools: one entry per publisher we subscribe to, and one
|
||||
// for our own transport with every subscriber we send to. `resolved` lists
|
||||
// the peer connections, as an SFU's entry lists what it connected to
|
||||
const connectionsChanged$ = new BehaviorSubject<void>(undefined);
|
||||
const connections$ = scope.behavior<BackendConnection[]>(
|
||||
combineLatest([connectionsChanged$, prepared$]).pipe(
|
||||
switchMap(([, prepared]) => {
|
||||
const entries: Observable<BackendConnection>[] = [];
|
||||
for (const { transport, peer, connection } of subscribers.values()) {
|
||||
const base = connection.current$.value;
|
||||
if (base)
|
||||
entries.push(
|
||||
base.state$.pipe(
|
||||
map((state) => ({
|
||||
transport,
|
||||
state,
|
||||
resolved: {
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
role: "subscriber",
|
||||
peerConnections: [describe(peer, base)],
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (localTransport && prepared) {
|
||||
const transport = localTransport;
|
||||
const outgoing = [...publishers.values()];
|
||||
entries.push(
|
||||
(outgoing.length === 0
|
||||
? of([])
|
||||
: combineLatest(outgoing.map((p) => p.connection.state$))
|
||||
).pipe(
|
||||
map(() => ({
|
||||
transport,
|
||||
// Publishing needs no server, so it is up once prepared
|
||||
state: MediaConnectionState.Connected,
|
||||
resolved: {
|
||||
type: FULL_MESH_TRANSPORT_TYPE,
|
||||
role: "publisher",
|
||||
peerConnections: outgoing.map(({ peer, connection }) =>
|
||||
describe(peer, connection),
|
||||
),
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
return entries.length === 0 ? of([]) : combineLatest(entries);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const local = createFullMeshLocalMedia({
|
||||
scope,
|
||||
publish,
|
||||
media,
|
||||
connectionState$: scope.behavior(
|
||||
prepared$.pipe(
|
||||
map((prepared) =>
|
||||
prepared
|
||||
? MediaConnectionState.Connected
|
||||
: MediaConnectionState.Initialized,
|
||||
),
|
||||
),
|
||||
),
|
||||
peerConnections: () =>
|
||||
[...publishers.values()].map(({ connection }) => connection.pc),
|
||||
logger,
|
||||
});
|
||||
|
||||
const acceptSubscribe = async (message: SignallingMessage): Promise<void> => {
|
||||
const peer: Peer = {
|
||||
userId: message.userId,
|
||||
deviceId: message.deviceId,
|
||||
memberId: message.memberId,
|
||||
};
|
||||
// We don't advertise full mesh, so nobody should have asked
|
||||
if (!prepared$.value) {
|
||||
signalling.send(peer, message.connectionId, {
|
||||
type: "close",
|
||||
reason: "not_publishing",
|
||||
});
|
||||
return;
|
||||
}
|
||||
latestSubscribe.set(peer.memberId, message.connectionId);
|
||||
// The request may overtake the subscriber's membership
|
||||
const member =
|
||||
members.get(peer.memberId) ??
|
||||
(await firstValueFrom(
|
||||
memberAdded$.pipe(
|
||||
filter((m) => m.memberId === peer.memberId),
|
||||
timeout(SUBSCRIBE_GRACE_MS),
|
||||
),
|
||||
).catch(() => undefined));
|
||||
if (latestSubscribe.get(peer.memberId) !== message.connectionId) return;
|
||||
if (
|
||||
member === undefined ||
|
||||
member.userId !== peer.userId ||
|
||||
member.deviceId !== peer.deviceId
|
||||
) {
|
||||
logger.warn(`Rejecting a subscribe from ${peer.memberId}`);
|
||||
signalling.send(peer, message.connectionId, {
|
||||
type: "close",
|
||||
reason: "not_a_member",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// At most one connection per member pair: the new one replaces the old
|
||||
publishers.get(peer.memberId)?.scope.end();
|
||||
const pubScope = new ObservableScope();
|
||||
const connection = createPublisherConnection({
|
||||
scope: pubScope,
|
||||
peer,
|
||||
connectionId: message.connectionId,
|
||||
signalling,
|
||||
iceServers: await iceServers(),
|
||||
local,
|
||||
logger,
|
||||
});
|
||||
publishers.set(peer.memberId, { connection, peer, scope: pubScope });
|
||||
pubScope.onEnd(() => {
|
||||
if (publishers.get(peer.memberId)?.connection === connection) {
|
||||
publishers.delete(peer.memberId);
|
||||
connectionsChanged$.next();
|
||||
}
|
||||
});
|
||||
connectionsChanged$.next();
|
||||
};
|
||||
|
||||
// A message of a connection has to come from the device the connection
|
||||
// was set up with, not only carry its id
|
||||
const fromPeer = (message: SignallingMessage, peer: Peer): boolean =>
|
||||
message.userId === peer.userId && message.deviceId === peer.deviceId;
|
||||
signalling.messages$.pipe(scope.bind()).subscribe((message) => {
|
||||
if (message.type === "subscribe") return void acceptSubscribe(message);
|
||||
const subscriber = subscribers.get(message.memberId);
|
||||
if (
|
||||
subscriber?.connection.current$.value?.connectionId ===
|
||||
message.connectionId &&
|
||||
fromPeer(message, subscriber.peer)
|
||||
)
|
||||
return subscriber.connection.handle(message);
|
||||
const publisher = publishers.get(message.memberId);
|
||||
if (
|
||||
publisher?.connection.connectionId === message.connectionId &&
|
||||
fromPeer(message, publisher.peer)
|
||||
)
|
||||
return publisher.connection.handle(message);
|
||||
logger.debug(`Dropping ${message.type} of a stale connection`);
|
||||
});
|
||||
|
||||
const mediaFor = (
|
||||
memberScope: ObservableScope,
|
||||
member: RTCMember,
|
||||
): MemberMedia => {
|
||||
members.set(member.memberId, member);
|
||||
memberAdded$.next(member);
|
||||
memberScope.onEnd(() => {
|
||||
members.delete(member.memberId);
|
||||
// We stop sending to members that are no longer joined
|
||||
publishers.get(member.memberId)?.scope.end();
|
||||
});
|
||||
|
||||
// The advertised device has to be the one the membership names. The MSC
|
||||
// wants it checked against the device that sent the membership, which the
|
||||
// js-sdk doesn't expose, so this only catches an inconsistent membership
|
||||
const fullMesh$ = memberScope.behavior(
|
||||
member.transport$.pipe(
|
||||
map((transport) =>
|
||||
transport?.type === FULL_MESH_TRANSPORT_TYPE &&
|
||||
transport.raw.device_id === member.deviceId
|
||||
? transport.raw
|
||||
: undefined,
|
||||
),
|
||||
distinctUntilChanged((a, b) => a?.device_id === b?.device_id),
|
||||
),
|
||||
);
|
||||
const subscriber$ = mapScoped(memberScope, fullMesh$, (s, transport) => {
|
||||
const connection = createSubscriberConnection({
|
||||
scope: s,
|
||||
peer: {
|
||||
userId: member.userId,
|
||||
deviceId: member.deviceId,
|
||||
memberId: member.memberId,
|
||||
},
|
||||
signalling,
|
||||
iceServers,
|
||||
audioOutputDeviceId$,
|
||||
logger,
|
||||
});
|
||||
subscribers.set(member.memberId, {
|
||||
transport,
|
||||
peer: {
|
||||
userId: member.userId,
|
||||
deviceId: member.deviceId,
|
||||
memberId: member.memberId,
|
||||
},
|
||||
connection,
|
||||
});
|
||||
connection.current$
|
||||
.pipe(s.bind())
|
||||
.subscribe(() => connectionsChanged$.next());
|
||||
connection.data$.pipe(s.bind()).subscribe((message) => {
|
||||
const packet = parseDataMessage(message);
|
||||
if (packet === null)
|
||||
logger.debug(`Ignoring a data message from ${member.memberId}`);
|
||||
else data$.next({ senderId: member.rtcBackendIdentity, ...packet });
|
||||
});
|
||||
s.onEnd(() => {
|
||||
subscribers.delete(member.memberId);
|
||||
connectionsChanged$.next();
|
||||
});
|
||||
return connection;
|
||||
});
|
||||
|
||||
return {
|
||||
tracks$: memberScope.behavior(
|
||||
subscriber$.pipe(switchMap((s) => s?.tracks$ ?? of(null))),
|
||||
),
|
||||
encryptionError$: NEVER,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
transportTypes: [FULL_MESH_TRANSPORT_TYPE],
|
||||
prepareLocalTransport: async (transport) => {
|
||||
// Another backend publishes: we subscribe as we do anyway
|
||||
if (transport === null) return { canDelegateDelayedLeave: false };
|
||||
if (transport.type !== FULL_MESH_TRANSPORT_TYPE)
|
||||
throw new MatrixRTCError(
|
||||
`Not a full mesh transport: ${transport.type}`,
|
||||
);
|
||||
localTransport = transport;
|
||||
// Fetched now, so that the first subscriber isn't kept waiting
|
||||
await iceServers();
|
||||
prepared$.next(true);
|
||||
return { canDelegateDelayedLeave: false };
|
||||
},
|
||||
delegateDelayedLeave: async () =>
|
||||
Promise.reject(
|
||||
new MatrixRTCError("Full mesh cannot take over the delayed leave"),
|
||||
),
|
||||
local,
|
||||
mediaFor,
|
||||
connections$: connections$ as Behavior<BackendConnection[]>,
|
||||
setAudioOutputDeviceId: async (deviceId) => {
|
||||
audioOutputDeviceId$.next(deviceId);
|
||||
return Promise.resolve();
|
||||
},
|
||||
sendData: async (topic, text) => {
|
||||
if (!prepared$.value)
|
||||
throw new MatrixRTCError(
|
||||
"Cannot send data: not publishing on full mesh",
|
||||
);
|
||||
const message = JSON.stringify({ topic, text });
|
||||
for (const { connection } of publishers.values())
|
||||
connection.sendData(message);
|
||||
return Promise.resolve();
|
||||
},
|
||||
data$: data$.pipe(share()),
|
||||
};
|
||||
}
|
||||
|
||||
/** A message of the `sendData` data channel, or null for a malformed one. */
|
||||
function parseDataMessage(
|
||||
message: string,
|
||||
): Pick<DataPacket, "topic" | "text"> | null {
|
||||
try {
|
||||
const { topic, text } = JSON.parse(message) as Record<string, unknown>;
|
||||
return typeof topic === "string" && typeof text === "string"
|
||||
? { topic, text }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One peer connection as the developer tools show it. */
|
||||
function describe(
|
||||
{ userId, deviceId, memberId }: Peer,
|
||||
{ connectionId, pc }: PeerConnectionBase,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
userId,
|
||||
deviceId,
|
||||
memberId,
|
||||
connectionId,
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
signalingState: pc.signalingState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type Mock,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type HandedOverTrack,
|
||||
type LocalMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type PublishRequest,
|
||||
type VideoProcessor,
|
||||
} from "../../media-api";
|
||||
import { constant } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { MediaConnectionState } from "../api";
|
||||
import {
|
||||
createFullMeshLocalMedia,
|
||||
type FullMeshLocalMedia,
|
||||
} from "./LocalMedia";
|
||||
import { FakeMediaStream, fakeTrack, installWebRtcFakes } from "./test";
|
||||
|
||||
const microphone: PublishRequest = { source: "microphone" };
|
||||
const camera: PublishRequest = { source: "camera" };
|
||||
|
||||
describe("createFullMeshLocalMedia", () => {
|
||||
let scope: ObservableScope;
|
||||
let getUserMedia: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
installWebRtcFakes();
|
||||
getUserMedia = vi.fn(async (constraints: MediaStreamConstraints) => {
|
||||
const tracks: MediaStreamTrack[] = [];
|
||||
if (constraints.audio) tracks.push(fakeTrack("audio"));
|
||||
if (constraints.video)
|
||||
tracks.push(fakeTrack("video", { deviceId: "cam-in-use" }));
|
||||
return Promise.resolve(new FakeMediaStream(tracks));
|
||||
});
|
||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||
// jsdom has no media playback
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
scope = new ObservableScope();
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function create(
|
||||
publish: PublishRequest[],
|
||||
media?: LocalMedia,
|
||||
): FullMeshLocalMedia {
|
||||
return createFullMeshLocalMedia({
|
||||
scope,
|
||||
publish,
|
||||
media,
|
||||
connectionState$: constant(MediaConnectionState.Connected),
|
||||
peerConnections: () => [],
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
/** Starts publishing and waits for the capture (or the take-over) to finish. */
|
||||
async function start(
|
||||
local: FullMeshLocalMedia,
|
||||
sources: string[],
|
||||
): Promise<void> {
|
||||
local.setPublishing(true);
|
||||
await vi.waitFor(() =>
|
||||
expect(local.publications$.value.map((p) => p.source)).toEqual(sources),
|
||||
);
|
||||
}
|
||||
|
||||
function publication(
|
||||
local: FullMeshLocalMedia,
|
||||
source: string,
|
||||
): FullMeshLocalMedia["publications$"]["value"][number] {
|
||||
const found = local.publications$.value.find((p) => p.source === source);
|
||||
if (found === undefined) throw new Error(`No ${source} publication`);
|
||||
return found;
|
||||
}
|
||||
|
||||
it("captures the requests at the first start", async () => {
|
||||
const local = create([microphone, camera]);
|
||||
expect(local.tracks$.value).toBeNull();
|
||||
|
||||
await start(local, ["microphone", "camera"]);
|
||||
|
||||
expect(getUserMedia).toHaveBeenCalledTimes(2);
|
||||
expect(local.tracks$.value?.map((t) => t.source)).toEqual([
|
||||
"microphone",
|
||||
"camera",
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes a lobby's tracks over instead of capturing", async () => {
|
||||
const processor = fakeProcessor();
|
||||
const handed = {
|
||||
microphone: handedTrack("audio", { muted: true }),
|
||||
camera: handedTrack("video", {
|
||||
constraints: { deviceId: "lobby-cam" },
|
||||
processor: { processor, processedTrack: processor.output },
|
||||
}),
|
||||
};
|
||||
const media = fakeMedia([microphone, camera], handed);
|
||||
const local = create(media.requests, media);
|
||||
|
||||
await start(local, ["microphone", "camera"]);
|
||||
|
||||
expect(media.takeTracks).toHaveBeenCalledOnce();
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
const mic = publication(local, "microphone");
|
||||
expect(mic.track$.value).toBe(handed.microphone.track);
|
||||
expect(handed.microphone.track.enabled).toBe(false);
|
||||
expect(mic.mediaTrack.muted$.value).toBe(true);
|
||||
// The running processor is adopted with its output
|
||||
const cam = publication(local, "camera");
|
||||
expect(cam.track$.value).toBe(processor.output);
|
||||
expect(processor.init).not.toHaveBeenCalled();
|
||||
expect(cam.mediaTrack.muted$.value).toBe(false);
|
||||
|
||||
// Turned off and on again, the camera reopens on the lobby's device and
|
||||
// restarts the processor rather than initialising it again
|
||||
await cam.mediaTrack.setEnabled(false);
|
||||
expect(handed.camera.track.stop).toHaveBeenCalled();
|
||||
expect(processor.output.stop).toHaveBeenCalled();
|
||||
await cam.mediaTrack.setEnabled(true);
|
||||
expect(getUserMedia).toHaveBeenCalledExactlyOnceWith({
|
||||
video: expect.objectContaining({ deviceId: { exact: "lobby-cam" } }),
|
||||
});
|
||||
expect(processor.restart).toHaveBeenCalledOnce();
|
||||
expect(processor.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases a camera that was muted in the lobby", async () => {
|
||||
const handed = { camera: handedTrack("video", { muted: true }) };
|
||||
const media = fakeMedia([camera], handed);
|
||||
const local = create(media.requests, media);
|
||||
|
||||
await start(local, ["camera"]);
|
||||
|
||||
const cam = publication(local, "camera");
|
||||
expect(handed.camera.track.stop).toHaveBeenCalled();
|
||||
expect(cam.track$.value).toBeNull();
|
||||
expect(cam.mediaTrack.muted$.value).toBe(true);
|
||||
});
|
||||
|
||||
it("stops handed over tracks it no longer wants", async () => {
|
||||
const handed = { camera: handedTrack("video") };
|
||||
const media = fakeMedia([microphone], handed);
|
||||
const local = create(media.requests, media);
|
||||
|
||||
await start(local, ["microphone"]);
|
||||
|
||||
expect(handed.camera.track.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("builds a processor set while the camera is off once it turns on", async () => {
|
||||
const processor = fakeProcessor();
|
||||
const local = create([camera]);
|
||||
await start(local, ["camera"]);
|
||||
const cam = publication(local, "camera");
|
||||
const { setProcessor } = cam.mediaTrack as LocalVideoMediaTrack;
|
||||
const raw = cam.track$.value;
|
||||
|
||||
await cam.mediaTrack.setEnabled(false);
|
||||
await setProcessor(processor);
|
||||
expect(processor.init).not.toHaveBeenCalled();
|
||||
// Setting it again changes nothing
|
||||
await setProcessor(processor);
|
||||
|
||||
await cam.mediaTrack.setEnabled(true);
|
||||
expect(processor.init).toHaveBeenCalledOnce();
|
||||
expect(processor.init).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "video" }),
|
||||
expect.any(HTMLVideoElement),
|
||||
);
|
||||
expect(cam.track$.value).toBe(processor.output);
|
||||
expect(cam.track$.value).not.toBe(raw);
|
||||
expect(processor.destroy).not.toHaveBeenCalled();
|
||||
|
||||
// Removed, the raw track goes out again and the processor is torn down
|
||||
await setProcessor(undefined);
|
||||
expect(processor.destroy).toHaveBeenCalledOnce();
|
||||
expect(processor.output.stop).toHaveBeenCalled();
|
||||
expect(cam.track$.value?.kind).toBe("video");
|
||||
expect(cam.track$.value).not.toBe(processor.output);
|
||||
});
|
||||
|
||||
it("runs a processor's steps one after the other", async () => {
|
||||
const processor = fakeProcessor();
|
||||
let initDone: (() => void) | undefined;
|
||||
processor.init.mockImplementationOnce(
|
||||
async () =>
|
||||
new Promise((resolve) => {
|
||||
initDone = (): void => resolve(processor.output);
|
||||
}),
|
||||
);
|
||||
const local = create([camera]);
|
||||
await start(local, ["camera"]);
|
||||
const cam = publication(local, "camera");
|
||||
const { setProcessor } = cam.mediaTrack as LocalVideoMediaTrack;
|
||||
|
||||
// Set while the camera is on, then the camera is turned off and on while
|
||||
// its init still runs: the reopen waits for it and restarts it, rather
|
||||
// than initialising it a second time on the new track
|
||||
const setting = setProcessor(processor);
|
||||
await vi.waitFor(() => expect(processor.init).toHaveBeenCalledOnce());
|
||||
await cam.mediaTrack.setEnabled(false);
|
||||
const turningOn = cam.mediaTrack.setEnabled(true);
|
||||
await vi.waitFor(() => expect(getUserMedia).toHaveBeenCalledTimes(2));
|
||||
expect(processor.restart).not.toHaveBeenCalled();
|
||||
|
||||
initDone?.();
|
||||
await setting;
|
||||
await turningOn;
|
||||
expect(processor.init).toHaveBeenCalledOnce();
|
||||
expect(processor.restart).toHaveBeenCalledOnce();
|
||||
expect(cam.track$.value).toBe(processor.output);
|
||||
});
|
||||
});
|
||||
|
||||
function fakeProcessor(): VideoProcessor & {
|
||||
output: MediaStreamTrack;
|
||||
init: Mock<VideoProcessor["init"]>;
|
||||
restart: Mock<VideoProcessor["restart"]>;
|
||||
destroy: Mock<VideoProcessor["destroy"]>;
|
||||
} {
|
||||
const output = fakeTrack("video");
|
||||
return {
|
||||
name: "blur",
|
||||
output,
|
||||
init: vi.fn(async () => Promise.resolve(output)),
|
||||
restart: vi.fn(async () => Promise.resolve(output)),
|
||||
destroy: vi.fn(async () => Promise.resolve()),
|
||||
};
|
||||
}
|
||||
|
||||
function handedTrack(
|
||||
kind: "audio" | "video",
|
||||
rest: Partial<Omit<HandedOverTrack, "track">> = {},
|
||||
): HandedOverTrack {
|
||||
return { track: fakeTrack(kind), constraints: {}, muted: false, ...rest };
|
||||
}
|
||||
|
||||
/** A lobby's media, as the participation hands it over: only `takeTracks` matters here. */
|
||||
function fakeMedia(
|
||||
requests: PublishRequest[],
|
||||
handed: Partial<Record<"microphone" | "camera", HandedOverTrack>>,
|
||||
): LocalMedia & { takeTracks: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
requests,
|
||||
tracks$: new BehaviorSubject([]),
|
||||
capture: vi.fn(),
|
||||
release: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
takeTracks: vi.fn(
|
||||
() =>
|
||||
new Map(
|
||||
Object.entries(handed) as [
|
||||
"microphone" | "camera",
|
||||
HandedOverTrack,
|
||||
][],
|
||||
),
|
||||
),
|
||||
} as unknown as LocalMedia & { takeTracks: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
/*
|
||||
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 } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
NEVER,
|
||||
} from "rxjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { MatrixRTCError, toMatrixRTCError } from "../../errors";
|
||||
import {
|
||||
type HandedOverTrack,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type PublishRequest,
|
||||
type VideoProcessor,
|
||||
} from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { inTurn } from "../../utils/processorTurns";
|
||||
import { type LocalMediaBackend, type MediaConnectionState } from "../api";
|
||||
import { createFullMeshMediaTrack, rtpStats } from "./MediaTrack";
|
||||
|
||||
type PublishedSource = Exclude<MediaSource, "unknown">;
|
||||
|
||||
/** One local track as the publisher connections send it. */
|
||||
export interface LocalPublication {
|
||||
source: PublishedSource;
|
||||
kind: "audio" | "video";
|
||||
/**
|
||||
* The `MediaStream` it is sent in: one for the user media, one for the
|
||||
* screen share, so that the subscriber plays each in sync.
|
||||
*/
|
||||
stream: MediaStream;
|
||||
/** What goes on the wire; null while the source is off. */
|
||||
track$: Behavior<MediaStreamTrack | null>;
|
||||
mediaTrack: LocalAudioMediaTrack | LocalVideoMediaTrack;
|
||||
}
|
||||
|
||||
export interface FullMeshLocalMedia extends LocalMediaBackend {
|
||||
/** Every local track, once the first `setPublishing(true)` started the capture. */
|
||||
publications$: Behavior<LocalPublication[]>;
|
||||
/** Whether the tracks reach the publisher connections. */
|
||||
publishing$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
publish: PublishRequest[];
|
||||
/** Tracks a lobby captured ahead of the join; taken over at the start instead of capturing. */
|
||||
media?: LocalMedia;
|
||||
connectionState$: Behavior<MediaConnectionState | Error>;
|
||||
/** The publisher connections, for the outbound statistics of a track. */
|
||||
peerConnections: () => RTCPeerConnection[];
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
interface Capture {
|
||||
/** What was captured, before any processor. */
|
||||
raw: MediaStreamTrack;
|
||||
out: MediaStreamTrack;
|
||||
}
|
||||
|
||||
/** The local member's side: capture, the publication list and the controls. */
|
||||
export function createFullMeshLocalMedia({
|
||||
scope,
|
||||
publish: initialRequests,
|
||||
media,
|
||||
connectionState$,
|
||||
peerConnections,
|
||||
logger: parentLogger,
|
||||
}: Props): FullMeshLocalMedia {
|
||||
const logger = parentLogger.getChild("[LocalMedia]");
|
||||
// Only their ids matter: the tracks are added per connection
|
||||
const userMediaStream = new MediaStream();
|
||||
const screenShareStream = new MediaStream();
|
||||
const desired = new Map<PublishRequest["source"], PublishRequest>(
|
||||
initialRequests.map((r) => [r.source, r]),
|
||||
);
|
||||
const started$ = new BehaviorSubject(false);
|
||||
const publishing$ = new BehaviorSubject(false);
|
||||
const publishError$ = new BehaviorSubject<Error | null>(null);
|
||||
const publications$ = new BehaviorSubject<LocalPublication[]>([]);
|
||||
const teardowns = new Map<PublishedSource, () => void>();
|
||||
|
||||
const statsFor = async (track: MediaStreamTrack | null) => {
|
||||
for (const pc of peerConnections())
|
||||
if (pc.getSenders().some((s) => s.track === track && track !== null))
|
||||
return rtpStats(pc, track, "outbound-rtp");
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Adds a publication whose track is managed by `control`. */
|
||||
const addPublication = (
|
||||
source: PublishedSource,
|
||||
kind: "audio" | "video",
|
||||
stream: MediaStream,
|
||||
initial: MediaStreamTrack | null,
|
||||
enabled: boolean,
|
||||
control: {
|
||||
setEnabled: (
|
||||
enabled: boolean,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<boolean>;
|
||||
setDevice?: (
|
||||
deviceId: string,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<void>;
|
||||
switchFacingMode?: (
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<string | undefined>;
|
||||
setProcessor?: (
|
||||
p: VideoProcessor | undefined,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
) => Promise<void>;
|
||||
stop: () => void;
|
||||
},
|
||||
): LocalPublication => {
|
||||
const pubScope = new ObservableScope();
|
||||
const track$ = new BehaviorSubject<MediaStreamTrack | null>(initial);
|
||||
const enabled$ = new BehaviorSubject(enabled);
|
||||
const base = createFullMeshMediaTrack(pubScope, {
|
||||
source,
|
||||
kind,
|
||||
id: uuidv4(),
|
||||
track$,
|
||||
muted$: pubScope.behavior(enabled$.pipe(map((e) => !e))),
|
||||
stats: async () => statsFor(track$.value),
|
||||
});
|
||||
const local = {
|
||||
setEnabled: async (enabled: boolean): Promise<boolean> => {
|
||||
const result = await control.setEnabled(enabled, track$);
|
||||
enabled$.next(result);
|
||||
return result;
|
||||
},
|
||||
setDevice: async (deviceId: string): Promise<void> => {
|
||||
if (!control.setDevice)
|
||||
throw new MatrixRTCError(`A ${source} track has no device`);
|
||||
await control.setDevice(deviceId, track$);
|
||||
},
|
||||
};
|
||||
const mediaTrack =
|
||||
base.kind === "audio"
|
||||
? { ...base, ...local }
|
||||
: {
|
||||
...base,
|
||||
...local,
|
||||
facingMode$: pubScope.behavior(
|
||||
track$.pipe(map((t) => facingModeOf(t))),
|
||||
),
|
||||
switchFacingMode: async () => control.switchFacingMode?.(track$),
|
||||
setProcessor: async (p: VideoProcessor | undefined) =>
|
||||
control.setProcessor?.(p, track$),
|
||||
};
|
||||
const publication: LocalPublication = {
|
||||
source,
|
||||
kind,
|
||||
stream,
|
||||
track$,
|
||||
mediaTrack: mediaTrack as LocalAudioMediaTrack | LocalVideoMediaTrack,
|
||||
};
|
||||
publications$.next([...publications$.value, publication]);
|
||||
teardowns.set(source, () => {
|
||||
control.stop();
|
||||
track$.value?.stop();
|
||||
pubScope.end();
|
||||
publications$.next(publications$.value.filter((p) => p !== publication));
|
||||
teardowns.delete(source);
|
||||
});
|
||||
return publication;
|
||||
};
|
||||
|
||||
const captureMicrophone = async (
|
||||
request: Extract<PublishRequest, { source: "microphone" }>,
|
||||
handed?: HandedOverTrack,
|
||||
): Promise<void> => {
|
||||
const track =
|
||||
handed?.track ??
|
||||
(await getTrack({
|
||||
audio: { deviceId: exact(request.deviceId), ...request.capture },
|
||||
}));
|
||||
const enabled = !handed?.muted;
|
||||
track.enabled = enabled;
|
||||
addPublication("microphone", "audio", userMediaStream, track, enabled, {
|
||||
// Muting a microphone keeps the device: unmuting is instant
|
||||
setEnabled: async (enabled, track$) => {
|
||||
if (track$.value) track$.value.enabled = enabled;
|
||||
return Promise.resolve(enabled);
|
||||
},
|
||||
setDevice: async (deviceId, track$) => {
|
||||
const next = await getTrack({
|
||||
audio: { deviceId: { exact: deviceId }, ...request.capture },
|
||||
});
|
||||
next.enabled = track$.value?.enabled ?? true;
|
||||
track$.value?.stop();
|
||||
track$.next(next);
|
||||
},
|
||||
stop: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
const captureCamera = async (
|
||||
request: Extract<PublishRequest, { source: "camera" }>,
|
||||
handed?: HandedOverTrack,
|
||||
): Promise<void> => {
|
||||
// The device the lobby ended up on, so that a reopen stays on it
|
||||
const handedDevice = handed?.constraints.deviceId;
|
||||
let deviceId =
|
||||
typeof handedDevice === "string" ? handedDevice : request.deviceId;
|
||||
let facingMode: string | undefined;
|
||||
// A lobby's processor comes with its output: it is running already
|
||||
let processor = handed?.processor?.processor ?? request.processor;
|
||||
let processorStarted = handed?.processor !== undefined;
|
||||
let capture: Capture | null = null;
|
||||
// What the processor reads frames from; a processor needs a playing element
|
||||
let input: HTMLVideoElement | undefined;
|
||||
const inputFor = (raw: MediaStreamTrack): HTMLVideoElement => {
|
||||
input ??= document.createElement("video");
|
||||
input.muted = true;
|
||||
input.playsInline = true;
|
||||
input.srcObject = new MediaStream([raw]);
|
||||
// A test DOM returns nothing from play
|
||||
(input.play() as Promise<void> | undefined)?.catch(() => {});
|
||||
return input;
|
||||
};
|
||||
const constraints = (): MediaTrackConstraints => ({
|
||||
deviceId: exact(deviceId),
|
||||
facingMode,
|
||||
width: request.capture?.resolution?.width,
|
||||
height: request.capture?.resolution?.height,
|
||||
frameRate: request.capture?.resolution?.frameRate,
|
||||
});
|
||||
/** Runs the processor on `raw`, in its turn; `raw` itself without one. */
|
||||
const process = async (
|
||||
raw: MediaStreamTrack,
|
||||
): Promise<MediaStreamTrack> => {
|
||||
const p = processor;
|
||||
if (p === undefined) return raw;
|
||||
// Started is noted within the turn, so that the next turn sees it
|
||||
return inTurn(p, async () => {
|
||||
const out = processorStarted
|
||||
? await p.restart(raw, inputFor(raw))
|
||||
: await p.init(raw, inputFor(raw));
|
||||
processorStarted = true;
|
||||
return out;
|
||||
});
|
||||
};
|
||||
const open = async (): Promise<Capture> => {
|
||||
const raw = await getTrack({ video: constraints() });
|
||||
return { raw, out: await process(raw) };
|
||||
};
|
||||
const close = (): void => {
|
||||
capture?.raw.stop();
|
||||
if (capture?.out !== capture?.raw) capture?.out.stop();
|
||||
capture = null;
|
||||
if (input) input.srcObject = null;
|
||||
};
|
||||
// Reopens the camera with the current settings, if it is on
|
||||
const reopen = async (
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
): Promise<void> => {
|
||||
if (capture === null) return;
|
||||
close();
|
||||
capture = await open();
|
||||
track$.next(capture.out);
|
||||
};
|
||||
if (handed) {
|
||||
capture = {
|
||||
raw: handed.track,
|
||||
out: handed.processor?.processedTrack ?? handed.track,
|
||||
};
|
||||
// A camera muted in the lobby is released, as one muted in the call is
|
||||
if (handed.muted) close();
|
||||
} else capture = await open();
|
||||
addPublication(
|
||||
"camera",
|
||||
"video",
|
||||
userMediaStream,
|
||||
capture?.out ?? null,
|
||||
capture !== null,
|
||||
{
|
||||
// Muting a camera releases it, so that its light goes off
|
||||
setEnabled: async (enabled, track$) => {
|
||||
if (enabled === (capture !== null)) return enabled;
|
||||
if (!enabled) {
|
||||
close();
|
||||
track$.next(null);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
capture = await open();
|
||||
track$.next(capture.out);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warn("Could not reopen the camera", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
setDevice: async (id, track$) => {
|
||||
deviceId = id;
|
||||
facingMode = undefined;
|
||||
await reopen(track$);
|
||||
},
|
||||
switchFacingMode: async (track$) => {
|
||||
const current = facingModeOf(track$.value);
|
||||
if (current === undefined) return undefined;
|
||||
deviceId = undefined;
|
||||
facingMode = current === "user" ? "environment" : "user";
|
||||
await reopen(track$);
|
||||
return capture?.raw.getSettings().deviceId;
|
||||
},
|
||||
// While the camera is off the processor is kept for when it turns on
|
||||
setProcessor: async (p, track$) => {
|
||||
if (p === processor) return;
|
||||
const previous = processor;
|
||||
processor = p;
|
||||
processorStarted = false;
|
||||
if (previous) await inTurn(previous, async () => previous.destroy());
|
||||
const current = capture;
|
||||
if (current === null) return;
|
||||
const out = await process(current.raw);
|
||||
// Turned off or reopened meanwhile: that capture has its own output
|
||||
if (capture !== current) return;
|
||||
if (current.out !== current.raw) current.out.stop();
|
||||
capture = { raw: current.raw, out };
|
||||
track$.next(out);
|
||||
},
|
||||
stop: () => {
|
||||
close();
|
||||
const p = processor;
|
||||
if (p) void inTurn(p, async () => p.destroy());
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const captureScreen = async (
|
||||
request: Extract<PublishRequest, { source: "screenShare" }>,
|
||||
): Promise<void> => {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
width: request.capture?.resolution?.width,
|
||||
height: request.capture?.resolution?.height,
|
||||
frameRate: request.capture?.resolution?.frameRate,
|
||||
},
|
||||
audio: request.audio ?? true,
|
||||
});
|
||||
const [video] = stream.getVideoTracks();
|
||||
const [audio] = stream.getAudioTracks();
|
||||
const toggle = async (
|
||||
enabled: boolean,
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>,
|
||||
): Promise<boolean> => {
|
||||
if (track$.value) track$.value.enabled = enabled;
|
||||
return Promise.resolve(enabled);
|
||||
};
|
||||
addPublication("screenShare", "video", screenShareStream, video, true, {
|
||||
setEnabled: toggle,
|
||||
// The audio goes with its video
|
||||
stop: () => {
|
||||
audio?.stop();
|
||||
teardowns.get("screenShareAudio")?.();
|
||||
},
|
||||
});
|
||||
if (audio)
|
||||
addPublication(
|
||||
"screenShareAudio",
|
||||
"audio",
|
||||
screenShareStream,
|
||||
audio,
|
||||
true,
|
||||
{
|
||||
setEnabled: toggle,
|
||||
stop: () => {},
|
||||
},
|
||||
);
|
||||
// Stopped from the browser's own controls
|
||||
video.addEventListener("ended", () => void unpublish("screenShare"));
|
||||
};
|
||||
|
||||
const capture = async (
|
||||
request: PublishRequest,
|
||||
handed?: HandedOverTrack,
|
||||
): Promise<void> => {
|
||||
teardowns.get(request.source)?.();
|
||||
switch (request.source) {
|
||||
case "microphone":
|
||||
return captureMicrophone(request, handed);
|
||||
case "camera":
|
||||
return captureCamera(request, handed);
|
||||
case "screenShare":
|
||||
return captureScreen(request);
|
||||
}
|
||||
};
|
||||
|
||||
// A lobby's tracks are taken over here rather than captured again
|
||||
const start = async (): Promise<void> => {
|
||||
const handed = media?.takeTracks() ?? new Map<string, HandedOverTrack>();
|
||||
for (const request of desired.values()) {
|
||||
const track = handed.get(request.source);
|
||||
handed.delete(request.source);
|
||||
await capture(request, track).catch((e: unknown) => {
|
||||
logger.warn(`Could not capture ${request.source}`, e);
|
||||
if (publishError$.value === null)
|
||||
publishError$.next(toMatrixRTCError(e));
|
||||
});
|
||||
}
|
||||
// Handed over but no longer wanted
|
||||
for (const { track, processor } of handed.values()) {
|
||||
track.stop();
|
||||
processor?.processedTrack.stop();
|
||||
}
|
||||
};
|
||||
|
||||
const setPublishing = (publish: boolean): void => {
|
||||
publishing$.next(publish);
|
||||
if (publish && !started$.value) {
|
||||
started$.next(true);
|
||||
void start();
|
||||
}
|
||||
};
|
||||
|
||||
const publish = async (
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack> => {
|
||||
desired.set(request.source, request);
|
||||
try {
|
||||
// Before the start, the start captures everything desired itself
|
||||
if (started$.value) await capture(request);
|
||||
return await firstValueFrom(
|
||||
publications$.pipe(
|
||||
map((ps) => ps.find((p) => p.source === request.source)),
|
||||
filter((p) => p !== undefined),
|
||||
map((p) => p.mediaTrack),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
desired.delete(request.source);
|
||||
throw toMatrixRTCError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const unpublish = async (source: MediaSource): Promise<void> => {
|
||||
const published = source === "screenShareAudio" ? "screenShare" : source;
|
||||
if (published === "unknown") return;
|
||||
desired.delete(published);
|
||||
teardowns.get(published)?.();
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
scope.onEnd(() => {
|
||||
// Each teardown removes itself from the map
|
||||
for (const teardown of Array.from(teardowns.values())) teardown();
|
||||
});
|
||||
|
||||
const tracks$ = scope.behavior(
|
||||
combineLatest([started$, publications$]).pipe(
|
||||
map(([started, ps]) => (started ? ps.map((p) => p.mediaTrack) : null)),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
connectionState$,
|
||||
tracks$,
|
||||
encryptionError$: NEVER,
|
||||
setPublishing,
|
||||
publishError$,
|
||||
publish,
|
||||
unpublish,
|
||||
publications$,
|
||||
publishing$,
|
||||
};
|
||||
}
|
||||
|
||||
function exact(deviceId: string | undefined): ConstrainDOMString | undefined {
|
||||
return deviceId === undefined ? undefined : { exact: deviceId };
|
||||
}
|
||||
|
||||
async function getTrack(
|
||||
constraints: MediaStreamConstraints,
|
||||
): Promise<MediaStreamTrack> {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const [track] = stream.getTracks();
|
||||
if (track === undefined) throw new MatrixRTCError("No track was captured");
|
||||
return track;
|
||||
}
|
||||
|
||||
function facingModeOf(
|
||||
track: MediaStreamTrack | null,
|
||||
): "user" | "environment" | undefined {
|
||||
const mode = track?.getSettings().facingMode;
|
||||
return mode === "user" || mode === "environment" ? mode : undefined;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
scan,
|
||||
switchMap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior, constant } from "../../reactive/Behavior";
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
type AudioMediaTrack,
|
||||
type MediaSource,
|
||||
type MediaStreamStats,
|
||||
type MediaTrack,
|
||||
type VideoMediaTrack,
|
||||
} from "../../media-api";
|
||||
|
||||
export interface TrackProps {
|
||||
source: MediaSource;
|
||||
kind: "audio" | "video";
|
||||
id: string;
|
||||
/** The track behind it; may change, as on a device switch, or be null while off. */
|
||||
track$: Behavior<MediaStreamTrack | null>;
|
||||
muted$: Behavior<boolean>;
|
||||
/** Statistics of the RTP stream carrying it, if there is one. */
|
||||
stats: () => Promise<MediaStreamStats>;
|
||||
/** Where attached elements play; only for tracks that are played. */
|
||||
audioOutputDeviceId$?: Behavior<string | undefined>;
|
||||
/** Whether to follow where attached video elements are shown, for `viewport$`. */
|
||||
observeViewport?: boolean;
|
||||
}
|
||||
|
||||
/** Where a video is shown: the largest of its visible elements, in device pixels if known. */
|
||||
export interface Viewport {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type FullMeshMediaTrack = (AudioMediaTrack | VideoMediaTrack) & {
|
||||
/** Null while no attached element is visible; always null unless observed. */
|
||||
viewport$: Behavior<Viewport | null>;
|
||||
};
|
||||
|
||||
/** Above this level a track turns active; below `INACTIVE_LEVEL` for `INACTIVE_AFTER_MS` it turns inactive. */
|
||||
const ACTIVE_LEVEL = 0.04;
|
||||
const INACTIVE_LEVEL = 0.02;
|
||||
const LEVEL_INTERVAL_MS = 100;
|
||||
const INACTIVE_AFTER_MS = 500;
|
||||
|
||||
let levelContext: AudioContext | undefined;
|
||||
|
||||
/**
|
||||
* A `MediaStreamTrack` as a `MediaTrack`: the same object for the life of a
|
||||
* publication, with every attached element following the track behind it.
|
||||
*/
|
||||
export function createFullMeshMediaTrack(
|
||||
scope: ObservableScope,
|
||||
props: TrackProps,
|
||||
): FullMeshMediaTrack {
|
||||
const { source, kind, id, track$, muted$, stats, audioOutputDeviceId$ } =
|
||||
props;
|
||||
const attached = new Set<HTMLMediaElement>();
|
||||
const viewport =
|
||||
kind === "video" && props.observeViewport
|
||||
? observeViewport(scope)
|
||||
: undefined;
|
||||
let audioContext: AudioContext | undefined;
|
||||
let audioPlugins: AudioNode[] = [];
|
||||
let volume = 1;
|
||||
let webAudio: { nodes: AudioNode[]; gain: GainNode } | undefined;
|
||||
|
||||
const routeWebAudio = (track: MediaStreamTrack | null): void => {
|
||||
for (const node of webAudio?.nodes ?? []) node.disconnect();
|
||||
webAudio = undefined;
|
||||
if (audioContext === undefined || track === null) return;
|
||||
const input = audioContext.createMediaStreamSource(
|
||||
new MediaStream([track]),
|
||||
);
|
||||
const gain = audioContext.createGain();
|
||||
gain.gain.value = volume;
|
||||
const chain: AudioNode[] = [input, ...audioPlugins, gain];
|
||||
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
|
||||
gain.connect(audioContext.destination);
|
||||
webAudio = { nodes: chain, gain };
|
||||
};
|
||||
|
||||
const apply = (element: HTMLMediaElement): void => {
|
||||
const track = track$.value;
|
||||
element.srcObject = track ? new MediaStream([track]) : null;
|
||||
if (kind === "audio") {
|
||||
// Played through Web Audio instead, where it is routed there
|
||||
element.muted = audioContext !== undefined;
|
||||
element.volume = volume;
|
||||
const sinkId = audioOutputDeviceId$?.value;
|
||||
if (sinkId !== undefined && "setSinkId" in element)
|
||||
void element.setSinkId(sinkId).catch(() => {});
|
||||
}
|
||||
if (element.paused) void element.play().catch(() => {});
|
||||
};
|
||||
|
||||
track$.pipe(scope.bind()).subscribe((track) => {
|
||||
for (const element of attached) apply(element);
|
||||
if (kind === "audio") routeWebAudio(track);
|
||||
});
|
||||
audioOutputDeviceId$?.pipe(scope.bind()).subscribe(() => {
|
||||
for (const element of attached) apply(element);
|
||||
});
|
||||
scope.onEnd(() => {
|
||||
for (const element of attached) element.srcObject = null;
|
||||
attached.clear();
|
||||
routeWebAudio(null);
|
||||
});
|
||||
|
||||
const base: MediaTrack = {
|
||||
source,
|
||||
kind,
|
||||
id,
|
||||
muted$,
|
||||
// DTLS-SRTP, its fingerprints exchanged over Olm
|
||||
encrypted: true,
|
||||
stats$: (intervalMs): Observable<MediaStreamStats> =>
|
||||
timer(0, intervalMs).pipe(
|
||||
switchMap(async () => stats()),
|
||||
scope.bind(),
|
||||
),
|
||||
attach: (element) => {
|
||||
if (attached.has(element)) return;
|
||||
attached.add(element);
|
||||
apply(element);
|
||||
viewport?.observe(element);
|
||||
},
|
||||
detach: (element) => {
|
||||
if (!attached.delete(element)) return;
|
||||
element.srcObject = null;
|
||||
viewport?.unobserve(element);
|
||||
},
|
||||
};
|
||||
|
||||
if (kind === "video")
|
||||
return {
|
||||
...base,
|
||||
kind: "video",
|
||||
viewport$: viewport?.viewport$ ?? constant(null),
|
||||
};
|
||||
|
||||
const audioLevel$ = scope.behavior(
|
||||
combineLatest([track$, muted$]).pipe(
|
||||
switchMap(([track, muted]) =>
|
||||
track === null || muted ? of(0) : level$(track),
|
||||
),
|
||||
),
|
||||
0,
|
||||
);
|
||||
const isActive$ = scope.behavior(
|
||||
audioLevel$.pipe(
|
||||
scan(
|
||||
({ active, quietMs }, level) => {
|
||||
if (level >= ACTIVE_LEVEL) return { active: true, quietMs: 0 };
|
||||
if (level >= INACTIVE_LEVEL) return { active, quietMs: 0 };
|
||||
const quiet = quietMs + LEVEL_INTERVAL_MS;
|
||||
return {
|
||||
active: active && quiet < INACTIVE_AFTER_MS,
|
||||
quietMs: quiet,
|
||||
};
|
||||
},
|
||||
{ active: false, quietMs: 0 },
|
||||
),
|
||||
map(({ active }) => active),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
kind: "audio",
|
||||
viewport$: constant(null),
|
||||
audioLevel$,
|
||||
isActive$,
|
||||
setAudioContext: (ctx, plugins = []) => {
|
||||
audioContext = ctx;
|
||||
audioPlugins = plugins;
|
||||
routeWebAudio(track$.value);
|
||||
for (const element of attached) apply(element);
|
||||
},
|
||||
setVolume: (v) => {
|
||||
volume = v;
|
||||
if (webAudio) webAudio.gain.gain.value = v;
|
||||
for (const element of attached) element.volume = v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the size and visibility of elements. Where the platform has no
|
||||
* observers, an element counts as visible at an unknown size.
|
||||
*/
|
||||
function observeViewport(scope: ObservableScope): {
|
||||
viewport$: Behavior<Viewport | null>;
|
||||
observe: (element: Element) => void;
|
||||
unobserve: (element: Element) => void;
|
||||
} {
|
||||
const shown = new Map<
|
||||
Element,
|
||||
{ visible: boolean; width?: number; height?: number }
|
||||
>();
|
||||
const viewport$ = new BehaviorSubject<Viewport | null>(null);
|
||||
const update = (): void => {
|
||||
const visible = [...shown.values()].filter(
|
||||
({ visible, width, height }) => visible && width !== 0 && height !== 0,
|
||||
);
|
||||
// One element of unknown size leaves the size unknown
|
||||
const next: Viewport | null =
|
||||
visible.length === 0
|
||||
? null
|
||||
: visible.some((e) => e.width === undefined || e.height === undefined)
|
||||
? {}
|
||||
: {
|
||||
width: Math.max(...visible.map((e) => e.width!)),
|
||||
height: Math.max(...visible.map((e) => e.height!)),
|
||||
};
|
||||
const current = viewport$.value;
|
||||
if (
|
||||
(current === null) !== (next === null) ||
|
||||
current?.width !== next?.width ||
|
||||
current?.height !== next?.height
|
||||
)
|
||||
viewport$.next(next);
|
||||
};
|
||||
|
||||
const pixelRatio = globalThis.devicePixelRatio ?? 1;
|
||||
const resizes =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? undefined
|
||||
: new ResizeObserver((entries) => {
|
||||
for (const { target, contentRect } of entries) {
|
||||
const entry = shown.get(target);
|
||||
if (entry === undefined) continue;
|
||||
entry.width = Math.ceil(contentRect.width * pixelRatio);
|
||||
entry.height = Math.ceil(contentRect.height * pixelRatio);
|
||||
}
|
||||
update();
|
||||
});
|
||||
const intersections =
|
||||
typeof IntersectionObserver === "undefined"
|
||||
? undefined
|
||||
: new IntersectionObserver((entries) => {
|
||||
for (const { target, isIntersecting } of entries) {
|
||||
const entry = shown.get(target);
|
||||
if (entry !== undefined) entry.visible = isIntersecting;
|
||||
}
|
||||
update();
|
||||
});
|
||||
scope.onEnd(() => {
|
||||
resizes?.disconnect();
|
||||
intersections?.disconnect();
|
||||
});
|
||||
|
||||
return {
|
||||
viewport$: scope.behavior(viewport$),
|
||||
observe: (element): void => {
|
||||
// Visible until the observers say otherwise, so that nothing is paused
|
||||
// before they first report
|
||||
shown.set(element, { visible: true });
|
||||
resizes?.observe(element);
|
||||
intersections?.observe(element);
|
||||
update();
|
||||
},
|
||||
unobserve: (element): void => {
|
||||
shown.delete(element);
|
||||
resizes?.unobserve(element);
|
||||
intersections?.unobserve(element);
|
||||
update();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The RMS level of an audio track, 0 to 1, sampled while subscribed. */
|
||||
function level$(track: MediaStreamTrack): Observable<number> {
|
||||
return new Observable<number>((subscriber) => {
|
||||
levelContext ??= new AudioContext();
|
||||
const ctx = levelContext;
|
||||
const input = ctx.createMediaStreamSource(new MediaStream([track]));
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
input.connect(analyser);
|
||||
const samples = new Float32Array(analyser.fftSize);
|
||||
const sub = timer(0, LEVEL_INTERVAL_MS).subscribe(() => {
|
||||
analyser.getFloatTimeDomainData(samples);
|
||||
let sum = 0;
|
||||
for (const s of samples) sum += s * s;
|
||||
subscriber.next(Math.min(1, Math.sqrt(sum / samples.length) * 4));
|
||||
});
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
input.disconnect();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The inbound or outbound RTP statistics of one track on a peer connection. */
|
||||
export async function rtpStats(
|
||||
pc: RTCPeerConnection | undefined,
|
||||
track: MediaStreamTrack | null,
|
||||
direction: "inbound-rtp" | "outbound-rtp",
|
||||
): Promise<MediaStreamStats> {
|
||||
if (pc === undefined || track === null) return undefined;
|
||||
const report = await pc.getStats(track);
|
||||
for (const stats of report.values())
|
||||
if ((stats as RTCStats).type === direction)
|
||||
return stats as RTCInboundRtpStreamStats & RTCOutboundRtpStreamStats;
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { MediaConnectionState } from "../api";
|
||||
import {
|
||||
CANDIDATE_BATCH_MS,
|
||||
createPeerConnectionBase,
|
||||
type PeerConnectionBase,
|
||||
} from "./PeerConnection";
|
||||
import { type ControlMessage, CONTROL_CHANNEL_LABEL } from "./ControlChannel";
|
||||
import {
|
||||
type FakeSignalling,
|
||||
fakeSignalling,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
describe("createPeerConnectionBase", () => {
|
||||
let scope: ObservableScope;
|
||||
let signalling: FakeSignalling;
|
||||
let base: PeerConnectionBase;
|
||||
let received: ControlMessage[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
installWebRtcFakes();
|
||||
scope = new ObservableScope();
|
||||
signalling = fakeSignalling();
|
||||
received = [];
|
||||
base = createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId: "c1",
|
||||
signalling,
|
||||
iceServers: [{ urls: ["turn:example.org"] }],
|
||||
onControlMessage: (message) => received.push(message),
|
||||
logger,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the ICE servers it was given", () => {
|
||||
expect(lastPeerConnection().config.iceServers).toEqual([
|
||||
{ urls: ["turn:example.org"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the candidates gathered within a batch in one message", () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.gather({ candidate: "a", sdpMid: "0", sdpMLineIndex: 0 });
|
||||
pc.gather({ candidate: "b", sdpMid: "0", sdpMLineIndex: 0 });
|
||||
pc.gather(null);
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(signalling.sent()).toEqual([
|
||||
{
|
||||
type: "candidates",
|
||||
candidates: [
|
||||
{ candidate: "a", sdpMid: "0", sdpMLineIndex: 0 },
|
||||
{ candidate: "b", sdpMid: "0", sdpMLineIndex: 0 },
|
||||
// The end of the gathering
|
||||
{ candidate: "", sdpMid: null, sdpMLineIndex: null },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(signalling.sentConnectionIds()).toEqual(["c1"]);
|
||||
});
|
||||
|
||||
it("drops pending candidates when it closes", () => {
|
||||
lastPeerConnection().gather({ candidate: "a" });
|
||||
base.close("bye", false);
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs queued operations in order, past a failing one", async () => {
|
||||
const order: string[] = [];
|
||||
base.enqueue(async () => {
|
||||
order.push("first");
|
||||
return Promise.reject(new Error("failed"));
|
||||
});
|
||||
base.enqueue(async () => {
|
||||
order.push("second");
|
||||
return Promise.resolve();
|
||||
});
|
||||
await settle();
|
||||
expect(order).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("applies received candidates, ignoring one that fails", async () => {
|
||||
base.addCandidates([{ candidate: "bad" }, { candidate: "good" }]);
|
||||
await settle();
|
||||
expect(lastPeerConnection().addedCandidates).toEqual([
|
||||
{ candidate: "good" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tells the peer once when it closes", () => {
|
||||
base.close("done", true);
|
||||
base.close("again", true);
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(signalling.sent()).toEqual([{ type: "close", reason: "done" }]);
|
||||
});
|
||||
|
||||
it("doesn't tell a peer that closed first", () => {
|
||||
base.close("theirs", false);
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes when its scope ends", () => {
|
||||
scope.end();
|
||||
expect(lastPeerConnection().closed).toBe(true);
|
||||
expect(signalling.sent()).toEqual([{ type: "close", reason: "ended" }]);
|
||||
});
|
||||
|
||||
it("maps the connection state", () => {
|
||||
const pc = lastPeerConnection();
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Initialized);
|
||||
pc.setConnectionState("connecting");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Connecting);
|
||||
pc.setConnectionState("connected");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Connected);
|
||||
pc.setConnectionState("disconnected");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Reconnecting);
|
||||
pc.setConnectionState("failed");
|
||||
expect(base.state$.value).toBeInstanceOf(Error);
|
||||
pc.setConnectionState("closed");
|
||||
expect(base.state$.value).toBe(MediaConnectionState.Stopped);
|
||||
});
|
||||
|
||||
describe("the control channel", () => {
|
||||
const offer: ControlMessage = {
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "o" },
|
||||
};
|
||||
const metadata = (muted: boolean): ControlMessage => ({
|
||||
type: "track_metadata",
|
||||
track_metadata: { "0": { purpose: "m.usermedia", muted } },
|
||||
});
|
||||
|
||||
it("is negotiated, with the reserved label and id", () => {
|
||||
base.openControlChannel();
|
||||
base.openControlChannel();
|
||||
const [channel] = lastPeerConnection().dataChannels;
|
||||
expect(lastPeerConnection().dataChannels).toHaveLength(1);
|
||||
expect(channel.label).toBe(CONTROL_CHANNEL_LABEL);
|
||||
expect(channel.init).toEqual({ negotiated: true, id: 0 });
|
||||
});
|
||||
|
||||
it("carries every message once it is open, apart from close", () => {
|
||||
base.openControlChannel();
|
||||
base.send(offer);
|
||||
expect(signalling.sent()).toEqual([offer]);
|
||||
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
base.answerReceived();
|
||||
base.send(offer);
|
||||
lastPeerConnection().gather({ candidate: "a" });
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(channel.sentMessages()).toEqual([
|
||||
offer,
|
||||
{ type: "candidates", candidates: [{ candidate: "a" }] },
|
||||
]);
|
||||
|
||||
base.close("done", true);
|
||||
expect(signalling.sent()).toEqual([
|
||||
offer,
|
||||
{ type: "close", reason: "done" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("is only switched to once no to-device offer awaits its answer", () => {
|
||||
base.openControlChannel();
|
||||
base.send(offer);
|
||||
base.send(metadata(true));
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
// A late to-device offer could otherwise overwrite this metadata
|
||||
lastPeerConnection().gather({ candidate: "a" });
|
||||
vi.advanceTimersByTime(CANDIDATE_BATCH_MS);
|
||||
expect(channel.sentMessages()).toEqual([]);
|
||||
expect(signalling.sent()).toEqual([
|
||||
offer,
|
||||
{ type: "candidates", candidates: [{ candidate: "a" }] },
|
||||
]);
|
||||
|
||||
base.answerReceived();
|
||||
expect(channel.sentMessages()).toEqual([metadata(true)]);
|
||||
});
|
||||
|
||||
it("holds the latest metadata until it opens", () => {
|
||||
base.openControlChannel();
|
||||
base.send(metadata(true));
|
||||
base.send(metadata(false));
|
||||
expect(signalling.send).not.toHaveBeenCalled();
|
||||
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
expect(channel.sentMessages()).toEqual([metadata(false)]);
|
||||
});
|
||||
|
||||
it("hands over the messages it receives, ignoring what it doesn't know", () => {
|
||||
base.openControlChannel();
|
||||
const channel = lastPeerConnection().control();
|
||||
channel.open();
|
||||
channel.receive(metadata(true));
|
||||
channel.receive({ type: "future_message" });
|
||||
channel.receive("not json");
|
||||
channel.receive({ type: "description", description: { type: "x" } });
|
||||
expect(received).toEqual([metadata(true)]);
|
||||
});
|
||||
|
||||
it("fails the connection when it closes on its own", () => {
|
||||
base.openControlChannel();
|
||||
const pc = lastPeerConnection();
|
||||
pc.setConnectionState("connected");
|
||||
pc.control().open();
|
||||
pc.control().close();
|
||||
expect(base.state$.value).toEqual(
|
||||
new Error("The control channel closed"),
|
||||
);
|
||||
});
|
||||
|
||||
it("doesn't fail a connection we close", () => {
|
||||
base.openControlChannel();
|
||||
lastPeerConnection().control().open();
|
||||
base.close("done", true);
|
||||
expect(base.state$.value).not.toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
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 the publisher and the subscriber side of a connection share. */
|
||||
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
fromEvent,
|
||||
map,
|
||||
startWith,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { MediaConnectionState } from "../api";
|
||||
import {
|
||||
type ControlMessage,
|
||||
createControlChannel,
|
||||
parseControlMessage,
|
||||
} from "./ControlChannel";
|
||||
import { type Peer, type Signalling } from "./signalling";
|
||||
|
||||
/** How long trickled candidates are collected before they are sent in one message. */
|
||||
export const CANDIDATE_BATCH_MS = 200;
|
||||
|
||||
type MetadataMessage = Extract<
|
||||
ControlMessage,
|
||||
{ type: "track_metadata" | "consumption_metadata" }
|
||||
>;
|
||||
|
||||
export interface PeerConnectionBase {
|
||||
readonly connectionId: string;
|
||||
readonly pc: RTCPeerConnection;
|
||||
readonly state$: Behavior<MediaConnectionState | Error>;
|
||||
/** Runs `op` after every operation queued before it, so that SDP and candidates apply in order. */
|
||||
enqueue(op: () => Promise<void>): void;
|
||||
/**
|
||||
* Creates our side of the control channel. The publisher does so before
|
||||
* its first offer, the subscriber once it applied that offer.
|
||||
*/
|
||||
openControlChannel(): void;
|
||||
/**
|
||||
* Sends a message to the peer: over the control channel once we switched
|
||||
* to it, before that as a to-device message. Metadata has no to-device
|
||||
* message of its own: the latest waits for the switch.
|
||||
*/
|
||||
send(message: ControlMessage): void;
|
||||
/**
|
||||
* Tells that the answer to our latest offer arrived. We switch to the
|
||||
* control channel only once no to-device offer is waiting for its answer,
|
||||
* so that a late offer can't overwrite newer metadata.
|
||||
*/
|
||||
answerReceived(): void;
|
||||
/** Applies a received batch of candidates. */
|
||||
addCandidates(candidates: RTCIceCandidateInit[]): void;
|
||||
/** Closes the peer connection, telling the peer unless it closed first. */
|
||||
close(reason: string | undefined, notifyPeer: boolean): void;
|
||||
}
|
||||
|
||||
export function createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
onControlMessage,
|
||||
logger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
connectionId: string;
|
||||
signalling: Signalling;
|
||||
iceServers: RTCIceServer[];
|
||||
/** A message the peer sent over the control channel. */
|
||||
onControlMessage: (message: ControlMessage) => void;
|
||||
logger: Logger;
|
||||
}): PeerConnectionBase {
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
const closed$ = new BehaviorSubject(false);
|
||||
const controlFailed$ = new BehaviorSubject(false);
|
||||
|
||||
let queue = Promise.resolve();
|
||||
const enqueue = (op: () => Promise<void>): void => {
|
||||
queue = queue.then(op).catch((e: unknown) => {
|
||||
logger.warn("Signalling operation failed", e);
|
||||
});
|
||||
};
|
||||
|
||||
let control: RTCDataChannel | null = null;
|
||||
let switched = false;
|
||||
let awaitingAnswer = false;
|
||||
const pendingMetadata = new Map<MetadataMessage["type"], MetadataMessage>();
|
||||
const trySwitch = (): void => {
|
||||
if (switched || awaitingAnswer || control?.readyState !== "open") return;
|
||||
switched = true;
|
||||
logger.debug("Switched to the control channel");
|
||||
for (const message of pendingMetadata.values())
|
||||
control.send(JSON.stringify(message));
|
||||
pendingMetadata.clear();
|
||||
};
|
||||
const openControlChannel = (): void => {
|
||||
if (control !== null) return;
|
||||
const channel = createControlChannel(pc);
|
||||
control = channel;
|
||||
channel.addEventListener("open", trySwitch);
|
||||
channel.addEventListener("message", ({ data }: MessageEvent) => {
|
||||
const message = parseControlMessage(data);
|
||||
if (message === null) logger.debug("Ignoring a control message", data);
|
||||
else onControlMessage(message);
|
||||
});
|
||||
// Ours to close only with the connection: closing otherwise is a failure
|
||||
channel.addEventListener("close", () => {
|
||||
if (!closed$.value) controlFailed$.next(true);
|
||||
});
|
||||
};
|
||||
|
||||
const send = (message: ControlMessage): void => {
|
||||
if (closed$.value) return;
|
||||
if (switched) control!.send(JSON.stringify(message));
|
||||
else if (
|
||||
message.type === "track_metadata" ||
|
||||
message.type === "consumption_metadata"
|
||||
)
|
||||
pendingMetadata.set(message.type, message);
|
||||
else {
|
||||
if (
|
||||
message.type === "description" &&
|
||||
message.description.type === "offer"
|
||||
)
|
||||
awaitingAnswer = true;
|
||||
signalling.send(peer, connectionId, message);
|
||||
}
|
||||
};
|
||||
|
||||
const answerReceived = (): void => {
|
||||
awaitingAnswer = false;
|
||||
trySwitch();
|
||||
};
|
||||
|
||||
// Trickled, in batches
|
||||
let pending: RTCIceCandidateInit[] = [];
|
||||
let batchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const flushCandidates = (): void => {
|
||||
batchTimer = undefined;
|
||||
if (pending.length === 0) return;
|
||||
send({ type: "candidates", candidates: pending });
|
||||
pending = [];
|
||||
};
|
||||
pc.addEventListener("icecandidate", ({ candidate }) => {
|
||||
// A null candidate ends the gathering: an empty one says so on the wire
|
||||
pending.push(
|
||||
candidate
|
||||
? candidate.toJSON()
|
||||
: { candidate: "", sdpMid: null, sdpMLineIndex: null },
|
||||
);
|
||||
batchTimer ??= setTimeout(flushCandidates, CANDIDATE_BATCH_MS);
|
||||
});
|
||||
|
||||
const addCandidates: PeerConnectionBase["addCandidates"] = (candidates) =>
|
||||
enqueue(async () => {
|
||||
for (const candidate of candidates)
|
||||
await pc
|
||||
.addIceCandidate(candidate)
|
||||
// A candidate of an outdated description, or a bad one: not fatal
|
||||
.catch((e: unknown) => logger.debug("Ignored a candidate", e));
|
||||
});
|
||||
|
||||
const close: PeerConnectionBase["close"] = (reason, notifyPeer) => {
|
||||
if (closed$.value) return;
|
||||
clearTimeout(batchTimer);
|
||||
// Always to-device: closing the peer connection would race a channel message
|
||||
if (notifyPeer)
|
||||
signalling.send(peer, connectionId, { type: "close", reason });
|
||||
closed$.next(true);
|
||||
pc.close();
|
||||
};
|
||||
scope.onEnd(() => close("ended", true));
|
||||
|
||||
const state$ = scope.behavior(
|
||||
combineLatest([
|
||||
fromEvent(pc, "connectionstatechange").pipe(startWith(null)),
|
||||
controlFailed$,
|
||||
]).pipe(
|
||||
map(([, controlFailed]) =>
|
||||
controlFailed && pc.connectionState !== "closed"
|
||||
? new Error("The control channel closed")
|
||||
: toState(pc.connectionState),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
pc,
|
||||
state$,
|
||||
enqueue,
|
||||
openControlChannel,
|
||||
send,
|
||||
answerReceived,
|
||||
addCandidates,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
function toState(state: RTCPeerConnectionState): MediaConnectionState | Error {
|
||||
switch (state) {
|
||||
case "new":
|
||||
return MediaConnectionState.Initialized;
|
||||
case "connecting":
|
||||
return MediaConnectionState.Connecting;
|
||||
case "connected":
|
||||
return MediaConnectionState.Connected;
|
||||
case "disconnected":
|
||||
return MediaConnectionState.Reconnecting;
|
||||
case "failed":
|
||||
return new Error("The peer connection failed");
|
||||
case "closed":
|
||||
return MediaConnectionState.Stopped;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { CONTROL_CHANNEL_LABEL } from "./ControlChannel";
|
||||
import { type FullMeshLocalMedia } from "./LocalMedia";
|
||||
import { DATA_CHANNEL_LABEL, DATA_PROTOCOL } from "./metadata";
|
||||
import {
|
||||
createPublisherConnection,
|
||||
type PublisherConnection,
|
||||
} from "./PublisherConnection";
|
||||
import {
|
||||
type FakeSignalling,
|
||||
fakeLocalMedia,
|
||||
fakePublication,
|
||||
fakeSignalling,
|
||||
fakeTrack,
|
||||
fromPeer,
|
||||
type PeerMessage,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
describe("createPublisherConnection", () => {
|
||||
let scope: ObservableScope;
|
||||
let signalling: FakeSignalling;
|
||||
let local: ReturnType<typeof fakeLocalMedia>;
|
||||
|
||||
function create(): PublisherConnection {
|
||||
return createPublisherConnection({
|
||||
scope,
|
||||
peer,
|
||||
connectionId: "c1",
|
||||
signalling,
|
||||
iceServers: [],
|
||||
local: local as unknown as FullMeshLocalMedia,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
const answer = (): PeerMessage =>
|
||||
fromPeer("c1", {
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
installWebRtcFakes();
|
||||
scope = new ObservableScope();
|
||||
signalling = fakeSignalling();
|
||||
local = fakeLocalMedia();
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("offers right away, even without tracks", async () => {
|
||||
create();
|
||||
await settle();
|
||||
expect(signalling.sent("description")).toEqual([
|
||||
{
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer-1" },
|
||||
track_metadata: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("describes each track by the mid it gets with the offer", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
const camera = fakePublication("video", { stream: mic.stream });
|
||||
const screen = fakePublication("video", { source: "screenShare" });
|
||||
camera.muted$.next(true);
|
||||
local.publications$.next([mic, camera, screen]);
|
||||
create();
|
||||
await settle();
|
||||
expect(signalling.sent("description")).toEqual([
|
||||
expect.objectContaining({
|
||||
track_metadata: {
|
||||
"0": { purpose: "m.usermedia", muted: false },
|
||||
"1": { purpose: "m.usermedia", muted: true },
|
||||
"2": { purpose: "m.screenshare", muted: false },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the control channel and the data channel before its first offer", () => {
|
||||
create();
|
||||
const [control, data] = lastPeerConnection().dataChannels;
|
||||
expect(control.label).toBe(CONTROL_CHANNEL_LABEL);
|
||||
expect(data).toMatchObject({
|
||||
label: DATA_CHANNEL_LABEL,
|
||||
protocol: DATA_PROTOCOL,
|
||||
});
|
||||
expect(lastPeerConnection().offers).toBe(0);
|
||||
});
|
||||
|
||||
it("sends each publication on a sendonly transceiver in its stream", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
const camera = fakePublication("video", { stream: mic.stream });
|
||||
local.publications$.next([mic, camera]);
|
||||
create();
|
||||
await settle();
|
||||
|
||||
const [a, v] = lastPeerConnection().transceivers;
|
||||
expect(a).toMatchObject({ kind: "audio", direction: "sendonly" });
|
||||
expect(a.streams).toEqual([mic.stream]);
|
||||
expect(a.sender.track).toBe(mic.track$.value);
|
||||
expect(v).toMatchObject({ kind: "video", direction: "sendonly" });
|
||||
expect(v.sender.track).toBe(camera.track$.value);
|
||||
// One offer for both
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("follows a track change without renegotiating", async () => {
|
||||
const camera = fakePublication("video");
|
||||
local.publications$.next([camera]);
|
||||
create();
|
||||
await settle();
|
||||
const next = fakeTrack("video");
|
||||
camera.track$.next(next);
|
||||
expect(lastPeerConnection().transceivers[0].sender.track).toBe(next);
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sends nothing while not publishing", async () => {
|
||||
const camera = fakePublication("video");
|
||||
local.publications$.next([camera]);
|
||||
local.publishing$.next(false);
|
||||
create();
|
||||
await settle();
|
||||
const { sender } = lastPeerConnection().transceivers[0];
|
||||
expect(sender.track).toBeNull();
|
||||
|
||||
local.publishing$.next(true);
|
||||
expect(sender.track).toBe(camera.track$.value);
|
||||
});
|
||||
|
||||
it("holds changes while an offer is unanswered, then offers once for all", async () => {
|
||||
const connection = create();
|
||||
const pc = lastPeerConnection();
|
||||
await settle();
|
||||
expect(pc.offers).toBe(1);
|
||||
|
||||
local.publications$.next([fakePublication("audio")]);
|
||||
local.publications$.next([
|
||||
...local.publications$.value,
|
||||
fakePublication("video"),
|
||||
]);
|
||||
await settle();
|
||||
expect(pc.offers).toBe(1);
|
||||
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
expect(pc.offers).toBe(2);
|
||||
expect(pc.transceivers).toHaveLength(2);
|
||||
|
||||
// Nothing changed since: the next answer doesn't lead to another offer
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
expect(pc.offers).toBe(2);
|
||||
});
|
||||
|
||||
it("stops the transceiver of a removed publication and renegotiates", async () => {
|
||||
const camera = fakePublication("video");
|
||||
local.publications$.next([camera]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
|
||||
local.publications$.next([]);
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.transceivers[0].stopped).toBe(true);
|
||||
expect(pc.offers).toBe(2);
|
||||
});
|
||||
|
||||
it("signals mute changes over the control channel without renegotiating", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
local.publications$.next([mic]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
mic.muted$.next(true);
|
||||
expect(control.sentMessages()).toEqual([
|
||||
{
|
||||
type: "track_metadata",
|
||||
track_metadata: { "0": { purpose: "m.usermedia", muted: true } },
|
||||
},
|
||||
]);
|
||||
expect(lastPeerConnection().offers).toBe(1);
|
||||
});
|
||||
|
||||
it("holds a mute change until its to-device offer is answered", async () => {
|
||||
const mic = fakePublication("audio");
|
||||
local.publications$.next([mic]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
mic.muted$.next(true);
|
||||
expect(control.sentMessages()).toEqual([]);
|
||||
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
expect(control.sentMessages()).toEqual([
|
||||
{
|
||||
type: "track_metadata",
|
||||
track_metadata: { "0": { purpose: "m.usermedia", muted: true } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renegotiates over the control channel once it is open", async () => {
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
|
||||
local.publications$.next([fakePublication("audio")]);
|
||||
await settle();
|
||||
expect(control.sentMessages()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer-2" },
|
||||
}),
|
||||
);
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
|
||||
// The answer comes back the same way
|
||||
control.receive({
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: "answer-2" },
|
||||
});
|
||||
await settle();
|
||||
expect(lastPeerConnection().remoteDescription).toEqual({
|
||||
type: "answer",
|
||||
sdp: "answer-2",
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumption metadata", () => {
|
||||
/** A connection sending one camera track, with its control channel open. */
|
||||
async function sendingCamera(settings: MediaTrackSettings = {}): Promise<{
|
||||
camera: ReturnType<typeof fakePublication>;
|
||||
consume: (metadata: unknown) => Promise<void>;
|
||||
}> {
|
||||
const camera = fakePublication("video");
|
||||
camera.track$.next(fakeTrack("video", settings));
|
||||
local.publications$.next([camera]);
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
return {
|
||||
camera,
|
||||
consume: async (metadata): Promise<void> => {
|
||||
control.receive({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: metadata,
|
||||
});
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("stops sending a track that isn't in use, without renegotiating", async () => {
|
||||
const { camera, consume } = await sendingCamera();
|
||||
const { sender } = lastPeerConnection().transceivers[0];
|
||||
await consume({ "0": { in_use: false } });
|
||||
expect(sender.track).toBeNull();
|
||||
|
||||
await consume({});
|
||||
expect(sender.track).toBe(camera.track$.value);
|
||||
expect(lastPeerConnection().offers).toBe(1);
|
||||
});
|
||||
|
||||
it("scales the video down to the largest size shown", async () => {
|
||||
const { consume } = await sendingCamera({ width: 1280, height: 720 });
|
||||
const { sender } = lastPeerConnection().transceivers[0];
|
||||
await consume({ "0": { max_width: 320, max_height: 320 } });
|
||||
expect(sender.parameters.encodings).toEqual([
|
||||
{ scaleResolutionDownBy: 4 },
|
||||
]);
|
||||
|
||||
// Larger than the camera: sent as it is
|
||||
await consume({ "0": { max_width: 4000, max_height: 4000 } });
|
||||
expect(sender.parameters.encodings).toEqual([
|
||||
{ scaleResolutionDownBy: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends data once the data channel is open", () => {
|
||||
const connection = create();
|
||||
expect(connection.sendData("early")).toBe(false);
|
||||
const data = lastPeerConnection().dataChannels[1];
|
||||
data.open();
|
||||
expect(connection.sendData("hello")).toBe(true);
|
||||
expect(data.sent).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("closes, telling the subscriber, when the control channel fails", async () => {
|
||||
create();
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
pc.control().open();
|
||||
pc.control().close();
|
||||
expect(pc.closed).toBe(true);
|
||||
expect(signalling.sent("close")).toEqual([
|
||||
{ type: "close", reason: "failed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies the subscriber's candidates", async () => {
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(answer());
|
||||
connection.handle(
|
||||
fromPeer("c1", {
|
||||
type: "candidates",
|
||||
candidates: [{ candidate: "x" }],
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
expect(lastPeerConnection().addedCandidates).toEqual([{ candidate: "x" }]);
|
||||
});
|
||||
|
||||
it("ends without answering when the subscriber closes", async () => {
|
||||
const connection = create();
|
||||
await settle();
|
||||
connection.handle(fromPeer("c1", { type: "close", reason: "bye" }));
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.closed).toBe(true);
|
||||
expect(signalling.sent("close")).toEqual([]);
|
||||
// Its scope ended: no more offers go out
|
||||
local.publications$.next([fakePublication("audio")]);
|
||||
await settle();
|
||||
expect(pc.offers).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
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 } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
skip,
|
||||
type Subscription,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type FullMeshLocalMedia, type LocalPublication } from "./LocalMedia";
|
||||
import {
|
||||
type ConsumptionMetadata,
|
||||
type ConsumptionMetadataEntry,
|
||||
DATA_CHANNEL_LABEL,
|
||||
DATA_PROTOCOL,
|
||||
type MediaPurpose,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
import {
|
||||
createPeerConnectionBase,
|
||||
type PeerConnectionBase,
|
||||
} from "./PeerConnection";
|
||||
import {
|
||||
type ConnectionMessage,
|
||||
type Peer,
|
||||
type Signalling,
|
||||
} from "./signalling";
|
||||
|
||||
export interface PublisherConnection extends PeerConnectionBase {
|
||||
/** A to-device message of this connection from the subscriber. */
|
||||
handle(message: ConnectionMessage): void;
|
||||
/** Sends one message on the data channel; false while it isn't open. */
|
||||
sendData(message: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Our side of a connection a subscriber asked for: we only send, and we are
|
||||
* the only side that makes offers, so there is no glare.
|
||||
*/
|
||||
export function createPublisherConnection({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
local,
|
||||
logger: parentLogger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
connectionId: string;
|
||||
signalling: Signalling;
|
||||
iceServers: RTCIceServer[];
|
||||
local: FullMeshLocalMedia;
|
||||
logger: Logger;
|
||||
}): PublisherConnection {
|
||||
const logger = parentLogger.getChild(
|
||||
`[Publisher → ${peer.memberId} ${connectionId}]`,
|
||||
);
|
||||
const base = createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
onControlMessage: (message) => handle(message),
|
||||
logger,
|
||||
});
|
||||
const { pc, enqueue } = base;
|
||||
const consumption$ = new BehaviorSubject<ConsumptionMetadata>({});
|
||||
|
||||
// Both before the first offer, which so has the connection's data section
|
||||
base.openControlChannel();
|
||||
const data = pc.createDataChannel(DATA_CHANNEL_LABEL, {
|
||||
protocol: DATA_PROTOCOL,
|
||||
});
|
||||
|
||||
// One sendonly transceiver per publication, for as long as it exists
|
||||
const transceivers = new Map<
|
||||
LocalPublication,
|
||||
{ transceiver: RTCRtpTransceiver; tracks: Subscription }
|
||||
>();
|
||||
|
||||
/** The metadata of every track with a `mid`, which it gets with the offer that adds it. */
|
||||
const trackMetadata = (): TrackMetadata => {
|
||||
const metadata: TrackMetadata = {};
|
||||
for (const [publication, { transceiver }] of transceivers)
|
||||
if (transceiver.mid !== null)
|
||||
metadata[transceiver.mid] = {
|
||||
purpose: purposeOf(publication),
|
||||
muted: publication.mediaTrack.muted$.value,
|
||||
};
|
||||
return metadata;
|
||||
};
|
||||
|
||||
// A new offer has to wait while one is unanswered; changes made meanwhile
|
||||
// go out together in the next one
|
||||
let offerPending = false;
|
||||
let renegotiate = false;
|
||||
const negotiate = (): void => {
|
||||
if (offerPending) {
|
||||
renegotiate = true;
|
||||
return;
|
||||
}
|
||||
offerPending = true;
|
||||
renegotiate = false;
|
||||
enqueue(async () => {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
base.send({
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: pc.localDescription!.sdp },
|
||||
track_metadata: trackMetadata(),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
scope.onEnd(() => {
|
||||
for (const { tracks } of transceivers.values()) tracks.unsubscribe();
|
||||
});
|
||||
local.publications$.pipe(scope.bind()).subscribe((publications) => {
|
||||
let changed = false;
|
||||
for (const [publication, { transceiver, tracks }] of transceivers)
|
||||
if (!publications.includes(publication)) {
|
||||
tracks.unsubscribe();
|
||||
if (pc.signalingState !== "closed") transceiver.stop();
|
||||
transceivers.delete(publication);
|
||||
changed = true;
|
||||
}
|
||||
for (const publication of publications)
|
||||
if (!transceivers.has(publication)) {
|
||||
const transceiver = pc.addTransceiver(publication.kind, {
|
||||
direction: "sendonly",
|
||||
streams: [publication.stream],
|
||||
});
|
||||
const tracks = sendPublication(transceiver, publication);
|
||||
transceivers.set(publication, { transceiver, tracks });
|
||||
changed = true;
|
||||
}
|
||||
if (changed) negotiate();
|
||||
});
|
||||
// Even without tracks: the subscriber learns that we are there
|
||||
if (transceivers.size === 0) negotiate();
|
||||
|
||||
/**
|
||||
* Sends the track of a publication as the subscriber wants it. None while
|
||||
* we don't publish or it isn't in use: the local preview stays live, and
|
||||
* turning it back on needs no renegotiation.
|
||||
*/
|
||||
function sendPublication(
|
||||
transceiver: RTCRtpTransceiver,
|
||||
publication: LocalPublication,
|
||||
): Subscription {
|
||||
const { sender } = transceiver;
|
||||
return combineLatest([
|
||||
publication.track$,
|
||||
local.publishing$,
|
||||
consumptionOf(transceiver),
|
||||
]).subscribe(([track, publishing, consumption]) => {
|
||||
const inUse = consumption.in_use ?? true;
|
||||
void sender
|
||||
.replaceTrack(publishing && inUse ? track : null)
|
||||
.then(async () => {
|
||||
if (publication.kind === "video" && track !== null)
|
||||
await constrainVideo(sender, track, consumption);
|
||||
})
|
||||
.catch((e: unknown) => logger.warn("Could not send a track", e));
|
||||
});
|
||||
}
|
||||
|
||||
// The mid is only set with the local description
|
||||
function consumptionOf(
|
||||
transceiver: RTCRtpTransceiver,
|
||||
): Observable<ConsumptionMetadataEntry> {
|
||||
return consumption$.pipe(
|
||||
map((c) => (transceiver.mid === null ? {} : (c[transceiver.mid] ?? {}))),
|
||||
distinctUntilChanged(
|
||||
(a, b) =>
|
||||
a.in_use === b.in_use &&
|
||||
a.max_width === b.max_width &&
|
||||
a.max_height === b.max_height,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mute changes without renegotiating; an offer carries the state at its time
|
||||
local.publications$
|
||||
.pipe(
|
||||
switchMap((ps) =>
|
||||
merge(...ps.map((p) => p.mediaTrack.muted$.pipe(skip(1)))),
|
||||
),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe(() =>
|
||||
base.send({ type: "track_metadata", track_metadata: trackMetadata() }),
|
||||
);
|
||||
|
||||
base.state$
|
||||
.pipe(
|
||||
filter((s) => s instanceof Error),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((e) => {
|
||||
// The subscriber retries; a publisher doesn't
|
||||
logger.info(`Connection failed: ${e.message}`);
|
||||
base.close("failed", true);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
function handle(message: ConnectionMessage): void {
|
||||
switch (message.type) {
|
||||
case "description":
|
||||
if (message.description.type !== "answer") return;
|
||||
enqueue(async () => {
|
||||
await pc.setRemoteDescription(message.description);
|
||||
base.answerReceived();
|
||||
offerPending = false;
|
||||
if (renegotiate) negotiate();
|
||||
});
|
||||
break;
|
||||
case "candidates":
|
||||
base.addCandidates(message.candidates);
|
||||
break;
|
||||
case "consumption_metadata":
|
||||
consumption$.next(message.consumption_metadata);
|
||||
break;
|
||||
case "close":
|
||||
logger.info(`Closed by the subscriber: ${message.reason}`);
|
||||
base.close(message.reason, false);
|
||||
scope.end();
|
||||
break;
|
||||
default:
|
||||
logger.debug(`Ignoring ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sendData = (message: string): boolean => {
|
||||
if (data.readyState !== "open") return false;
|
||||
data.send(message);
|
||||
return true;
|
||||
};
|
||||
|
||||
return { ...base, handle, sendData };
|
||||
}
|
||||
|
||||
function purposeOf({ source }: LocalPublication): MediaPurpose {
|
||||
return source === "microphone" || source === "camera"
|
||||
? "m.usermedia"
|
||||
: "m.screenshare";
|
||||
}
|
||||
|
||||
/** Scales the video down to the size the subscriber shows it at. */
|
||||
async function constrainVideo(
|
||||
sender: RTCRtpSender,
|
||||
track: MediaStreamTrack,
|
||||
{ max_width, max_height }: ConsumptionMetadataEntry,
|
||||
): Promise<void> {
|
||||
const parameters = sender.getParameters();
|
||||
const [encoding] = parameters.encodings ?? [];
|
||||
if (encoding === undefined) return;
|
||||
const { width, height } = track.getSettings();
|
||||
encoding.scaleResolutionDownBy = Math.max(
|
||||
1,
|
||||
width && max_width ? width / max_width : 1,
|
||||
height && max_height ? height / max_height : 1,
|
||||
);
|
||||
await sender.setParameters(parameters);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Full mesh media backend
|
||||
|
||||
The MatrixRTC full mesh transport, as specified in
|
||||
[MSC4575: Full mesh transport for MatrixRTC](https://github.com/matrix-org/matrix-spec-proposals/pull/4575).
|
||||
|
||||
Every subscriber opens a WebRTC connection to every publisher it receives from. To-device messages set
|
||||
the connection up; after that, the connection's control channel carries all signalling.
|
||||
|
||||
The MSC is not merged, so all identifiers use its unstable prefix `org.matrix.msc4575`.
|
||||
|
||||
Not yet as the MSC has it: the membership's `transports.can_subscribe` is built by the js-sdk
|
||||
(`MembershipManager.makeMyMembership`), which lists the LiveKit types only, so full mesh isn't
|
||||
advertised there although every participation receives it.
|
||||
|
||||
| File | What it implements |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `FullMeshBackend.ts` | The `MediaBackend`: subscribing to members, accepting subscribers |
|
||||
| `signalling.ts` | The to-device messages and their ordering by `seq` |
|
||||
| `ControlChannel.ts` | The control channel and its messages |
|
||||
| `PeerConnection.ts` | What both sides of a connection share, including the channel switch |
|
||||
| `PublisherConnection.ts` | Sending our tracks and the data channel to one subscriber |
|
||||
| `SubscriberConnection.ts` | Receiving one publisher's tracks, with retries |
|
||||
| `metadata.ts` | Track and consumption metadata, and their validation |
|
||||
| `LocalMedia.ts` | The local tracks every publisher connection sends: captured, or taken over from a lobby |
|
||||
| `MediaTrack.ts` | A WebRTC track as the SDK's `MediaTrack` |
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { constant } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
DATA_CHANNEL_LABEL,
|
||||
DATA_PROTOCOL,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
import {
|
||||
createSubscriberConnection,
|
||||
type SubscriberConnection,
|
||||
} from "./SubscriberConnection";
|
||||
import {
|
||||
type FakeSignalling,
|
||||
fakeSignalling,
|
||||
fromPeer,
|
||||
type PeerMessage,
|
||||
installWebRtcFakes,
|
||||
lastPeerConnection,
|
||||
peer,
|
||||
settle,
|
||||
} from "./test";
|
||||
|
||||
describe("createSubscriberConnection", () => {
|
||||
let scope: ObservableScope;
|
||||
let signalling: FakeSignalling;
|
||||
let subscriber: SubscriberConnection;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
installWebRtcFakes();
|
||||
scope = new ObservableScope();
|
||||
signalling = fakeSignalling();
|
||||
subscriber = createSubscriberConnection({
|
||||
scope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers: async () => Promise.resolve([{ urls: ["turn:x"] }]),
|
||||
audioOutputDeviceId$: constant(undefined),
|
||||
logger,
|
||||
});
|
||||
await settle();
|
||||
});
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("asks the publisher for its media on a fresh connection", () => {
|
||||
expect(signalling.sent()).toEqual([{ type: "subscribe" }]);
|
||||
expect(signalling.sentConnectionIds()).toEqual([current()]);
|
||||
expect(lastPeerConnection().config.iceServers).toEqual([
|
||||
{ urls: ["turn:x"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers an offer and shows the tracks it brings", async () => {
|
||||
expect(subscriber.tracks$.value).toBeNull();
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("video", "1");
|
||||
pc.receive("video", "2");
|
||||
|
||||
subscriber.handle(
|
||||
offer(current(), {
|
||||
"1": { purpose: "m.usermedia", muted: true },
|
||||
"2": { purpose: "m.screenshare" },
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(pc.remoteDescription).toEqual({ type: "offer", sdp: "offer" });
|
||||
// How it shows them goes over the control channel once it is open
|
||||
expect(signalling.sent("description")).toEqual([
|
||||
{ type: "description", description: { type: "answer", sdp: "answer" } },
|
||||
]);
|
||||
const tracks = subscriber.tracks$.value!;
|
||||
expect(tracks.map((t) => [t.source, t.kind, t.muted$.value])).toEqual([
|
||||
["camera", "video", true],
|
||||
["screenShare", "video", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("names audio by its purpose", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("audio", "0");
|
||||
pc.receive("audio", "3");
|
||||
subscriber.handle(
|
||||
offer(current(), {
|
||||
"0": { purpose: "m.usermedia" },
|
||||
"3": { purpose: "m.screenshare" },
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value!.map((t) => t.source)).toEqual([
|
||||
"microphone",
|
||||
"screenShareAudio",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves out tracks without metadata or with an unknown purpose", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("video", "0");
|
||||
pc.receive("video", "1");
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.whiteboard" } }));
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows mute changes, keeping the same track", async () => {
|
||||
lastPeerConnection().receive("video", "1");
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
|
||||
await settle();
|
||||
const [track] = subscriber.tracks$.value!;
|
||||
expect(track.muted$.value).toBe(false);
|
||||
|
||||
lastPeerConnection()
|
||||
.control()
|
||||
.receive({
|
||||
type: "track_metadata",
|
||||
track_metadata: { "1": { purpose: "m.usermedia", muted: true } },
|
||||
});
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toEqual([track]);
|
||||
expect(track.muted$.value).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a track whose transceiver no longer receives", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
const transceiver = pc.receive("video", "1");
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toHaveLength(1);
|
||||
|
||||
transceiver.currentDirection = "inactive";
|
||||
subscriber.handle(offer(current(), { "1": { purpose: "m.usermedia" } }));
|
||||
await settle();
|
||||
expect(subscriber.tracks$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores messages of another connection", async () => {
|
||||
subscriber.handle(offer("other", {}));
|
||||
await settle();
|
||||
expect(lastPeerConnection().remoteDescription).toBeNull();
|
||||
});
|
||||
|
||||
it("subscribes anew after the publisher closes, backing off", async () => {
|
||||
const first = current();
|
||||
subscriber.handle(fromPeer(first, { type: "close", reason: "nope" }));
|
||||
expect(subscriber.current$.value).toBeNull();
|
||||
expect(subscriber.tracks$.value).toBeNull();
|
||||
// It doesn't echo the publisher's close
|
||||
expect(signalling.sent("close")).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
const second = current();
|
||||
expect(second).not.toBe(first);
|
||||
expect(signalling.sentConnectionIds("subscribe")).toEqual([first, second]);
|
||||
|
||||
// A failed connection is closed and retried after twice the wait
|
||||
lastPeerConnection().setConnectionState("failed");
|
||||
expect(signalling.sent("close")).toEqual([
|
||||
{ type: "close", reason: "failed" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(signalling.sent("subscribe")).toHaveLength(2);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(signalling.sent("subscribe")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("stops retrying once its scope ends", async () => {
|
||||
subscriber.handle(fromPeer(current(), { type: "close", reason: "nope" }));
|
||||
scope.end();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(signalling.sent("subscribe")).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe("the control channel", () => {
|
||||
it("is created once the offer is applied", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
expect(pc.dataChannels).toEqual([]);
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
expect(pc.control().init).toEqual({ negotiated: true, id: 0 });
|
||||
});
|
||||
|
||||
it("failing leads to a new subscription", async () => {
|
||||
const first = current();
|
||||
subscriber.handle(offer(first, {}));
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
control.close();
|
||||
expect(signalling.sent("close")).toEqual([
|
||||
{ type: "close", reason: "failed" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(current()).not.toBe(first);
|
||||
});
|
||||
|
||||
it("carries the answers to later offers", async () => {
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
const control = lastPeerConnection().control();
|
||||
control.open();
|
||||
control.receive({
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer-2" },
|
||||
track_metadata: {},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(control.sentMessages()).toContainEqual({
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: "answer" },
|
||||
});
|
||||
expect(signalling.sent("description")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumption metadata", () => {
|
||||
it("tells the publisher how each video is shown, once the channel is open", async () => {
|
||||
const pc = lastPeerConnection();
|
||||
pc.receive("audio", "0");
|
||||
pc.receive("video", "1");
|
||||
subscriber.handle(
|
||||
offer(current(), {
|
||||
"0": { purpose: "m.usermedia" },
|
||||
"1": { purpose: "m.usermedia" },
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
const control = pc.control();
|
||||
control.open();
|
||||
// Nothing shows the video yet; the audio is always in use
|
||||
expect(control.sentMessages()).toEqual([
|
||||
{
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: { "1": { in_use: false } },
|
||||
},
|
||||
]);
|
||||
|
||||
const video = subscriber.tracks$.value!.find((t) => t.kind === "video")!;
|
||||
const element = { paused: false } as HTMLMediaElement;
|
||||
video.attach(element);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
// Shown at a size the platform doesn't report: no constraint at all
|
||||
expect(control.sentMessages().at(-1)).toEqual({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: {},
|
||||
});
|
||||
|
||||
video.detach(element);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(control.sentMessages().at(-1)).toEqual({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: { "1": { in_use: false } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the data channel", () => {
|
||||
it("delivers what the publisher sends on it", async () => {
|
||||
const received: string[] = [];
|
||||
subscriber.data$.subscribe((text) => received.push(text));
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
pc.announceDataChannel(DATA_CHANNEL_LABEL, DATA_PROTOCOL).receive("hi");
|
||||
expect(received).toEqual(["hi"]);
|
||||
});
|
||||
|
||||
it("ignores a channel whose protocol it doesn't know, whatever its label", async () => {
|
||||
const received: string[] = [];
|
||||
subscriber.data$.subscribe((text) => received.push(text));
|
||||
subscriber.handle(offer(current(), {}));
|
||||
await settle();
|
||||
const pc = lastPeerConnection();
|
||||
pc.announceDataChannel(DATA_CHANNEL_LABEL, "x.other").receive("no");
|
||||
pc.announceDataChannel(DATA_CHANNEL_LABEL).receive("no");
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function offer(connectionId: string, metadata: TrackMetadata): PeerMessage {
|
||||
return fromPeer(connectionId, {
|
||||
type: "description",
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/** The connection id of the current attempt. */
|
||||
function current(): string {
|
||||
const base = subscriber.current$.value;
|
||||
if (base === null) throw new Error("No current attempt");
|
||||
return base.connectionId;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
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 } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { type MediaSource } from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { MediaConnectionState } from "../api";
|
||||
import {
|
||||
createFullMeshMediaTrack,
|
||||
type FullMeshMediaTrack,
|
||||
rtpStats,
|
||||
type Viewport,
|
||||
} from "./MediaTrack";
|
||||
import {
|
||||
type ConsumptionMetadata,
|
||||
DATA_PROTOCOL,
|
||||
isMediaPurpose,
|
||||
type MediaPurpose,
|
||||
type TrackMetadata,
|
||||
} from "./metadata";
|
||||
import {
|
||||
createPeerConnectionBase,
|
||||
type PeerConnectionBase,
|
||||
} from "./PeerConnection";
|
||||
import {
|
||||
type ConnectionMessage,
|
||||
type Peer,
|
||||
type Signalling,
|
||||
} from "./signalling";
|
||||
|
||||
const RETRY_MIN_MS = 1_000;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
/** How long the shown size of the tracks settles before the publisher hears of it. */
|
||||
const CONSUMPTION_DEBOUNCE_MS = 200;
|
||||
|
||||
export interface SubscriberConnection {
|
||||
/** The current attempt; null while waiting to retry. */
|
||||
current$: Behavior<PeerConnectionBase | null>;
|
||||
/** The publisher's tracks; null until its first offer is applied. */
|
||||
tracks$: Behavior<FullMeshMediaTrack[] | null>;
|
||||
/** What the publisher sent on its data channel, on any attempt. */
|
||||
data$: Observable<string>;
|
||||
/** A to-device message from the publisher; dropped unless it is for the current attempt. */
|
||||
handle(message: ConnectionMessage & { connectionId: string }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Our subscription to one publisher: asks it for its media and answers its
|
||||
* offers. A failed or closed attempt is retried with a fresh connection id,
|
||||
* backing off, for as long as `scope` lives.
|
||||
*/
|
||||
export function createSubscriberConnection({
|
||||
scope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers,
|
||||
audioOutputDeviceId$,
|
||||
logger: parentLogger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
signalling: Signalling;
|
||||
iceServers: () => Promise<RTCIceServer[]>;
|
||||
audioOutputDeviceId$: Behavior<string | undefined>;
|
||||
logger: Logger;
|
||||
}): SubscriberConnection {
|
||||
const logger = parentLogger.getChild(`[Subscriber ← ${peer.memberId}]`);
|
||||
const attempt$ = new BehaviorSubject<Attempt | null>(null);
|
||||
const data$ = new Subject<string>();
|
||||
let retryMs = RETRY_MIN_MS;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let ended = false;
|
||||
scope.onEnd(() => (ended = true));
|
||||
|
||||
const startAttempt = async (): Promise<void> => {
|
||||
const servers = await iceServers();
|
||||
if (ended) return;
|
||||
const attemptScope = new ObservableScope();
|
||||
const attempt = createAttempt({
|
||||
scope: attemptScope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers: servers,
|
||||
audioOutputDeviceId$,
|
||||
onData: (text) => data$.next(text),
|
||||
onFailed: (reason) => {
|
||||
logger.info(`Attempt ${attempt.connectionId} ended: ${reason}`);
|
||||
attemptScope.end();
|
||||
if (attempt$.value === attempt) attempt$.next(null);
|
||||
if (ended) return;
|
||||
retryTimer = setTimeout(() => void startAttempt(), retryMs);
|
||||
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
||||
},
|
||||
onConnected: () => (retryMs = RETRY_MIN_MS),
|
||||
logger,
|
||||
});
|
||||
attempt$.next(attempt);
|
||||
};
|
||||
void startAttempt();
|
||||
scope.onEnd(() => {
|
||||
clearTimeout(retryTimer);
|
||||
attempt$.value?.end();
|
||||
attempt$.next(null);
|
||||
data$.complete();
|
||||
});
|
||||
|
||||
return {
|
||||
current$: scope.behavior(attempt$.pipe(map((a) => a?.base ?? null))),
|
||||
tracks$: scope.behavior(
|
||||
attempt$.pipe(switchMap((a) => a?.tracks$ ?? of(null))),
|
||||
),
|
||||
data$,
|
||||
handle: (message) => {
|
||||
if (message.connectionId === attempt$.value?.connectionId)
|
||||
attempt$.value.handle(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Attempt {
|
||||
connectionId: string;
|
||||
base: PeerConnectionBase;
|
||||
tracks$: Behavior<FullMeshMediaTrack[] | null>;
|
||||
handle(message: ConnectionMessage): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
interface ReceivedTrack {
|
||||
track: FullMeshMediaTrack;
|
||||
mid: string;
|
||||
scope: ObservableScope;
|
||||
source: MediaSource;
|
||||
}
|
||||
|
||||
function createAttempt({
|
||||
scope,
|
||||
peer,
|
||||
signalling,
|
||||
iceServers,
|
||||
audioOutputDeviceId$,
|
||||
onData,
|
||||
onFailed,
|
||||
onConnected,
|
||||
logger,
|
||||
}: {
|
||||
scope: ObservableScope;
|
||||
peer: Peer;
|
||||
signalling: Signalling;
|
||||
iceServers: RTCIceServer[];
|
||||
audioOutputDeviceId$: Behavior<string | undefined>;
|
||||
onData: (text: string) => void;
|
||||
onFailed: (reason: string) => void;
|
||||
onConnected: () => void;
|
||||
logger: Logger;
|
||||
}): Attempt {
|
||||
const connectionId = uuidv4();
|
||||
let failed = false;
|
||||
const fail = (reason: string): void => {
|
||||
if (failed) return;
|
||||
failed = true;
|
||||
onFailed(reason);
|
||||
};
|
||||
const base = createPeerConnectionBase({
|
||||
scope,
|
||||
peer,
|
||||
connectionId,
|
||||
signalling,
|
||||
iceServers,
|
||||
onControlMessage: (message) => handle(message),
|
||||
logger,
|
||||
});
|
||||
const { pc, enqueue } = base;
|
||||
const metadata$ = new BehaviorSubject<TrackMetadata>({});
|
||||
const received$ = new BehaviorSubject<ReceivedTrack[] | null>(null);
|
||||
const wrappers = new Map<RTCRtpTransceiver, ReceivedTrack>();
|
||||
|
||||
pc.addEventListener("datachannel", ({ channel }) => {
|
||||
if (channel.protocol !== DATA_PROTOCOL) return;
|
||||
channel.addEventListener("message", ({ data }: MessageEvent) => {
|
||||
if (typeof data === "string") onData(data);
|
||||
});
|
||||
});
|
||||
|
||||
/** The tracks of the transceivers that receive, after each negotiation. */
|
||||
const updateTracks = (): void => {
|
||||
const metadata = metadata$.value;
|
||||
const live = new Set<RTCRtpTransceiver>();
|
||||
for (const transceiver of pc.getTransceivers()) {
|
||||
const { mid, receiver } = transceiver;
|
||||
const track = receiver.track;
|
||||
const entry = mid === null ? undefined : metadata[mid];
|
||||
if (
|
||||
transceiver.currentDirection !== "recvonly" ||
|
||||
mid === null ||
|
||||
entry === undefined ||
|
||||
!isMediaPurpose(entry.purpose)
|
||||
)
|
||||
continue;
|
||||
const kind = track.kind === "audio" ? "audio" : "video";
|
||||
const source = sourceOf(entry.purpose, kind);
|
||||
live.add(transceiver);
|
||||
const existing = wrappers.get(transceiver);
|
||||
if (existing?.source === source) continue;
|
||||
existing?.scope.end();
|
||||
const trackScope = new ObservableScope();
|
||||
wrappers.set(transceiver, {
|
||||
scope: trackScope,
|
||||
source,
|
||||
mid,
|
||||
track: createFullMeshMediaTrack(trackScope, {
|
||||
source,
|
||||
kind,
|
||||
id: `${connectionId}:${mid}`,
|
||||
track$: trackScope.behavior(of(track)),
|
||||
muted$: trackScope.behavior(
|
||||
metadata$.pipe(
|
||||
map((m) => m[mid]?.muted ?? false),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
),
|
||||
stats: async () => rtpStats(pc, track, "inbound-rtp"),
|
||||
audioOutputDeviceId$,
|
||||
observeViewport: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
for (const [transceiver, { scope: s }] of wrappers)
|
||||
if (!live.has(transceiver)) {
|
||||
s.end();
|
||||
wrappers.delete(transceiver);
|
||||
}
|
||||
received$.next([...wrappers.values()]);
|
||||
};
|
||||
|
||||
// How the videos are shown; the first goes out once the control channel
|
||||
// is open, as everything is sent at full size until then anyway
|
||||
received$
|
||||
.pipe(
|
||||
switchMap((received) => {
|
||||
const video = (received ?? []).filter((r) => r.track.kind === "video");
|
||||
return video.length === 0
|
||||
? of({})
|
||||
: combineLatest(video.map((r) => r.track.viewport$)).pipe(
|
||||
map((viewports) => consumptionOf(video, viewports)),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
debounceTime(CONSUMPTION_DEBOUNCE_MS),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((metadata) =>
|
||||
base.send({
|
||||
type: "consumption_metadata",
|
||||
consumption_metadata: metadata,
|
||||
}),
|
||||
);
|
||||
|
||||
base.state$
|
||||
.pipe(
|
||||
filter((s) => s instanceof Error || s === MediaConnectionState.Connected),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((s) => {
|
||||
if (s instanceof Error) {
|
||||
base.close("failed", true);
|
||||
fail(s.message);
|
||||
} else onConnected();
|
||||
});
|
||||
|
||||
function handle(message: ConnectionMessage): void {
|
||||
switch (message.type) {
|
||||
case "description":
|
||||
if (message.description.type !== "offer") return;
|
||||
enqueue(async () => {
|
||||
if (message.track_metadata) metadata$.next(message.track_metadata);
|
||||
await pc.setRemoteDescription(message.description);
|
||||
base.openControlChannel();
|
||||
await pc.setLocalDescription(await pc.createAnswer());
|
||||
updateTracks();
|
||||
base.send({
|
||||
type: "description",
|
||||
description: { type: "answer", sdp: pc.localDescription!.sdp },
|
||||
});
|
||||
});
|
||||
break;
|
||||
case "candidates":
|
||||
base.addCandidates(message.candidates);
|
||||
break;
|
||||
case "track_metadata":
|
||||
metadata$.next(message.track_metadata);
|
||||
// A track may only become known with its metadata
|
||||
enqueue(async () => Promise.resolve(updateTracks()));
|
||||
break;
|
||||
case "close":
|
||||
base.close(message.reason, false);
|
||||
fail(`closed by the publisher: ${message.reason}`);
|
||||
break;
|
||||
default:
|
||||
logger.debug(`Ignoring ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
scope.onEnd(() => {
|
||||
for (const { scope: s } of wrappers.values()) s.end();
|
||||
wrappers.clear();
|
||||
});
|
||||
|
||||
signalling.send(peer, connectionId, { type: "subscribe" });
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
base,
|
||||
tracks$: scope.behavior(
|
||||
received$.pipe(map((r) => r?.map(({ track }) => track) ?? null)),
|
||||
),
|
||||
handle,
|
||||
end: () => scope.end(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The consumption metadata for the received videos, given their viewports
|
||||
* in order: a video no element shows isn't in use, and one shown at a known
|
||||
* size needs no more than that.
|
||||
*/
|
||||
function consumptionOf(
|
||||
video: ReceivedTrack[],
|
||||
viewports: (Viewport | null)[],
|
||||
): ConsumptionMetadata {
|
||||
const metadata: ConsumptionMetadata = {};
|
||||
video.forEach(({ mid }, i) => {
|
||||
const viewport = viewports[i];
|
||||
if (viewport === null) metadata[mid] = { in_use: false };
|
||||
else if (viewport.width !== undefined && viewport.height !== undefined)
|
||||
metadata[mid] = {
|
||||
max_width: viewport.width,
|
||||
max_height: viewport.height,
|
||||
};
|
||||
});
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function sourceOf(purpose: MediaPurpose, kind: "audio" | "video"): MediaSource {
|
||||
if (purpose === "m.usermedia")
|
||||
return kind === "audio" ? "microphone" : "camera";
|
||||
return kind === "audio" ? "screenShareAudio" : "screenShare";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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 { parseConsumptionMetadata, parseTrackMetadata } from "./metadata";
|
||||
|
||||
describe("parseTrackMetadata", () => {
|
||||
it("keeps the well-formed entries", () => {
|
||||
expect(
|
||||
parseTrackMetadata({
|
||||
"0": { purpose: "m.usermedia", muted: true, extra: 1 },
|
||||
"1": { purpose: "m.screenshare", muted: "yes" },
|
||||
"2": { muted: true },
|
||||
"3": [],
|
||||
}),
|
||||
).toEqual({
|
||||
"0": { purpose: "m.usermedia", muted: true },
|
||||
"1": { purpose: "m.screenshare" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects what isn't metadata at all", () => {
|
||||
expect(parseTrackMetadata("x")).toBeNull();
|
||||
expect(parseTrackMetadata([])).toBeNull();
|
||||
});
|
||||
|
||||
it("reads no more entries than any member publishes", () => {
|
||||
const many = Object.fromEntries(
|
||||
Array.from({ length: 100 }, (_, i) => [i, { purpose: "m.usermedia" }]),
|
||||
);
|
||||
expect(Object.keys(parseTrackMetadata(many)!)).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseConsumptionMetadata", () => {
|
||||
it("bounds the sizes to what makes sense", () => {
|
||||
expect(
|
||||
parseConsumptionMetadata({
|
||||
"0": { in_use: false, max_width: 1e9, max_height: -1 },
|
||||
"1": { max_width: 320.4, max_height: Number.NaN },
|
||||
}),
|
||||
).toEqual({
|
||||
"0": { in_use: false, max_width: 16_384 },
|
||||
"1": { max_width: 320 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
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 track and consumption metadata of the full mesh transport (MSC4575),
|
||||
* and their validation: both come from the other member and are untrusted.
|
||||
* Both are keyed by the `mid` of a track's transceiver.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The data channel every publisher connection has for `sendData`, and its
|
||||
* protocol: one JSON object `{ topic, text }` per message.
|
||||
*/
|
||||
export const DATA_CHANNEL_LABEL = "io.element.matrixrtc.data";
|
||||
export const DATA_PROTOCOL = "io.element.matrixrtc.topic_text";
|
||||
|
||||
export type MediaPurpose = "m.usermedia" | "m.screenshare";
|
||||
|
||||
export interface TrackMetadataEntry {
|
||||
purpose: string;
|
||||
muted?: boolean;
|
||||
}
|
||||
export type TrackMetadata = Record<string, TrackMetadataEntry>;
|
||||
|
||||
export interface ConsumptionMetadataEntry {
|
||||
in_use?: boolean;
|
||||
max_width?: number;
|
||||
max_height?: number;
|
||||
}
|
||||
export type ConsumptionMetadata = Record<string, ConsumptionMetadataEntry>;
|
||||
|
||||
/** Larger than any video we capture; a bound on what a subscriber can ask for. */
|
||||
const MAX_DIMENSION = 16_384;
|
||||
/** More tracks than any member publishes; a bound on the work a message causes. */
|
||||
const MAX_TRACKS = 64;
|
||||
|
||||
export function isMediaPurpose(purpose: string): purpose is MediaPurpose {
|
||||
return purpose === "m.usermedia" || purpose === "m.screenshare";
|
||||
}
|
||||
|
||||
/** The entries of `value` that are well formed, or null if it isn't metadata at all. */
|
||||
export function parseTrackMetadata(value: unknown): TrackMetadata | null {
|
||||
const entries = recordEntries(value);
|
||||
if (entries === null) return null;
|
||||
const metadata: TrackMetadata = {};
|
||||
for (const [mid, e] of entries) {
|
||||
if (typeof e.purpose !== "string") continue;
|
||||
const entry: TrackMetadataEntry = { purpose: e.purpose };
|
||||
if (typeof e.muted === "boolean") entry.muted = e.muted;
|
||||
metadata[mid] = entry;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/** As `parseTrackMetadata`, with the sizes bounded to what makes sense. */
|
||||
export function parseConsumptionMetadata(
|
||||
value: unknown,
|
||||
): ConsumptionMetadata | null {
|
||||
const entries = recordEntries(value);
|
||||
if (entries === null) return null;
|
||||
const metadata: ConsumptionMetadata = {};
|
||||
for (const [mid, e] of entries) {
|
||||
const entry: ConsumptionMetadataEntry = {};
|
||||
if (typeof e.in_use === "boolean") entry.in_use = e.in_use;
|
||||
const width = bounded(e.max_width);
|
||||
if (width !== undefined) entry.max_width = width;
|
||||
const height = bounded(e.max_height);
|
||||
if (height !== undefined) entry.max_height = height;
|
||||
metadata[mid] = entry;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function recordEntries(
|
||||
value: unknown,
|
||||
): [string, Record<string, unknown>][] | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
return Object.entries(value)
|
||||
.filter(
|
||||
(e): e is [string, Record<string, unknown>] =>
|
||||
typeof e[1] === "object" && e[1] !== null && !Array.isArray(e[1]),
|
||||
)
|
||||
.slice(0, MAX_TRACKS);
|
||||
}
|
||||
|
||||
function bounded(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.round(Math.min(value, MAX_DIMENSION))
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import { ClientEvent, EventType, MatrixEvent } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
createSignalling,
|
||||
SEQ_GAP_TIMEOUT_MS,
|
||||
SeqOrderer,
|
||||
SignallingEventType,
|
||||
type SignallingClient,
|
||||
type SignallingMessage,
|
||||
} from "./signalling";
|
||||
|
||||
describe("SeqOrderer", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("delivers in seq order", () => {
|
||||
const delivered: string[] = [];
|
||||
const orderer = new SeqOrderer<string>((s) => delivered.push(s));
|
||||
orderer.push(1, "b");
|
||||
orderer.push(2, "c");
|
||||
expect(delivered).toEqual([]);
|
||||
orderer.push(0, "a");
|
||||
expect(delivered).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("skips a gap that isn't filled in time, then drops the late message", () => {
|
||||
const delivered: string[] = [];
|
||||
const orderer = new SeqOrderer<string>((s) => delivered.push(s));
|
||||
orderer.push(0, "a");
|
||||
orderer.push(2, "c");
|
||||
vi.advanceTimersByTime(SEQ_GAP_TIMEOUT_MS);
|
||||
expect(delivered).toEqual(["a", "c"]);
|
||||
orderer.push(1, "b");
|
||||
expect(delivered).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSignalling", () => {
|
||||
const own = { userId: "@me:x", deviceId: "MINE", memberId: "me" };
|
||||
|
||||
function setup(): {
|
||||
client: SignallingClient & EventEmitter;
|
||||
received: SignallingMessage[];
|
||||
scope: ObservableScope;
|
||||
} {
|
||||
const client = Object.assign(new EventEmitter(), {
|
||||
encryptAndSendToDevice: vi.fn(async () => Promise.resolve()),
|
||||
}) as unknown as SignallingClient & EventEmitter;
|
||||
const scope = new ObservableScope();
|
||||
const signalling = createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId: "!room:x",
|
||||
slotId: "m.call#ROOM",
|
||||
own,
|
||||
logger,
|
||||
});
|
||||
const received: SignallingMessage[] = [];
|
||||
signalling.messages$.subscribe((m) => received.push(m));
|
||||
return { client, received, scope };
|
||||
}
|
||||
|
||||
function toDevice(
|
||||
type: string,
|
||||
content: Record<string, unknown>,
|
||||
encrypted = true,
|
||||
): MatrixEvent {
|
||||
const event = new MatrixEvent({ type, sender: "@peer:x", content });
|
||||
if (encrypted)
|
||||
vi.spyOn(event, "getWireType").mockReturnValue(
|
||||
EventType.RoomMessageEncrypted,
|
||||
);
|
||||
return event;
|
||||
}
|
||||
|
||||
const common = {
|
||||
room_id: "!room:x",
|
||||
slot_id: "m.call#ROOM",
|
||||
member_id: "peer",
|
||||
target_member_id: "me",
|
||||
connection_id: "c1",
|
||||
claimed_device_id: "PEER",
|
||||
};
|
||||
|
||||
it("passes valid messages on and drops cleartext or misaddressed ones", () => {
|
||||
const { client, received, scope } = setup();
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }, false),
|
||||
);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, {
|
||||
...common,
|
||||
target_member_id: "someone else",
|
||||
seq: 0,
|
||||
}),
|
||||
);
|
||||
expect(received).toEqual([]);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }),
|
||||
);
|
||||
expect(received).toEqual([
|
||||
{
|
||||
type: "subscribe",
|
||||
memberId: "peer",
|
||||
userId: "@peer:x",
|
||||
deviceId: "PEER",
|
||||
connectionId: "c1",
|
||||
},
|
||||
]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("orders by seq across connection ids", () => {
|
||||
const { client, received, scope } = setup();
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, {
|
||||
...common,
|
||||
connection_id: "c2",
|
||||
seq: 2,
|
||||
}),
|
||||
);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.close, { ...common, seq: 1 }),
|
||||
);
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.subscribe, { ...common, seq: 0 }),
|
||||
);
|
||||
expect(received.map((m) => [m.type, m.connectionId])).toEqual([
|
||||
["subscribe", "c1"],
|
||||
["close", "c1"],
|
||||
["subscribe", "c2"],
|
||||
]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("keeps the well-formed metadata of a description", () => {
|
||||
const { client, received, scope } = setup();
|
||||
client.emit(
|
||||
ClientEvent.ToDeviceEvent,
|
||||
toDevice(SignallingEventType.description, {
|
||||
...common,
|
||||
seq: 0,
|
||||
description: { type: "offer", sdp: "offer" },
|
||||
track_metadata: {
|
||||
"0": { purpose: "m.usermedia", muted: "yes" },
|
||||
"1": { muted: true },
|
||||
"2": "not an object",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(received).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "description",
|
||||
track_metadata: { "0": { purpose: "m.usermedia" } },
|
||||
}),
|
||||
]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("sends each payload as its event type", () => {
|
||||
const { client, scope } = setup();
|
||||
const signalling = createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId: "!room:x",
|
||||
slotId: "m.call#ROOM",
|
||||
own,
|
||||
logger,
|
||||
});
|
||||
signalling.send(
|
||||
{ userId: "@peer:x", deviceId: "PEER", memberId: "peer" },
|
||||
"c1",
|
||||
{ type: "close", reason: "bye" },
|
||||
);
|
||||
expect(client.encryptAndSendToDevice).toHaveBeenCalledWith(
|
||||
SignallingEventType.close,
|
||||
[{ userId: "@peer:x", deviceId: "PEER" }],
|
||||
expect.objectContaining({ reason: "bye", connection_id: "c1", seq: 0 }),
|
||||
);
|
||||
scope.end();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
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 to-device signalling of the full mesh transport (MSC4575): four event
|
||||
* types, the fields every one carries, and the ordering by `seq`. It sets a
|
||||
* connection up; the control channel carries the rest.
|
||||
*/
|
||||
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { fromEvent, Observable, share } from "rxjs";
|
||||
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type ControlMessage } from "./ControlChannel";
|
||||
import { parseTrackMetadata } from "./metadata";
|
||||
|
||||
export const FULL_MESH_TRANSPORT_TYPE = "org.matrix.msc4575.full_mesh";
|
||||
|
||||
const prefix = "org.matrix.msc4575.rtc.full_mesh";
|
||||
/** The event type of each payload type. */
|
||||
export const SignallingEventType = {
|
||||
subscribe: `${prefix}.subscribe`,
|
||||
description: `${prefix}.description`,
|
||||
candidates: `${prefix}.candidates`,
|
||||
close: `${prefix}.close`,
|
||||
} as const;
|
||||
|
||||
export const signallingEventTypes: string[] =
|
||||
Object.values(SignallingEventType);
|
||||
|
||||
/** How long a gap in `seq` is waited for before the missing messages are skipped. */
|
||||
export const SEQ_GAP_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface CommonContent {
|
||||
room_id: string;
|
||||
slot_id: string;
|
||||
member_id: string;
|
||||
target_member_id: string;
|
||||
connection_id: string;
|
||||
seq: number;
|
||||
/**
|
||||
* The sender's device, which replies go to. Stated by the sender, as the
|
||||
* MSC has it, because the js-sdk and the widget API don't expose the Olm
|
||||
* sending device; a client that knows it checks the two agree.
|
||||
*/
|
||||
claimed_device_id: string;
|
||||
}
|
||||
|
||||
/** What a connection hears from its peer, over to-device messages or the control channel. */
|
||||
export type ConnectionMessage =
|
||||
| ControlMessage
|
||||
| { type: "close"; reason?: string };
|
||||
|
||||
export type SignallingPayload =
|
||||
| { type: "subscribe" }
|
||||
| Extract<
|
||||
ConnectionMessage,
|
||||
{ type: "description" | "candidates" | "close" }
|
||||
>;
|
||||
|
||||
export type SignallingMessage = SignallingPayload & {
|
||||
/** The member that sent it, as named in `member_id`. */
|
||||
memberId: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
/** Another member, as far as signalling needs to know it. */
|
||||
export interface Peer {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
memberId: string;
|
||||
}
|
||||
|
||||
export interface Signalling {
|
||||
send(peer: Peer, connectionId: string, payload: SignallingPayload): void;
|
||||
/** Every valid message for us, per sender in `seq` order. */
|
||||
messages$: Observable<SignallingMessage>;
|
||||
}
|
||||
|
||||
export type SignallingClient = Pick<
|
||||
MatrixClient,
|
||||
"encryptAndSendToDevice" | "on" | "off"
|
||||
>;
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
client: SignallingClient;
|
||||
roomId: string;
|
||||
slotId: string;
|
||||
own: CallMembershipIdentityParts;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function createSignalling({
|
||||
scope,
|
||||
client,
|
||||
roomId,
|
||||
slotId,
|
||||
own,
|
||||
logger: parentLogger,
|
||||
}: Props): Signalling {
|
||||
const logger = parentLogger.getChild("[Signalling]");
|
||||
const nextSeqTo = new Map<string, number>();
|
||||
|
||||
const send = (
|
||||
peer: Peer,
|
||||
connectionId: string,
|
||||
{ type, ...payload }: SignallingPayload,
|
||||
): void => {
|
||||
const eventType = SignallingEventType[type];
|
||||
const seq = nextSeqTo.get(peer.memberId) ?? 0;
|
||||
nextSeqTo.set(peer.memberId, seq + 1);
|
||||
const content: CommonContent & Record<string, unknown> = {
|
||||
...payload,
|
||||
room_id: roomId,
|
||||
slot_id: slotId,
|
||||
member_id: own.memberId,
|
||||
target_member_id: peer.memberId,
|
||||
connection_id: connectionId,
|
||||
seq,
|
||||
claimed_device_id: own.deviceId,
|
||||
};
|
||||
client
|
||||
.encryptAndSendToDevice(
|
||||
eventType,
|
||||
[{ userId: peer.userId, deviceId: peer.deviceId }],
|
||||
content,
|
||||
)
|
||||
.catch((e: unknown) => logger.warn(`Failed to send ${eventType}`, e));
|
||||
};
|
||||
|
||||
const messages$ = new Observable<SignallingMessage>((subscriber) => {
|
||||
const orderers = new Map<string, SeqOrderer<SignallingMessage>>();
|
||||
const sub = fromEvent<MatrixEvent>(client, ClientEvent.ToDeviceEvent)
|
||||
.pipe(scope.bind())
|
||||
.subscribe((event) => {
|
||||
const parsed = parse(event, roomId, slotId, own.memberId, logger);
|
||||
if (parsed === null) return;
|
||||
let orderer = orderers.get(parsed.message.memberId);
|
||||
if (orderer === undefined) {
|
||||
orderer = new SeqOrderer((m) => subscriber.next(m), logger);
|
||||
orderers.set(parsed.message.memberId, orderer);
|
||||
}
|
||||
orderer.push(parsed.seq, parsed.message);
|
||||
});
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
for (const orderer of orderers.values()) orderer.dispose();
|
||||
};
|
||||
}).pipe(share());
|
||||
|
||||
return { send, messages$ };
|
||||
}
|
||||
|
||||
function parse(
|
||||
event: MatrixEvent,
|
||||
roomId: string,
|
||||
slotId: string,
|
||||
ownMemberId: string,
|
||||
logger: Logger,
|
||||
): { seq: number; message: SignallingMessage } | null {
|
||||
const type = event.getType();
|
||||
if (!signallingEventTypes.includes(type)) return null;
|
||||
// Via the widget API, to-device events are reconstructed as encrypted
|
||||
if (event.getWireType() !== EventType.RoomMessageEncrypted) {
|
||||
logger.warn(`Dropping ${type} sent in clear from ${event.getSender()}`);
|
||||
return null;
|
||||
}
|
||||
const c = event.getContent<
|
||||
Partial<CommonContent> & Record<string, unknown>
|
||||
>();
|
||||
const userId = event.getSender();
|
||||
if (
|
||||
userId === undefined ||
|
||||
c.room_id !== roomId ||
|
||||
c.slot_id !== slotId ||
|
||||
c.target_member_id !== ownMemberId ||
|
||||
typeof c.member_id !== "string" ||
|
||||
typeof c.connection_id !== "string" ||
|
||||
typeof c.claimed_device_id !== "string" ||
|
||||
typeof c.seq !== "number"
|
||||
)
|
||||
return null;
|
||||
const common = {
|
||||
memberId: c.member_id,
|
||||
userId,
|
||||
deviceId: c.claimed_device_id,
|
||||
connectionId: c.connection_id,
|
||||
};
|
||||
let payload: SignallingPayload;
|
||||
switch (type) {
|
||||
case SignallingEventType.subscribe:
|
||||
payload = { type: "subscribe" };
|
||||
break;
|
||||
case SignallingEventType.description: {
|
||||
const d = c.description as { type?: unknown; sdp?: unknown } | undefined;
|
||||
if (
|
||||
(d?.type !== "offer" && d?.type !== "answer") ||
|
||||
typeof d.sdp !== "string"
|
||||
)
|
||||
return null;
|
||||
payload = {
|
||||
type: "description",
|
||||
description: { type: d.type, sdp: d.sdp },
|
||||
track_metadata: parseTrackMetadata(c.track_metadata) ?? undefined,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case SignallingEventType.candidates:
|
||||
if (!Array.isArray(c.candidates)) return null;
|
||||
payload = {
|
||||
type: "candidates",
|
||||
candidates: c.candidates as RTCIceCandidateInit[],
|
||||
};
|
||||
break;
|
||||
case SignallingEventType.close:
|
||||
payload = {
|
||||
type: "close",
|
||||
reason: typeof c.reason === "string" ? c.reason : undefined,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return { seq: c.seq, message: { ...payload, ...common } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers the messages of one sender in `seq` order. A gap is waited for
|
||||
* `SEQ_GAP_TIMEOUT_MS`, then skipped. A message below the next expected `seq`
|
||||
* came after its gap was skipped, and is dropped.
|
||||
*/
|
||||
export class SeqOrderer<T> {
|
||||
private next = 0;
|
||||
private readonly buffer = new Map<number, T>();
|
||||
private gapTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
public constructor(
|
||||
private readonly deliver: (item: T) => void,
|
||||
private readonly logger?: Logger,
|
||||
private readonly gapTimeoutMs = SEQ_GAP_TIMEOUT_MS,
|
||||
) {}
|
||||
|
||||
public push(seq: number, item: T): void {
|
||||
if (seq < this.next) {
|
||||
this.logger?.warn(`Dropping seq ${seq}, already past it`);
|
||||
return;
|
||||
}
|
||||
this.buffer.set(seq, item);
|
||||
this.flush();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
clearTimeout(this.gapTimer);
|
||||
this.buffer.clear();
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
let item = this.buffer.get(this.next);
|
||||
while (item !== undefined) {
|
||||
this.buffer.delete(this.next);
|
||||
this.next++;
|
||||
this.deliver(item);
|
||||
item = this.buffer.get(this.next);
|
||||
}
|
||||
clearTimeout(this.gapTimer);
|
||||
this.gapTimer = undefined;
|
||||
if (this.buffer.size > 0)
|
||||
this.gapTimer = setTimeout(() => this.skipGap(), this.gapTimeoutMs);
|
||||
}
|
||||
|
||||
private skipGap(): void {
|
||||
const lowest = Math.min(...this.buffer.keys());
|
||||
this.logger?.warn(`Skipping seq ${this.next} to ${lowest - 1}`);
|
||||
this.next = lowest;
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
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 for the parts of WebRTC that jsdom lacks, and for the signalling, so
|
||||
* that the full mesh connections can run in unit tests.
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
|
||||
import { CONTROL_CHANNEL_LABEL } from "./ControlChannel";
|
||||
import { type LocalPublication } from "./LocalMedia";
|
||||
import {
|
||||
type ConnectionMessage,
|
||||
type Peer,
|
||||
type Signalling,
|
||||
type SignallingMessage,
|
||||
type SignallingPayload,
|
||||
} from "./signalling";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export class FakeMediaStream {
|
||||
public readonly id: string;
|
||||
public constructor(public readonly tracks: MediaStreamTrack[] = []) {
|
||||
this.id = `stream-${nextId++}`;
|
||||
}
|
||||
public getTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
}
|
||||
|
||||
export function fakeTrack(
|
||||
kind: "audio" | "video",
|
||||
settings: MediaTrackSettings = {},
|
||||
): MediaStreamTrack {
|
||||
return {
|
||||
id: `track-${nextId++}`,
|
||||
kind,
|
||||
enabled: true,
|
||||
stop: vi.fn(),
|
||||
getSettings: () => settings,
|
||||
} as unknown as MediaStreamTrack;
|
||||
}
|
||||
|
||||
/** A data channel whose opening and incoming messages the test drives. */
|
||||
export class FakeDataChannel extends EventTarget {
|
||||
public readyState: RTCDataChannelState = "connecting";
|
||||
public readonly sent: string[] = [];
|
||||
public readonly protocol: string;
|
||||
|
||||
public constructor(
|
||||
public readonly label: string,
|
||||
public readonly init: RTCDataChannelInit = {},
|
||||
) {
|
||||
super();
|
||||
this.protocol = init.protocol ?? "";
|
||||
}
|
||||
|
||||
public send(data: string): void {
|
||||
if (this.readyState !== "open") throw new Error("The channel isn't open");
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
/** What was sent, parsed. */
|
||||
public sentMessages(): unknown[] {
|
||||
return this.sent.map((data) => JSON.parse(data) as unknown);
|
||||
}
|
||||
|
||||
public open(): void {
|
||||
this.readyState = "open";
|
||||
this.dispatchEvent(new Event("open"));
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.readyState === "closed") return;
|
||||
this.readyState = "closed";
|
||||
this.dispatchEvent(new Event("close"));
|
||||
}
|
||||
|
||||
/** A message from the peer; an object is sent as JSON. */
|
||||
public receive(data: unknown): void {
|
||||
this.dispatchEvent(
|
||||
Object.assign(new Event("message"), {
|
||||
data: typeof data === "string" ? data : JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeTransceiver {
|
||||
kind: string;
|
||||
/** Null until a local description gives it one, as in a browser. */
|
||||
mid: string | null;
|
||||
direction: RTCRtpTransceiverDirection;
|
||||
currentDirection: RTCRtpTransceiverDirection | null;
|
||||
streams: MediaStream[];
|
||||
stopped: boolean;
|
||||
stop(): void;
|
||||
sender: {
|
||||
track: MediaStreamTrack | null;
|
||||
replaceTrack: ReturnType<typeof vi.fn>;
|
||||
parameters: RTCRtpSendParameters;
|
||||
getParameters(): RTCRtpSendParameters;
|
||||
setParameters: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
receiver: { track: MediaStreamTrack };
|
||||
}
|
||||
|
||||
/** Enough of `RTCPeerConnection` for one side of a connection, driven by the test. */
|
||||
export class FakePeerConnection extends EventTarget {
|
||||
public static instances: FakePeerConnection[] = [];
|
||||
|
||||
public signalingState: RTCSignalingState = "stable";
|
||||
public connectionState: RTCPeerConnectionState = "new";
|
||||
public iceConnectionState: RTCIceConnectionState = "new";
|
||||
public localDescription: RTCSessionDescriptionInit | null = null;
|
||||
public remoteDescription: RTCSessionDescriptionInit | null = null;
|
||||
public readonly transceivers: FakeTransceiver[] = [];
|
||||
public readonly addedCandidates: RTCIceCandidateInit[] = [];
|
||||
public readonly dataChannels: FakeDataChannel[] = [];
|
||||
public offers = 0;
|
||||
public closed = false;
|
||||
|
||||
public constructor(public readonly config: RTCConfiguration) {
|
||||
super();
|
||||
FakePeerConnection.instances.push(this);
|
||||
}
|
||||
|
||||
public addTransceiver(
|
||||
kind: string,
|
||||
init: RTCRtpTransceiverInit,
|
||||
): FakeTransceiver {
|
||||
const transceiver: FakeTransceiver = {
|
||||
kind,
|
||||
mid: null,
|
||||
direction: init.direction ?? "sendrecv",
|
||||
currentDirection: null,
|
||||
streams: init.streams ?? [],
|
||||
stopped: false,
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
},
|
||||
sender: {
|
||||
track: null,
|
||||
replaceTrack: vi.fn(async (track: MediaStreamTrack | null) => {
|
||||
transceiver.sender.track = track;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
parameters: {
|
||||
encodings: [{}],
|
||||
} as unknown as RTCRtpSendParameters,
|
||||
getParameters: (): RTCRtpSendParameters =>
|
||||
structuredClone(transceiver.sender.parameters),
|
||||
setParameters: vi.fn(async (parameters: RTCRtpSendParameters) => {
|
||||
transceiver.sender.parameters = parameters;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
receiver: { track: fakeTrack(kind === "audio" ? "audio" : "video") },
|
||||
};
|
||||
this.transceivers.push(transceiver);
|
||||
return transceiver;
|
||||
}
|
||||
|
||||
public createDataChannel(
|
||||
label: string,
|
||||
init?: RTCDataChannelInit,
|
||||
): FakeDataChannel {
|
||||
const channel = new FakeDataChannel(label, init);
|
||||
this.dataChannels.push(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** Our side of the control channel. */
|
||||
public control(): FakeDataChannel {
|
||||
const channel = this.dataChannels.find(
|
||||
(c) => c.label === CONTROL_CHANNEL_LABEL,
|
||||
);
|
||||
if (channel === undefined) throw new Error("No control channel");
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** A data channel the remote side created, as `ondatachannel` brings it. */
|
||||
public announceDataChannel(label: string, protocol = ""): FakeDataChannel {
|
||||
const channel = new FakeDataChannel(label, { protocol });
|
||||
this.dispatchEvent(Object.assign(new Event("datachannel"), { channel }));
|
||||
return channel;
|
||||
}
|
||||
|
||||
public getTransceivers(): FakeTransceiver[] {
|
||||
return this.transceivers;
|
||||
}
|
||||
|
||||
public getSenders(): FakeTransceiver["sender"][] {
|
||||
return this.transceivers.map((t) => t.sender);
|
||||
}
|
||||
|
||||
public async createOffer(): Promise<RTCSessionDescriptionInit> {
|
||||
return Promise.resolve({ type: "offer", sdp: `offer-${++this.offers}` });
|
||||
}
|
||||
|
||||
public async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||
return Promise.resolve({ type: "answer", sdp: "answer" });
|
||||
}
|
||||
|
||||
public async setLocalDescription(
|
||||
description: RTCSessionDescriptionInit,
|
||||
): Promise<void> {
|
||||
this.localDescription = description;
|
||||
this.transceivers.forEach((t, i) => (t.mid ??= String(i)));
|
||||
this.signalingState =
|
||||
description.type === "offer" ? "have-local-offer" : "stable";
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async setRemoteDescription(
|
||||
description: RTCSessionDescriptionInit,
|
||||
): Promise<void> {
|
||||
this.remoteDescription = description;
|
||||
this.signalingState =
|
||||
description.type === "offer" ? "have-remote-offer" : "stable";
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
if (candidate.candidate === "bad")
|
||||
return Promise.reject(new Error("bad candidate"));
|
||||
this.addedCandidates.push(candidate);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async getStats(): Promise<RTCStatsReport> {
|
||||
return Promise.resolve(new Map() as unknown as RTCStatsReport);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.closed = true;
|
||||
this.signalingState = "closed";
|
||||
this.connectionState = "closed";
|
||||
for (const channel of this.dataChannels) channel.close();
|
||||
}
|
||||
|
||||
/** Gathers a local candidate; null ends the gathering. */
|
||||
public gather(candidate: RTCIceCandidateInit | null): void {
|
||||
this.dispatchEvent(
|
||||
Object.assign(new Event("icecandidate"), {
|
||||
candidate: candidate && { toJSON: () => candidate },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public setConnectionState(state: RTCPeerConnectionState): void {
|
||||
this.connectionState = state;
|
||||
this.dispatchEvent(new Event("connectionstatechange"));
|
||||
}
|
||||
|
||||
/** A track the remote side sends, as the next negotiation would bring it. */
|
||||
public receive(kind: "audio" | "video", mid: string): FakeTransceiver {
|
||||
const transceiver = this.addTransceiver(kind, { direction: "recvonly" });
|
||||
transceiver.currentDirection = "recvonly";
|
||||
transceiver.mid = mid;
|
||||
return transceiver;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
public destination = {};
|
||||
public createMediaStreamSource(): object {
|
||||
return { connect: vi.fn(), disconnect: vi.fn() };
|
||||
}
|
||||
public createAnalyser(): object {
|
||||
return {
|
||||
fftSize: 512,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getFloatTimeDomainData: (samples: Float32Array): void => {
|
||||
samples.fill(0);
|
||||
},
|
||||
};
|
||||
}
|
||||
public createGain(): object {
|
||||
return { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces the WebRTC and Web Audio globals jsdom doesn't have. Undo with `vi.unstubAllGlobals()`. */
|
||||
export function installWebRtcFakes(): void {
|
||||
FakePeerConnection.instances = [];
|
||||
vi.stubGlobal("RTCPeerConnection", FakePeerConnection);
|
||||
vi.stubGlobal("MediaStream", FakeMediaStream);
|
||||
vi.stubGlobal("AudioContext", FakeAudioContext);
|
||||
}
|
||||
|
||||
/** The last peer connection created. */
|
||||
export function lastPeerConnection(): FakePeerConnection {
|
||||
const pc = FakePeerConnection.instances.at(-1);
|
||||
if (pc === undefined) throw new Error("No peer connection was created");
|
||||
return pc;
|
||||
}
|
||||
|
||||
/** Lets every queued signalling operation run. */
|
||||
export async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
export const peer: Peer = {
|
||||
userId: "@peer:example.org",
|
||||
deviceId: "PEERDEVICE",
|
||||
memberId: "peerMember",
|
||||
};
|
||||
|
||||
export interface FakeSignalling extends Signalling {
|
||||
send: ReturnType<typeof vi.fn<Signalling["send"]>>;
|
||||
messages$: Subject<SignallingMessage>;
|
||||
/** The payloads sent so far, optionally only those of one type. */
|
||||
sent(type?: string): SignallingPayload[];
|
||||
/** The connection ids sent with the payloads, in the same order. */
|
||||
sentConnectionIds(type?: string): string[];
|
||||
}
|
||||
|
||||
export function fakeSignalling(): FakeSignalling {
|
||||
const send = vi.fn<Signalling["send"]>();
|
||||
const calls = (type?: string): Parameters<Signalling["send"]>[] =>
|
||||
send.mock.calls.filter(([, , p]) => type === undefined || p.type === type);
|
||||
return {
|
||||
send,
|
||||
messages$: new Subject(),
|
||||
sent: (type) => calls(type).map(([, , p]) => p),
|
||||
sentConnectionIds: (type) => calls(type).map(([, id]) => id),
|
||||
};
|
||||
}
|
||||
|
||||
/** A to-device message of a connection, as signalling delivers it. */
|
||||
export type PeerMessage = ConnectionMessage & Omit<SignallingMessage, "type">;
|
||||
|
||||
/** A to-device message from `peer` on a connection. */
|
||||
export function fromPeer<P extends SignallingPayload>(
|
||||
connectionId: string,
|
||||
payload: P,
|
||||
): P & Omit<SignallingMessage, "type"> {
|
||||
return { ...payload, ...peer, connectionId };
|
||||
}
|
||||
|
||||
/** The local media a publisher connection reads. */
|
||||
export function fakeLocalMedia(): {
|
||||
publications$: BehaviorSubject<LocalPublication[]>;
|
||||
publishing$: BehaviorSubject<boolean>;
|
||||
} {
|
||||
return {
|
||||
publications$: new BehaviorSubject<LocalPublication[]>([]),
|
||||
publishing$: new BehaviorSubject(true),
|
||||
};
|
||||
}
|
||||
|
||||
export function fakePublication(
|
||||
kind: "audio" | "video",
|
||||
{
|
||||
source = kind === "audio" ? "microphone" : "camera",
|
||||
stream = new FakeMediaStream() as unknown as MediaStream,
|
||||
}: Partial<Pick<LocalPublication, "source" | "stream">> = {},
|
||||
): LocalPublication & {
|
||||
track$: BehaviorSubject<MediaStreamTrack | null>;
|
||||
muted$: BehaviorSubject<boolean>;
|
||||
} {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
return {
|
||||
source,
|
||||
kind,
|
||||
stream,
|
||||
track$: new BehaviorSubject<MediaStreamTrack | null>(fakeTrack(kind)),
|
||||
muted$,
|
||||
mediaTrack: { muted$ } as unknown as LocalPublication["mediaTrack"],
|
||||
};
|
||||
}
|
||||
@@ -108,8 +108,11 @@ export class ConnectionMap {
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
connectionFactory: ConnectionFactory;
|
||||
/** The transport we publish on; the one connection with the publisher role. */
|
||||
localTransport: TransportLocator;
|
||||
/**
|
||||
* The transport we publish on; the one connection with the publisher role.
|
||||
* Null for a backend that only subscribes, while another backend publishes.
|
||||
*/
|
||||
localTransport: TransportLocator | null;
|
||||
/** Every transport some member is on, deduplicated against the local one. */
|
||||
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
|
||||
logger: Logger;
|
||||
@@ -135,7 +138,11 @@ export function createConnectionMap$({
|
||||
scope.behavior(
|
||||
remoteTransports$.pipe(
|
||||
mapEpoch((remoteTransports) =>
|
||||
removeDuplicateTransports([localTransport, ...remoteTransports]),
|
||||
removeDuplicateTransports(
|
||||
localTransport
|
||||
? [localTransport, ...remoteTransports]
|
||||
: remoteTransports,
|
||||
),
|
||||
),
|
||||
tap((transports) =>
|
||||
logger.debug("localAndRemoteTransports$ = ", transports),
|
||||
@@ -143,7 +150,7 @@ export function createConnectionMap$({
|
||||
),
|
||||
);
|
||||
|
||||
const localTransportKey = keyFor(localTransport);
|
||||
const localTransportKey = localTransport && keyFor(localTransport);
|
||||
|
||||
const connections$ = scope.behavior(
|
||||
localAndRemoteTransports$.pipe(
|
||||
|
||||
@@ -475,6 +475,38 @@ describe("createLivekitBackend", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("connects nowhere until it knows whether it publishes", async () => {
|
||||
const b = backend();
|
||||
const media = b.mediaFor(testScope(), bob(bothTransport, [legacyId]));
|
||||
await flushPromises();
|
||||
expect(media.tracks$.value).toBeNull();
|
||||
// A subscriber connection now would be replaced by the publisher one
|
||||
expect(connections).toHaveLength(0);
|
||||
|
||||
fetchMock.post(homeserverEndpoint, 404);
|
||||
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
|
||||
await b.prepareLocalTransport(bothTransport);
|
||||
await flushPromises();
|
||||
expect(connections.map((c) => c.connection.role)).toEqual(["publisher"]);
|
||||
});
|
||||
|
||||
it("only subscribes once told that another backend publishes", async () => {
|
||||
const b = backend();
|
||||
b.mediaFor(testScope(), bob(bothTransport, [legacyId]));
|
||||
await flushPromises();
|
||||
expect(connections).toHaveLength(0);
|
||||
|
||||
await expect(b.prepareLocalTransport(null)).resolves.toEqual({
|
||||
canDelegateDelayedLeave: false,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(connections.map((c) => c.connection.role)).toEqual(["subscriber"]);
|
||||
expect(connections[0].connection.transport).toEqual(bothTransport);
|
||||
await expect(b.delegateDelayedLeave("id")).rejects.toThrow(
|
||||
"No local transport",
|
||||
);
|
||||
});
|
||||
|
||||
it("has no media for a member without a transport, and opens no connection", async () => {
|
||||
const b = await prepared();
|
||||
const media = b.mediaFor(testScope(), {
|
||||
|
||||
@@ -106,7 +106,11 @@ export function createLivekitBackend(
|
||||
: [requestsOrMedia.requests, requestsOrMedia];
|
||||
const keyProvider = createKeyProvider(encryptionSystem, mediaKeys$, logger);
|
||||
const transports = createTransportRegistry(scope);
|
||||
const localTransport$ = new BehaviorSubject<TransportLocator | null>(null);
|
||||
// Undefined until the session says whether we publish here: null where
|
||||
// another backend publishes
|
||||
const localTransport$ = new BehaviorSubject<
|
||||
TransportLocator | null | undefined
|
||||
>(undefined);
|
||||
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -121,9 +125,20 @@ export function createLivekitBackend(
|
||||
);
|
||||
|
||||
// A new map, in a scope of its own, whenever the local transport changes:
|
||||
// the publisher role sits on one connection, and which one is fixed per map
|
||||
// the publisher role sits on one connection, and which one is fixed per map.
|
||||
// Without a local transport the map only subscribes, for when another
|
||||
// backend publishes; boxed so that `mapScoped` builds a map for null too.
|
||||
// None at all while it isn't known yet: a subscriber connection to the
|
||||
// transport we are about to publish on would only be torn down again
|
||||
const boxedLocalTransport$ = scope.behavior(
|
||||
localTransport$.pipe(
|
||||
map((localTransport) =>
|
||||
localTransport === undefined ? undefined : { localTransport },
|
||||
),
|
||||
),
|
||||
);
|
||||
const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
|
||||
mapScoped(scope, localTransport$, (mapScope, localTransport) =>
|
||||
mapScoped(scope, boxedLocalTransport$, (mapScope, { localTransport }) =>
|
||||
createConnectionMap$({
|
||||
scope: mapScope,
|
||||
connectionFactory,
|
||||
@@ -138,7 +153,9 @@ export function createLivekitBackend(
|
||||
const localMedia_ = createLivekitLocalMedia({
|
||||
scope,
|
||||
connectionMap$,
|
||||
localTransport$,
|
||||
localTransport$: scope.behavior(
|
||||
localTransport$.pipe(map((localTransport) => localTransport ?? null)),
|
||||
),
|
||||
publish,
|
||||
media,
|
||||
audioOutputDeviceId$,
|
||||
@@ -146,18 +163,25 @@ export function createLivekitBackend(
|
||||
logger,
|
||||
});
|
||||
|
||||
// Probed once: the answer cannot change within a session
|
||||
const homeserverSupportsDelegation = supportsDelegation(
|
||||
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
|
||||
"homeserver",
|
||||
logger,
|
||||
);
|
||||
// Probed once, when first needed: the answer cannot change within a
|
||||
// session, and a backend that only subscribes never needs it
|
||||
let homeserverProbe: Promise<boolean> | undefined;
|
||||
const homeserverSupportsDelegation = async (): Promise<boolean> =>
|
||||
(homeserverProbe ??= supportsDelegation(
|
||||
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
|
||||
"homeserver",
|
||||
logger,
|
||||
));
|
||||
|
||||
const ownServerName = serverNameOf(ownMembershipIdentity.userId);
|
||||
|
||||
const prepareLocalTransport = async (
|
||||
transport: Transport,
|
||||
transport: Transport | null,
|
||||
): Promise<TransportCapabilities> => {
|
||||
if (transport === null) {
|
||||
localTransport$.next(null);
|
||||
return { canDelegateDelayedLeave: false };
|
||||
}
|
||||
if (!isUnstableLivekitTransport(transport))
|
||||
throw new MatrixRTCError(`Not a LiveKit transport: ${transport.type}`);
|
||||
const serverName = serverNameOfTransport(
|
||||
@@ -182,7 +206,7 @@ export function createLivekitBackend(
|
||||
`transport ${flow.serviceUrl}`,
|
||||
logger,
|
||||
)
|
||||
: homeserverSupportsDelegation;
|
||||
: homeserverSupportsDelegation();
|
||||
}
|
||||
localTransport$.next(locator);
|
||||
// The membership is only sent once the publisher connection holds its
|
||||
@@ -198,7 +222,7 @@ export function createLivekitBackend(
|
||||
|
||||
const delegateLeave = async (delayId: string): Promise<void> => {
|
||||
const local = localTransport$.value;
|
||||
if (local === null)
|
||||
if (!local)
|
||||
throw new MatrixRTCError("No local transport to delegate the leave to");
|
||||
await delegateDelayedLeave({
|
||||
client,
|
||||
|
||||
+22
-9
@@ -23,13 +23,14 @@ import {
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { HomePage } from "./home/HomePage";
|
||||
import { LoginPage } from "./auth/LoginPage";
|
||||
import { RegisterPage } from "./auth/RegisterPage";
|
||||
import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
@@ -130,9 +131,22 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
.catch(logger.error);
|
||||
});
|
||||
|
||||
const content = loaded ? (
|
||||
<ClientProvider session={vm.clientSession}>
|
||||
<ClientManagerProvider value={vm.clientManager}>
|
||||
// As a widget, the client comes from the host over the widget API. Standalone,
|
||||
// Element Call finds one itself, so there is nothing to wait for here.
|
||||
const [widgetClient, setWidgetClient] = useState<MatrixClient | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (widget === null) return;
|
||||
widget.client
|
||||
.then(setWidgetClient)
|
||||
.catch((e) => logger.error("Failed to obtain the host's client", e));
|
||||
}, [widget]);
|
||||
const clientReady = widget === null || widgetClient !== undefined;
|
||||
|
||||
const content =
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
|
||||
<Sentry.ErrorBoundary
|
||||
@@ -147,11 +161,10 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
</Sentry.ErrorBoundary>
|
||||
</BackgroundEffectsProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientManagerProvider>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
|
||||
+16
-2
@@ -12,7 +12,6 @@ import { type FC, type PropsWithChildren } from "react";
|
||||
|
||||
import { ClientContextProvider } from "./ClientContext";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { hostClientSession } from "./state/ClientSession";
|
||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||
import {
|
||||
type HostBridge,
|
||||
@@ -27,7 +26,22 @@ const TestComponent: FC<
|
||||
}>
|
||||
> = ({ client, hostBridge = nullHostBridge, children }) => {
|
||||
return (
|
||||
<ClientContextProvider value={hostClientSession(client)}>
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
state: "valid",
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
reactions: true,
|
||||
},
|
||||
setClient: vi.fn(),
|
||||
authenticated: {
|
||||
client,
|
||||
isPasswordlessUser: true,
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
|
||||
</ClientContextProvider>
|
||||
);
|
||||
|
||||
+10
-6
@@ -15,7 +15,7 @@ import {
|
||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { useClient } from "./ClientContext";
|
||||
import { useClientState } from "./ClientContext";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
|
||||
export enum Size {
|
||||
@@ -74,7 +74,7 @@ export const Avatar: FC<Props> = ({
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const { client } = useClient();
|
||||
const clientState = useClientState();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const sizePx = useMemo(
|
||||
@@ -87,7 +87,7 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
// In theory, a change in `client` or `sizePx` could run extra media
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra media
|
||||
// downloads, but in practice they should be stable long before this code runs.
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
@@ -99,8 +99,12 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
if (hostBridge.downloadMedia) {
|
||||
blob = hostBridge.downloadMedia(src);
|
||||
} else if (client && sizePx) {
|
||||
blob = getAvatarFromServer(client, src, sizePx);
|
||||
} else if (
|
||||
clientState?.state === "valid" &&
|
||||
clientState.authenticated?.client &&
|
||||
sizePx
|
||||
) {
|
||||
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
|
||||
} else {
|
||||
setAvatarUrl(undefined);
|
||||
return;
|
||||
@@ -129,7 +133,7 @@ export const Avatar: FC<Props> = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [client, hostBridge, src, sizePx]);
|
||||
}, [clientState, hostBridge, src, sizePx]);
|
||||
|
||||
return (
|
||||
<CompoundAvatar
|
||||
|
||||
+60
-93
@@ -6,25 +6,12 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { act, render, renderHook } from "@testing-library/react";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC } from "react";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
ClientManagerProvider,
|
||||
ClientProvider,
|
||||
useClientManager,
|
||||
useClientManagerIfAny,
|
||||
useClientSessionState,
|
||||
} from "./ClientContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
hostClientSession,
|
||||
} from "./state/ClientSession";
|
||||
import { ClientProvider, useClientState } from "./ClientContext";
|
||||
|
||||
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
({
|
||||
@@ -37,47 +24,67 @@ const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
|
||||
/** Reports what the context says, so a test can assert on it. */
|
||||
const ShowClientState: FC = () => {
|
||||
const state = useClientSessionState();
|
||||
switch (state.state) {
|
||||
case "signedIn":
|
||||
return <span>{state.client.getUserId()}</span>;
|
||||
case "signedOut":
|
||||
return <span>unauthenticated</span>;
|
||||
default:
|
||||
return <span>{state.state}</span>;
|
||||
}
|
||||
const state = useClientState();
|
||||
if (state === undefined) return <span>loading</span>;
|
||||
if (state.state === "error") return <span>error</span>;
|
||||
return (
|
||||
<span>{state.authenticated?.client.getUserId() ?? "unauthenticated"}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProvider = (
|
||||
session: ClientSession,
|
||||
children = <ShowClientState />,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const client = mockClient();
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider session={session}>{children}</ClientProvider>
|
||||
<ClientProvider client={client}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const { container } = renderProvider(hostClientSession(mockClient()));
|
||||
|
||||
// Available on the very first render: a supplied client needs no session
|
||||
// restoring, so there is no loading state to pass through.
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
});
|
||||
|
||||
test("does not claim exclusive use of storage when given a client", () => {
|
||||
// The channel is created when the module loads, so spy on the prototype
|
||||
// rather than trying to replace the global.
|
||||
const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage");
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={mockClient()}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// The broadcast shuts down other instances to protect Element Call's own
|
||||
// stores. A host's client brings its own, so there is nothing to protect.
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
|
||||
postMessage.mockRestore();
|
||||
});
|
||||
|
||||
test("follows the client when the host swaps it", () => {
|
||||
const { container, rerender } = renderProvider(
|
||||
hostClientSession(mockClient()),
|
||||
const first = mockClient();
|
||||
const second = mockClient("@bob:example.org");
|
||||
|
||||
const { container, rerender } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={first}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
|
||||
// A host that re-authenticates hands us a new client on a mounted component
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<ClientProvider
|
||||
session={hostClientSession(mockClient("@bob:example.org"))}
|
||||
>
|
||||
<ClientProvider client={second}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
@@ -86,63 +93,23 @@ test("follows the client when the host swaps it", () => {
|
||||
expect(container.textContent).toBe("@bob:example.org");
|
||||
});
|
||||
|
||||
test("follows a session of its own as it is restored", () => {
|
||||
const state$ = new BehaviorSubject<ClientSessionState>({ state: "loading" });
|
||||
const { container } = renderProvider({ state$ });
|
||||
test("finds a client of its own when the host supplies none", async () => {
|
||||
const client = mockClient();
|
||||
vi.doMock("./utils/spa", () => ({
|
||||
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Nothing to show until a session has been restored or created
|
||||
expect(container.textContent).toBe("loading");
|
||||
act(() =>
|
||||
state$.next({
|
||||
state: "signedIn",
|
||||
client: mockClient(),
|
||||
passwordlessUser: true,
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
});
|
||||
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
|
||||
|
||||
test("has no one signed in when the session says so", () => {
|
||||
const { container } = renderProvider({
|
||||
state$: new BehaviorSubject<ClientSessionState>({ state: "signedOut" }),
|
||||
});
|
||||
expect(container.textContent).toBe("unauthenticated");
|
||||
});
|
||||
|
||||
test("shows the session's error in place of its children", () => {
|
||||
const { container } = renderProvider({
|
||||
state$: new BehaviorSubject<ClientSessionState>({
|
||||
state: "error",
|
||||
error: new Error("Opened elsewhere"),
|
||||
}),
|
||||
});
|
||||
// The error page, not the children
|
||||
expect(container.textContent).not.toContain("error");
|
||||
expect(container.textContent).toContain("Something went wrong");
|
||||
});
|
||||
|
||||
test("hands the session's manager to views that need one", () => {
|
||||
const manager: ClientManager = {
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
};
|
||||
const { result } = renderHook(
|
||||
() => [useClientManager(), useClientManagerIfAny()],
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<ClientManagerProvider value={manager}>
|
||||
<>{children}</>
|
||||
</ClientManagerProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
expect(result.current).toEqual([manager, manager]);
|
||||
});
|
||||
|
||||
test("has no manager for a host's session", () => {
|
||||
// The component and the widget provide none; a view that insists on one is
|
||||
// in the wrong build
|
||||
expect(renderHook(() => useClientManagerIfAny()).result.current).toBeNull();
|
||||
expect(() => renderHook(() => useClientManager())).toThrow();
|
||||
vi.doUnmock("./utils/spa");
|
||||
});
|
||||
|
||||
+353
-51
@@ -5,18 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useEffect, createContext, use, type JSX } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
createContext,
|
||||
use,
|
||||
useRef,
|
||||
useMemo,
|
||||
type JSX,
|
||||
} from "react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { ErrorPage } from "./FullScreenView";
|
||||
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
|
||||
import { useBehavior } from "./useBehavior";
|
||||
import { constant } from "./state/Behavior";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
} from "./state/ClientSession";
|
||||
PosthogAnalytics,
|
||||
RegistrationType,
|
||||
} from "./analytics/PosthogAnalytics";
|
||||
import { useEventTarget } from "./useEvents";
|
||||
import { OpenElsewhereError } from "./RichError";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -25,70 +37,360 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// Without a provider, no one is signed in and there is no session to act on
|
||||
const noClientSession: ClientSession = {
|
||||
state$: constant({ state: "signedOut" }),
|
||||
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;
|
||||
};
|
||||
setClient: (client: MatrixClient, session: Session) => void;
|
||||
};
|
||||
|
||||
const ClientContext = createContext<ClientSession>(noClientSession);
|
||||
export type AuthenticatedClient = {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
changePassword: (password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
export type ErrorState = {
|
||||
state: "error";
|
||||
error: Error;
|
||||
};
|
||||
|
||||
const ClientContext = createContext<ClientState | undefined>(undefined);
|
||||
|
||||
export const ClientContextProvider = ClientContext.Provider;
|
||||
|
||||
export const useClientSessionState = (): ClientSessionState =>
|
||||
useBehavior(use(ClientContext).state$);
|
||||
export const useClientState = (): ClientState | undefined => use(ClientContext);
|
||||
|
||||
export function useClient(): { client?: MatrixClient } {
|
||||
const state = useClientSessionState();
|
||||
return { client: state.state === "signedIn" ? state.client : undefined };
|
||||
export function useClient(): {
|
||||
client?: MatrixClient;
|
||||
setClient?: (client: MatrixClient, session: Session) => void;
|
||||
} {
|
||||
let client;
|
||||
let setClient;
|
||||
|
||||
const clientState = useClientState();
|
||||
if (clientState?.state === "valid") {
|
||||
client = clientState.authenticated?.client;
|
||||
setClient = clientState.setClient;
|
||||
}
|
||||
|
||||
return { client, setClient };
|
||||
}
|
||||
|
||||
// Null where the session is a host's: the component, and the widget
|
||||
const ClientManagerContext = createContext<ClientManager | null>(null);
|
||||
// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
|
||||
export function useClientLegacy(): {
|
||||
client?: MatrixClient;
|
||||
setClient?: (client: MatrixClient, session: Session) => void;
|
||||
passwordlessUser: boolean;
|
||||
loading: boolean;
|
||||
authenticated: boolean;
|
||||
logout?: () => void;
|
||||
error?: Error;
|
||||
} {
|
||||
const clientState = useClientState();
|
||||
|
||||
export const ClientManagerProvider = ClientManagerContext.Provider;
|
||||
let client;
|
||||
let setClient;
|
||||
let passwordlessUser = false;
|
||||
let loading = true;
|
||||
let error;
|
||||
let authenticated = false;
|
||||
let logout;
|
||||
|
||||
/**
|
||||
* The manager of a session that is Element Call's own, for views that only
|
||||
* the standalone app mounts: the login and registration pages. Throws
|
||||
* elsewhere, as those views have no business being there.
|
||||
*/
|
||||
export function useClientManager(): ClientManager {
|
||||
const manager = use(ClientManagerContext);
|
||||
if (manager === null)
|
||||
throw new Error("This view needs a session of Element Call's own");
|
||||
return manager;
|
||||
if (clientState?.state === "valid") {
|
||||
client = clientState.authenticated?.client;
|
||||
setClient = clientState.setClient;
|
||||
passwordlessUser = clientState.authenticated?.isPasswordlessUser ?? false;
|
||||
loading = false;
|
||||
authenticated = client !== undefined;
|
||||
logout = clientState.authenticated?.logout;
|
||||
} else if (clientState?.state === "error") {
|
||||
error = clientState.error;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
setClient,
|
||||
passwordlessUser,
|
||||
loading,
|
||||
authenticated,
|
||||
logout,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's manager, or null where the session is a host's. For views
|
||||
* shared with the widget, which can offer to sign out only when that is ours
|
||||
* to do.
|
||||
*/
|
||||
export const useClientManagerIfAny = (): ClientManager | null =>
|
||||
use(ClientManagerContext);
|
||||
const loadChannel =
|
||||
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
/** a session from Element Call SPA, from a widget or the host (EW) */
|
||||
session: ClientSession;
|
||||
/**
|
||||
* The client Element Call should use.
|
||||
*
|
||||
* An application hosting Element Call as a component already has a client,
|
||||
* and owns the user's session; supplying it here means Element Call neither
|
||||
* authenticates anyone nor manages their session. Left out, Element Call finds a client
|
||||
* itself — from the widget API, or by restoring or creating a session of its
|
||||
* own.
|
||||
*/
|
||||
client?: MatrixClient;
|
||||
}
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children, session }) => {
|
||||
const state = useBehavior(session.state$);
|
||||
export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
// null = signed out, undefined = loading
|
||||
const [initClientState, setInitClientState] = useState<
|
||||
InitResult | null | undefined
|
||||
>(
|
||||
client === undefined
|
||||
? undefined
|
||||
: // A supplied client belongs to the host, so there is no session of ours
|
||||
// to restore and nothing to wait for.
|
||||
{ client, passwordlessUser: false },
|
||||
);
|
||||
|
||||
const initializing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (client !== undefined) {
|
||||
// Nothing to load, but a host may hand us a different client later — on
|
||||
// re-authenticating, say — so follow whichever one it has given us.
|
||||
setInitClientState((current) =>
|
||||
current?.client === client
|
||||
? current
|
||||
: { client, passwordlessUser: false },
|
||||
);
|
||||
// Analytics still need to follow the user's choices.
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
return;
|
||||
}
|
||||
// In case the component is mounted, unmounted, and remounted quickly (as
|
||||
// React does in strict mode), we need to make sure not to doubly initialize
|
||||
// the client.
|
||||
if (initializing.current) return;
|
||||
initializing.current = true;
|
||||
|
||||
loadClient()
|
||||
.then((initResult) => {
|
||||
setInitClientState(initResult);
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
})
|
||||
.catch((err) => logger.error(err))
|
||||
.finally(() => (initializing.current = false));
|
||||
}, [client]);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
const session = loadSession();
|
||||
if (!initClientState?.client || !session) {
|
||||
return;
|
||||
}
|
||||
|
||||
await initClientState.client.setPassword(
|
||||
{
|
||||
type: "m.login.password",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: session.user_id,
|
||||
},
|
||||
user: session.user_id,
|
||||
password: session.tempPassword,
|
||||
},
|
||||
password,
|
||||
);
|
||||
|
||||
saveSession({ ...session, passwordlessUser: false });
|
||||
|
||||
setInitClientState({
|
||||
...initClientState,
|
||||
passwordlessUser: false,
|
||||
});
|
||||
},
|
||||
[initClientState],
|
||||
);
|
||||
|
||||
const setClient = useCallback(
|
||||
(client: MatrixClient, session: Session) => {
|
||||
const oldClient = initClientState?.client;
|
||||
if (oldClient && oldClient !== client) {
|
||||
oldClient.stopClient();
|
||||
}
|
||||
|
||||
saveSession(session);
|
||||
setInitClientState({
|
||||
client,
|
||||
passwordlessUser: session.passwordlessUser,
|
||||
});
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
},
|
||||
[initClientState?.client],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const client = initClientState?.client;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.logout(true);
|
||||
await client.clearStores();
|
||||
clearSession();
|
||||
setInitClientState(null);
|
||||
leaveToHome?.();
|
||||
PosthogAnalytics.instance.logout();
|
||||
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
|
||||
}, [leaveToHome, initClientState?.client]);
|
||||
|
||||
// To protect against multiple sessions writing to the same storage
|
||||
// simultaneously, we send a broadcast message that shuts down all other
|
||||
// running instances of the app. Element Call only has storage of its own to
|
||||
// protect when it created the session itself; given a client — by a host, or
|
||||
// over the widget API — it is mostly stateless.
|
||||
const ownsSession = client === undefined;
|
||||
useEffect(() => {
|
||||
if (ownsSession) loadChannel?.postMessage({});
|
||||
}, [ownsSession]);
|
||||
|
||||
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEventTarget(
|
||||
loadChannel,
|
||||
"message",
|
||||
useCallback(() => {
|
||||
initClientState?.client.stopClient();
|
||||
setAlreadyOpenedErr(new OpenElsewhereError());
|
||||
}, [initClientState?.client, setAlreadyOpenedErr]),
|
||||
);
|
||||
|
||||
const [isDisconnected, setIsDisconnected] = useState(false);
|
||||
const [supportsReactions, setSupportsReactions] = useState(false);
|
||||
|
||||
const state: ClientState | undefined = useMemo(() => {
|
||||
if (alreadyOpenedErr) {
|
||||
return { state: "error", error: alreadyOpenedErr };
|
||||
}
|
||||
|
||||
if (initClientState === undefined) return undefined;
|
||||
|
||||
const authenticated =
|
||||
initClientState === null
|
||||
? undefined
|
||||
: {
|
||||
client: initClientState.client,
|
||||
isPasswordlessUser: initClientState.passwordlessUser,
|
||||
changePassword,
|
||||
logout,
|
||||
};
|
||||
|
||||
return {
|
||||
state: "valid",
|
||||
authenticated,
|
||||
setClient,
|
||||
disconnected: isDisconnected,
|
||||
supportedFeatures: {
|
||||
reactions: supportsReactions,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
alreadyOpenedErr,
|
||||
changePassword,
|
||||
initClientState,
|
||||
logout,
|
||||
setClient,
|
||||
isDisconnected,
|
||||
supportsReactions,
|
||||
]);
|
||||
|
||||
const onSync = useCallback(
|
||||
(state: SyncState, _old: SyncState | null, data?: ISyncStateData) => {
|
||||
setIsDisconnected(clientIsDisconnected(state, data));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.state !== "signedIn") return;
|
||||
window.matrixclient = state.client;
|
||||
window.passwordlessUser = state.passwordlessUser;
|
||||
if (!initClientState) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.matrixclient = initClientState.client;
|
||||
window.passwordlessUser = initClientState.passwordlessUser;
|
||||
|
||||
if (PosthogAnalytics.hasInstance())
|
||||
PosthogAnalytics.instance.onLoginStatusChanged();
|
||||
// Analytics follow the user's choices whoever the client belongs to
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
}, [state]);
|
||||
|
||||
if (state.state === "error") return <ErrorPage error={state.error} />;
|
||||
if (initClientState.client) {
|
||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||
}
|
||||
|
||||
return <ClientContext value={session}>{children}</ClientContext>;
|
||||
if (!hostBridge.supportsReactions)
|
||||
logger.warn("The host does not permit reactions");
|
||||
setSupportsReactions(hostBridge.supportsReactions);
|
||||
|
||||
return (): void => {
|
||||
if (initClientState.client) {
|
||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||
}
|
||||
};
|
||||
}, [initClientState, onSync, hostBridge]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage error={alreadyOpenedErr} />;
|
||||
}
|
||||
|
||||
return <ClientContext value={state}>{children}</ClientContext>;
|
||||
};
|
||||
|
||||
export type InitResult = {
|
||||
client: MatrixClient;
|
||||
passwordlessUser: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own. Only reached when no
|
||||
* client was supplied for it to use.
|
||||
*/
|
||||
async function loadClient(): Promise<InitResult | null> {
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
access_token: string;
|
||||
passwordlessUser: boolean;
|
||||
tempPassword?: string;
|
||||
}
|
||||
|
||||
const clearSession = (): void => localStorage.removeItem("matrix-auth-store");
|
||||
const saveSession = (s: Session): void =>
|
||||
localStorage.setItem("matrix-auth-store", JSON.stringify(s));
|
||||
const loadSession = (): Session | undefined => {
|
||||
const data = localStorage.getItem("matrix-auth-store");
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return JSON.parse(data);
|
||||
};
|
||||
|
||||
const clientIsDisconnected = (
|
||||
syncState: SyncState,
|
||||
syncData?: ISyncStateData,
|
||||
): boolean =>
|
||||
syncState === "ERROR" && syncData?.error?.name === "ConnectionError";
|
||||
|
||||
@@ -1,43 +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 { expect, test, vi } from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
import { hostClientSession } from "./state/ClientSession";
|
||||
|
||||
test("shows while the client's sync fails to connect", () => {
|
||||
const client = {
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => "@alice:example.org",
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
const { container } = render(
|
||||
<ClientProvider session={hostClientSession(client)}>
|
||||
<DisconnectedBanner />
|
||||
</ClientProvider>,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
|
||||
const [, onSync] = vi.mocked(client.on).mock.calls[0] as [
|
||||
unknown,
|
||||
(state: string, old: null, data?: { error?: Error }) => void,
|
||||
];
|
||||
const connectionError = Object.assign(new Error("offline"), {
|
||||
name: "ConnectionError",
|
||||
});
|
||||
act(() => onSync("ERROR", null, { error: connectionError }));
|
||||
expect(container.textContent).not.toBe("");
|
||||
// Any other failure is not a connection problem
|
||||
act(() => onSync("ERROR", null, { error: new Error("rate limited") }));
|
||||
expect(container.textContent).toBe("");
|
||||
act(() => onSync("SYNCING", null));
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
@@ -6,60 +6,30 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type FC, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
|
||||
import styles from "./DisconnectedBanner.module.css";
|
||||
import { useClient } from "./ClientContext";
|
||||
import { type ValidClientState, useClientState } from "./ClientContext";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLElement> {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown while the signed-in client is supposed to be connected but is not.
|
||||
*/
|
||||
export const DisconnectedBanner: FC<Props> = ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { client } = useClient();
|
||||
// Tagged with the client it describes, so that a swapped client does not
|
||||
// inherit the old one's verdict before its first sync
|
||||
const [disconnected, setDisconnected] = useState<
|
||||
[MatrixClient, boolean] | null
|
||||
>(null);
|
||||
const clientState = useClientState();
|
||||
let shouldShowBanner = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (client === undefined) return;
|
||||
const onSync = (
|
||||
syncState: SyncState,
|
||||
_old: SyncState | null,
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
const clientIsDisconnected =
|
||||
syncState === "ERROR" && data?.error?.name === "ConnectionError";
|
||||
setDisconnected([client, clientIsDisconnected]);
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
return (): void => {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const shouldShowBanner =
|
||||
disconnected !== null && disconnected[0] === client && disconnected[1];
|
||||
if (clientState?.state === "valid") {
|
||||
const validClientState = clientState as ValidClientState;
|
||||
shouldShowBanner = validClientState.disconnected;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+8
-10
@@ -18,8 +18,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -181,40 +179,40 @@ export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
|
||||
+6
-10
@@ -21,10 +21,6 @@ import {
|
||||
UserIntent,
|
||||
} from "../src/UrlParams";
|
||||
import { mockConfig } from "./utils/test";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
const ROOM_NAME = "roomNameHere";
|
||||
const ROOM_ID = "!d45f138fsd";
|
||||
@@ -279,7 +275,7 @@ describe("UrlParams", () => {
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
skipLobby: false,
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
callIntent: "video",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,7 +322,7 @@ describe("UrlParams", () => {
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
skipLobby: false,
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,7 +333,7 @@ describe("UrlParams", () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
...joinExistingCallDefaults("desktop"),
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -352,7 +348,7 @@ describe("UrlParams", () => {
|
||||
sendNotificationType: "ring",
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: true,
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,7 +363,7 @@ describe("UrlParams", () => {
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
callIntent: "video",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,7 +377,7 @@ describe("UrlParams", () => {
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,96 +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 { expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
|
||||
import { LeaveToHomeProvider } from "./LeaveToHomeContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
hostClientSession,
|
||||
} from "./state/ClientSession";
|
||||
import { UserMenuContainer } from "./UserMenuContainer";
|
||||
import { flushPromises } from "./utils/test";
|
||||
|
||||
vi.mock("./settings/SettingsModal", () => ({
|
||||
defaultSettingsTab: "audio",
|
||||
SettingsModal: (): null => null,
|
||||
}));
|
||||
|
||||
const mockClient = (): MatrixClient =>
|
||||
({
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => "@alice:example.org",
|
||||
getUserIdLocalpart: () => "alice",
|
||||
getDeviceId: () => "AAAA",
|
||||
getUser: () => null,
|
||||
stopClient: vi.fn(),
|
||||
}) as Partial<MatrixClient> as MatrixClient;
|
||||
|
||||
test("leaves for home after logging out of the session", async () => {
|
||||
const logout = vi.fn().mockResolvedValue(undefined);
|
||||
const leaveToHome = vi.fn();
|
||||
renderMenu(
|
||||
{
|
||||
state$: new BehaviorSubject<ClientSessionState>({
|
||||
state: "signedIn",
|
||||
client: mockClient(),
|
||||
passwordlessUser: false,
|
||||
}),
|
||||
},
|
||||
{ login: vi.fn(), register: vi.fn(), logout },
|
||||
leaveToHome,
|
||||
);
|
||||
|
||||
await selectSignOut();
|
||||
expect(logout).toHaveBeenCalled();
|
||||
expect(leaveToHome).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot end a session that is not ours", async () => {
|
||||
const leaveToHome = vi.fn();
|
||||
renderMenu(hostClientSession(mockClient()), null, leaveToHome);
|
||||
|
||||
await selectSignOut();
|
||||
expect(leaveToHome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function selectSignOut(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("usermenu_open"));
|
||||
await user.click(await screen.findByTestId("usermenu_logout"));
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
function renderMenu(
|
||||
session: ClientSession,
|
||||
manager: ClientManager | null,
|
||||
leaveToHome: (() => void) | null = null,
|
||||
): void {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<LeaveToHomeProvider value={leaveToHome}>
|
||||
<ClientProvider session={session}>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<UserMenuContainer />
|
||||
</ClientManagerProvider>
|
||||
</ClientProvider>
|
||||
</LeaveToHomeProvider>
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,7 @@ import { type FC, useCallback, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
useClient,
|
||||
useClientManagerIfAny,
|
||||
useClientSessionState,
|
||||
} from "./ClientContext";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import { useClientLegacy } from "./ClientContext";
|
||||
import { useProfile } from "./profile/useProfile";
|
||||
import { defaultSettingsTab, SettingsModal } from "./settings/SettingsModal";
|
||||
import { UserMenu } from "./UserMenu";
|
||||
@@ -26,13 +21,7 @@ interface Props {
|
||||
export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { client } = useClient();
|
||||
const session = useClientSessionState();
|
||||
const passwordlessUser =
|
||||
session.state === "signedIn" && session.passwordlessUser;
|
||||
// Absent when the session is a host's, which Element Call cannot end
|
||||
const logout = useClientManagerIfAny()?.logout;
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const { client, logout, authenticated, passwordlessUser } = useClientLegacy();
|
||||
const { displayName, avatarUrl } = useProfile(client);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const onDismissSettingsModal = useCallback(
|
||||
@@ -54,9 +43,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
setSettingsModalOpen(true);
|
||||
break;
|
||||
case "logout":
|
||||
logout?.()
|
||||
.then(() => leaveToHome?.())
|
||||
.catch((e) => logger.error("Failed to log out", e));
|
||||
logout?.();
|
||||
break;
|
||||
case "login":
|
||||
navigate("/login", { state: { from: location } })?.catch((error) =>
|
||||
@@ -65,7 +52,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigate, location, logout, leaveToHome, setSettingsModalOpen],
|
||||
[navigate, location, logout, setSettingsModalOpen],
|
||||
);
|
||||
|
||||
const userName = client?.getUserIdLocalpart() ?? "";
|
||||
@@ -73,7 +60,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
<>
|
||||
<UserMenu
|
||||
preventNavigation={preventNavigation}
|
||||
isAuthenticated={client !== undefined}
|
||||
isAuthenticated={authenticated}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
avatarUrl={avatarUrl}
|
||||
onAction={onAction}
|
||||
|
||||
@@ -1,78 +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 { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type FC } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { LoginPage } from "./LoginPage";
|
||||
import { ClientManagerProvider } from "../ClientContext";
|
||||
import { type ClientManager } from "../state/ClientSession";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("signs in with the session's manager and goes home", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.login).mockResolvedValue({} as MatrixClient);
|
||||
renderLogin(manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.login).toHaveBeenCalledWith(
|
||||
"https://hs.example.org",
|
||||
"alice",
|
||||
"hunter2",
|
||||
);
|
||||
expect(await screen.findByText("at /")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows why signing in failed", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.login).mockRejectedValue(new Error("Wrong password"));
|
||||
renderLogin(manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(await screen.findByText("Wrong password")).toBeVisible();
|
||||
expect(screen.getByText("at /login")).toBeVisible();
|
||||
});
|
||||
|
||||
async function submit(username: string, password: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("login_username"), username);
|
||||
await user.type(screen.getByTestId("login_password"), password);
|
||||
await user.click(screen.getByTestId("login_login"));
|
||||
}
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
const WhereAmI: FC = () => <span>at {useLocation().pathname}</span>;
|
||||
|
||||
function renderLogin(manager: ClientManager): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<LoginPage />
|
||||
<WhereAmI />
|
||||
</ClientManagerProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
+13
-4
@@ -11,9 +11,10 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
import Logo from "../icons/LogoLarge.svg?react";
|
||||
import { useClientManager } from "../ClientContext";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import styles from "./LoginPage.module.css";
|
||||
import { useInteractiveLogin } from "./useInteractiveLogin";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
@@ -23,7 +24,8 @@ export const LoginPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("login_title"));
|
||||
|
||||
const { login } = useClientManager();
|
||||
const { client, setClient } = useClient();
|
||||
const login = useInteractiveLogin(client);
|
||||
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
@@ -44,8 +46,15 @@ export const LoginPage: FC = () => {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
login(homeserver, usernameRef.current.value, passwordRef.current.value)
|
||||
.then(async () => {
|
||||
.then(async ([client, session]) => {
|
||||
if (!setClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClient(client, session);
|
||||
|
||||
const locationState = location.state;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
@@ -63,7 +72,7 @@ export const LoginPage: FC = () => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[login, location, navigate, homeserver],
|
||||
[login, location, navigate, homeserver, setClient],
|
||||
);
|
||||
// we need to limit the length of the homserver name to not cover the whole loginview input with the string.
|
||||
let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25);
|
||||
|
||||
@@ -1,133 +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 { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type FC } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { RegisterPage } from "./RegisterPage";
|
||||
import { ClientContextProvider, ClientManagerProvider } from "../ClientContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSessionState,
|
||||
} from "../state/ClientSession";
|
||||
|
||||
vi.mock("./useRecaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
execute: vi.fn().mockResolvedValue("captcha-response"),
|
||||
reset,
|
||||
recaptchaId: "recaptcha",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./useRegistrationTerms", () => ({
|
||||
useRegistrationTerms: () => ({ recaptchaKey: "site-key" }),
|
||||
}));
|
||||
|
||||
const reset = vi.fn();
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("waits for the session to be restored", () => {
|
||||
renderRegister({ state: "loading" }, mockManager());
|
||||
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("registers an account, signs in and goes home", async () => {
|
||||
const manager = mockManager();
|
||||
const setSignedIn = vi.fn();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn,
|
||||
});
|
||||
renderRegister({ state: "signedOut" }, manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"alice",
|
||||
"hunter2",
|
||||
"alice",
|
||||
"captcha-response",
|
||||
false,
|
||||
);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
expect(await screen.findByText("at /")).toBeVisible();
|
||||
});
|
||||
|
||||
test("upgrades a passwordless user to a full account", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn: vi.fn(),
|
||||
});
|
||||
renderRegister(
|
||||
{
|
||||
state: "signedIn",
|
||||
client: { getUserId: () => "@guest:example.org" } as MatrixClient,
|
||||
passwordlessUser: true,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"alice",
|
||||
"hunter2",
|
||||
"alice",
|
||||
"captcha-response",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shows why registering failed and resets the captcha", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockRejectedValue(new Error("Name taken"));
|
||||
renderRegister({ state: "signedOut" }, manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(await screen.findByText("Name taken")).toBeVisible();
|
||||
expect(reset).toHaveBeenCalled();
|
||||
expect(screen.getByText("at /register")).toBeVisible();
|
||||
});
|
||||
|
||||
async function submit(username: string, password: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("register_username"), username);
|
||||
await user.type(screen.getByTestId("register_password"), password);
|
||||
await user.type(screen.getByTestId("register_confirm_password"), password);
|
||||
await user.click(screen.getByTestId("register_register"));
|
||||
}
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
const WhereAmI: FC = () => <span>at {useLocation().pathname}</span>;
|
||||
|
||||
function renderRegister(
|
||||
state: ClientSessionState,
|
||||
manager: ClientManager,
|
||||
): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/register"]}>
|
||||
<ClientContextProvider
|
||||
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
|
||||
>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<RegisterPage />
|
||||
<WhereAmI />
|
||||
</ClientManagerProvider>
|
||||
</ClientContextProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
+17
-17
@@ -22,12 +22,8 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import {
|
||||
useClient,
|
||||
useClientManager,
|
||||
useClientSessionState,
|
||||
} from "../ClientContext";
|
||||
import { useRegistrationTerms } from "./useRegistrationTerms";
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "./useInteractiveRegistration";
|
||||
import styles from "./LoginPage.module.css";
|
||||
import Logo from "../icons/LogoLarge.svg?react";
|
||||
import { LoadingPage } from "../FullScreenView";
|
||||
@@ -41,13 +37,8 @@ export const RegisterPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("action.register"));
|
||||
|
||||
const session = useClientSessionState();
|
||||
const loading = session.state === "loading";
|
||||
const passwordlessUser =
|
||||
session.state === "signedIn" && session.passwordlessUser;
|
||||
const { client } = useClient();
|
||||
const authenticated = client !== undefined;
|
||||
const { register } = useClientManager();
|
||||
const { loading, authenticated, passwordlessUser, client, setClient } =
|
||||
useClientLegacy();
|
||||
|
||||
const confirmPasswordRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -56,7 +47,7 @@ export const RegisterPage: FC = () => {
|
||||
const [error, setError] = useState<Error>();
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
||||
const { recaptchaKey } = useRegistrationTerms();
|
||||
const { recaptchaKey, register } = useInteractiveRegistration(client);
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const onSubmitRegisterForm = useCallback(
|
||||
@@ -73,7 +64,7 @@ export const RegisterPage: FC = () => {
|
||||
setRegistering(true);
|
||||
|
||||
const recaptchaResponse = await execute();
|
||||
const { client: newClient, setSignedIn } = await register(
|
||||
const [newClient, session] = await register(
|
||||
userName,
|
||||
password,
|
||||
userName,
|
||||
@@ -104,7 +95,7 @@ export const RegisterPage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
setSignedIn();
|
||||
setClient?.(newClient, session);
|
||||
PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date());
|
||||
};
|
||||
|
||||
@@ -126,7 +117,16 @@ export const RegisterPage: FC = () => {
|
||||
reset();
|
||||
});
|
||||
},
|
||||
[register, location, navigate, passwordlessUser, reset, execute, client],
|
||||
[
|
||||
register,
|
||||
location,
|
||||
navigate,
|
||||
passwordlessUser,
|
||||
reset,
|
||||
execute,
|
||||
client,
|
||||
setClient,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,104 +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 { afterEach, expect, test, vi } from "vitest";
|
||||
import { createClient, type MatrixClient, MatrixError } from "matrix-js-sdk";
|
||||
import type * as MatrixJsSdk from "matrix-js-sdk";
|
||||
|
||||
import { loginWithPassword, registerWithPassword } from "./interactiveAuth";
|
||||
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixJsSdk>()),
|
||||
createClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const credentials = {
|
||||
user_id: "@alice:example.org",
|
||||
device_id: "AAAA",
|
||||
access_token: "secret",
|
||||
};
|
||||
|
||||
// What interactive auth asks of the client besides the request itself
|
||||
const authClient = { generateClientSecret: () => "client-secret" };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("logs in with a password", async () => {
|
||||
const login = vi.fn().mockResolvedValue({ ...credentials, extra: "ignored" });
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
...authClient,
|
||||
login,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
|
||||
await expect(
|
||||
loginWithPassword("https://hs.example.org", "alice", "hunter2"),
|
||||
).resolves.toEqual(credentials);
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
baseUrl: "https://hs.example.org",
|
||||
});
|
||||
expect(login).toHaveBeenCalledWith("m.login.password", {
|
||||
identifier: { type: "m.id.user", user: "alice" },
|
||||
password: "hunter2",
|
||||
});
|
||||
});
|
||||
|
||||
test("registers, agreeing to the terms and answering the recaptcha", async () => {
|
||||
// The homeserver turns the first request away with the stages to complete
|
||||
// No error in it: that is the shape of a challenge rather than a refusal
|
||||
const challenge = new MatrixError(
|
||||
{
|
||||
session: "session",
|
||||
flows: [{ stages: ["m.login.terms", "m.login.recaptcha"] }],
|
||||
params: {},
|
||||
completed: [],
|
||||
},
|
||||
401,
|
||||
);
|
||||
const registerRequest = vi
|
||||
.fn()
|
||||
.mockImplementation(({ auth }: { auth?: { type: string } }) => {
|
||||
if (auth === undefined) throw challenge;
|
||||
challenge.data.completed = [
|
||||
...(challenge.data.completed as string[]),
|
||||
auth.type,
|
||||
];
|
||||
if (challenge.data.completed.length < 2) throw challenge;
|
||||
return credentials;
|
||||
});
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
...authClient,
|
||||
registerRequest,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
|
||||
await expect(
|
||||
registerWithPassword(
|
||||
"https://hs.example.org",
|
||||
"alice",
|
||||
"hunter2",
|
||||
"captcha",
|
||||
),
|
||||
).resolves.toEqual(credentials);
|
||||
|
||||
expect(registerRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: "alice",
|
||||
password: "hunter2",
|
||||
auth: expect.objectContaining({ type: "m.login.terms" }),
|
||||
}),
|
||||
);
|
||||
expect(registerRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({
|
||||
type: "m.login.recaptcha",
|
||||
response: "captcha",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,106 +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 {
|
||||
createClient,
|
||||
InteractiveAuth,
|
||||
type LoginResponse,
|
||||
type RegisterResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type StoredSession } from "../state/ClientSession";
|
||||
|
||||
/** What the homeserver hands out for a new session. */
|
||||
export type Credentials = Pick<
|
||||
StoredSession,
|
||||
"user_id" | "device_id" | "access_token"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Logs in to the given homeserver with a password.
|
||||
*/
|
||||
export async function loginWithPassword(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Credentials> {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (): Promise<LoginResponse> =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
}),
|
||||
stateUpdated: (): void => {},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "" }),
|
||||
});
|
||||
return credentials(await interactiveAuth.attemptAuth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an account on the given homeserver, accepting its terms and
|
||||
* answering its recaptcha challenge.
|
||||
*/
|
||||
export async function registerWithPassword(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
recaptchaResponse: string,
|
||||
): Promise<Credentials> {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (auth): Promise<RegisterResponse> =>
|
||||
authClient.registerRequest({
|
||||
username,
|
||||
password,
|
||||
auth: auth || undefined,
|
||||
}),
|
||||
stateUpdated: (nextStage, status): void => {
|
||||
if (status.error) {
|
||||
throw new Error(status.error);
|
||||
}
|
||||
|
||||
if (nextStage === "m.login.terms") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.terms",
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
} else if (nextStage === "m.login.recaptcha") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.recaptcha",
|
||||
response: recaptchaResponse,
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
}
|
||||
},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "dummy" }),
|
||||
});
|
||||
return credentials(await interactiveAuth.attemptAuth());
|
||||
}
|
||||
|
||||
// XXX: attemptAuth claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
function credentials(authData: unknown): Credentials {
|
||||
/* eslint-disable camelcase */
|
||||
const { user_id, access_token, device_id } = authData as Credentials;
|
||||
return { user_id, access_token, device_id };
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
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 { InteractiveAuth } from "matrix-js-sdk";
|
||||
import {
|
||||
createClient,
|
||||
type LoginResponse,
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { type Session } from "../ClientContext";
|
||||
/**
|
||||
* This provides the login method to login using user credentials.
|
||||
* @param oldClient If there is an already authenticated client it should be passed to this hook
|
||||
* this allows the interactive login to sign out the client before logging in.
|
||||
* @returns A async method that can be called/awaited to log in with the provided credentials.
|
||||
*/
|
||||
export function useInteractiveLogin(
|
||||
oldClient?: MatrixClient,
|
||||
): (
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<[MatrixClient, Session]> {
|
||||
return useCallback<
|
||||
(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<[MatrixClient, Session]>
|
||||
>(
|
||||
async (homeserver: string, username: string, password: string) => {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (): Promise<LoginResponse> =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
}),
|
||||
stateUpdated: (): void => {},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "" }),
|
||||
});
|
||||
|
||||
// XXX: This claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||
const { user_id, access_token, device_id } =
|
||||
(await interactiveAuth.attemptAuth()) as any;
|
||||
const session = {
|
||||
user_id,
|
||||
access_token,
|
||||
device_id,
|
||||
passwordlessUser: false,
|
||||
};
|
||||
|
||||
// To not confuse the rust crypto sessions we need to logout the old client before initializing the new one.
|
||||
await oldClient?.logout(true);
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: homeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
/* eslint-enable camelcase */
|
||||
return [client, session];
|
||||
},
|
||||
[oldClient],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
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 { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk";
|
||||
import {
|
||||
createClient,
|
||||
type MatrixClient,
|
||||
type RegisterResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { type Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export const useInteractiveRegistration = (
|
||||
oldClient?: MatrixClient,
|
||||
): {
|
||||
privacyPolicyUrl?: string;
|
||||
recaptchaKey?: string;
|
||||
register: (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser: boolean,
|
||||
) => Promise<[MatrixClient, Session]>;
|
||||
} => {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const authClient = useRef<MatrixClient | undefined>(undefined);
|
||||
if (!authClient.current) {
|
||||
authClient.current = createClient({
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
authClient.current!.registerRequest({}).catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url,
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, [isWidget]);
|
||||
|
||||
const register = useCallback(
|
||||
async (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser: boolean,
|
||||
): Promise<[MatrixClient, Session]> => {
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient.current!,
|
||||
doRequest: async (auth): Promise<RegisterResponse> =>
|
||||
authClient.current!.registerRequest({
|
||||
username,
|
||||
password,
|
||||
auth: auth || undefined,
|
||||
}),
|
||||
stateUpdated: (nextStage, status): void => {
|
||||
if (status.error) {
|
||||
throw new Error(status.error);
|
||||
}
|
||||
|
||||
if (nextStage === "m.login.terms") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.terms",
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
} else if (nextStage === "m.login.recaptcha") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.recaptcha",
|
||||
response: recaptchaResponse,
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
}
|
||||
},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "dummy" }),
|
||||
});
|
||||
|
||||
// XXX: This claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||
const { user_id, access_token, device_id } =
|
||||
(await interactiveAuth.attemptAuth()) as any;
|
||||
await oldClient?.logout(true);
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
await client.setDisplayName(displayName);
|
||||
|
||||
const session: Session = {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
passwordlessUser,
|
||||
};
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
if (passwordlessUser) {
|
||||
session.tempPassword = password;
|
||||
}
|
||||
|
||||
const user = client.getUser(client.getUserId()!)!;
|
||||
user.setRawDisplayName(displayName);
|
||||
user.setDisplayName(displayName);
|
||||
|
||||
return [client, session];
|
||||
},
|
||||
[oldClient],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, recaptchaKey, register };
|
||||
};
|
||||
@@ -1,116 +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 { afterEach, expect, test, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { useRegisterPasswordlessUser } from "./useRegisterPasswordlessUser";
|
||||
import { ClientManagerProvider } from "../ClientContext";
|
||||
import { type ClientManager } from "../state/ClientSession";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
|
||||
vi.mock("./useRecaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
execute: vi.fn().mockResolvedValue("captcha-response"),
|
||||
reset,
|
||||
recaptchaId: "recaptcha",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./useRegistrationTerms", () => ({
|
||||
useRegistrationTerms: () => ({
|
||||
privacyPolicyUrl: "https://hs.example.org/privacy",
|
||||
recaptchaKey: "site-key",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./generateRandomName", () => ({
|
||||
generateRandomName: () => "Random Name",
|
||||
}));
|
||||
|
||||
const reset = vi.fn();
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("registers a user under a random name and signs them in", async () => {
|
||||
const setSignedIn = vi.fn();
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, false),
|
||||
});
|
||||
await result.current.registerPasswordlessUser("Alice");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"Random Name",
|
||||
expect.stringMatching(/^.{16}$/),
|
||||
"Alice",
|
||||
"captcha-response",
|
||||
true,
|
||||
);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
expect(result.current.privacyPolicyUrl).toBe(
|
||||
"https://hs.example.org/privacy",
|
||||
);
|
||||
expect(result.current.recaptchaId).toBe("recaptcha");
|
||||
});
|
||||
|
||||
test("resets the captcha when registration fails", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockRejectedValue(new Error("Nope"));
|
||||
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, false),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow("Nope");
|
||||
expect(reset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot register on a session that is not ours", async () => {
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(null, false),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow("This session cannot register");
|
||||
});
|
||||
|
||||
test("never registers as a widget", async () => {
|
||||
const manager = mockManager();
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, true),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow(/embedded mode/);
|
||||
expect(manager.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
function wrapper(
|
||||
manager: ClientManager | null,
|
||||
isWidget: boolean,
|
||||
): FC<PropsWithChildren> {
|
||||
const params = { ...computeUrlParams(), isWidget };
|
||||
return ({ children }) => (
|
||||
<UrlParamsProvider value={params}>
|
||||
<ClientManagerProvider value={manager}>{children}</ClientManagerProvider>
|
||||
</UrlParamsProvider>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details.
|
||||
import { useCallback } from "react";
|
||||
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
|
||||
|
||||
import { useClientManagerIfAny } from "../ClientContext";
|
||||
import { useRegistrationTerms } from "../auth/useRegistrationTerms";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
@@ -21,16 +21,16 @@ interface UseRegisterPasswordlessUserType {
|
||||
}
|
||||
|
||||
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
// Null in the widget, which never registers anyone
|
||||
const register = useClientManagerIfAny()?.register;
|
||||
const { setClient } = useClient();
|
||||
const { isWidget } = useUrlParams();
|
||||
const { privacyPolicyUrl, recaptchaKey } = useRegistrationTerms();
|
||||
const { privacyPolicyUrl, recaptchaKey, register } =
|
||||
useInteractiveRegistration();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const registerPasswordlessUser = useCallback(
|
||||
async (displayName: string) => {
|
||||
if (!register) {
|
||||
throw new Error("This session cannot register");
|
||||
if (!setClient) {
|
||||
throw new Error("No client context");
|
||||
}
|
||||
if (isWidget) {
|
||||
throw new Error(
|
||||
@@ -41,20 +41,20 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
try {
|
||||
const recaptchaResponse = await execute();
|
||||
const userName = generateRandomName();
|
||||
const { setSignedIn } = await register(
|
||||
const [client, session] = await register(
|
||||
userName,
|
||||
secureRandomString(16),
|
||||
displayName,
|
||||
recaptchaResponse,
|
||||
true,
|
||||
);
|
||||
setSignedIn();
|
||||
setClient(client, session);
|
||||
} catch (e) {
|
||||
reset();
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[execute, reset, register, isWidget],
|
||||
[execute, reset, register, setClient, isWidget],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
||||
|
||||
@@ -1,98 +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 { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type * as MatrixJsSdk from "matrix-js-sdk";
|
||||
import { createClient, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
|
||||
import { useRegistrationTerms } from "./useRegistrationTerms";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixJsSdk>()),
|
||||
createClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const registerRequest = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
registerRequest,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("reads the terms the homeserver asks for from a registration attempt", async () => {
|
||||
registerRequest.mockRejectedValue({
|
||||
data: {
|
||||
params: {
|
||||
"m.login.terms": {
|
||||
policies: {
|
||||
privacy_policy: { en: { url: "https://hs.example.org/privacy" } },
|
||||
},
|
||||
},
|
||||
"m.login.recaptcha": { public_key: "site-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(false),
|
||||
});
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
baseUrl: "https://hs.example.org",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: "https://hs.example.org/privacy",
|
||||
recaptchaKey: "site-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("has nothing to agree to where the homeserver asks for nothing", async () => {
|
||||
registerRequest.mockRejectedValue({ data: { params: {} } });
|
||||
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(false),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(registerRequest).toHaveBeenCalled());
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: undefined,
|
||||
recaptchaKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("asks the homeserver nothing as a widget", () => {
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(true),
|
||||
});
|
||||
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: undefined,
|
||||
recaptchaKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
function wrapper(isWidget: boolean): FC<PropsWithChildren> {
|
||||
const params = { ...computeUrlParams(), isWidget };
|
||||
return ({ children }) => (
|
||||
<UrlParamsProvider value={params}>{children}</UrlParamsProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
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 { createClient } from "matrix-js-sdk";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
/**
|
||||
* What the homeserver asks of a registration: its privacy policy to agree to
|
||||
* and the recaptcha key to answer a challenge with.
|
||||
*/
|
||||
export function useRegistrationTerms(): {
|
||||
privacyPolicyUrl?: string;
|
||||
recaptchaKey?: string;
|
||||
} {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
createClient({ baseUrl: Config.defaultHomeserverUrl()! })
|
||||
.registerRequest({})
|
||||
.catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en
|
||||
?.url,
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, [isWidget]);
|
||||
|
||||
return { privacyPolicyUrl, recaptchaKey };
|
||||
}
|
||||
@@ -141,7 +141,6 @@ export const Default: Story = {
|
||||
backgroundImageRefusal: undefined,
|
||||
addBackgroundImage: fn(),
|
||||
removeBackgroundEffect: fn(),
|
||||
cameraTrack: null,
|
||||
hangup: fn(),
|
||||
buttonSize: "lg",
|
||||
showFooter: true,
|
||||
|
||||
@@ -33,9 +33,7 @@ import { useBehavior } from "../useBehavior";
|
||||
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
|
||||
import { LayoutSwitch } from "../room/LayoutSwitch";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import { type UnusableReason } from "../tracks/backgroundImages";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
export interface BackgroundImageRefusal {
|
||||
reason: UnusableReason | "not-kept";
|
||||
@@ -94,8 +92,6 @@ export interface FooterState {
|
||||
backgroundEffectSettling: boolean;
|
||||
/** Why the last file offered couldn't be kept, if it couldn't. */
|
||||
backgroundImageRefusal: BackgroundImageRefusal | undefined;
|
||||
/** The camera to preview, with the effect in force; null while it is off. */
|
||||
cameraTrack: LocalVideoMediaTrack | null;
|
||||
showFooter: boolean;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
@@ -185,7 +181,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
|
||||
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
|
||||
const cameraTrack = useBehavior(vm.cameraTrack$);
|
||||
const refusal = useBehavior(vm.backgroundImageRefusal$);
|
||||
const refusalMessage = useMemo(() => {
|
||||
switch (refusal?.reason) {
|
||||
@@ -269,7 +264,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
backgroundEffectSettling={backgroundEffectSettling}
|
||||
onAddBackgroundImage={addBackgroundImage}
|
||||
onRemoveBackgroundEffect={removeBackgroundEffect}
|
||||
selfPreview={<SelfPreview track={cameraTrack} />}
|
||||
backgroundImageRefusal={refusalMessage}
|
||||
backgroundEffectNotice={
|
||||
backgroundEffectNotice === "unavailable"
|
||||
|
||||
@@ -7,17 +7,14 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import {
|
||||
flushPromises,
|
||||
testScope,
|
||||
mockMuteStates,
|
||||
mockMediaDevices,
|
||||
mockVideoTrack,
|
||||
} from "../utils/test";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { constant } from "../state/Behavior";
|
||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import type { Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
@@ -81,7 +78,6 @@ vi.mock("livekit-client", () => ({
|
||||
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
return {
|
||||
layout$: constant(layout),
|
||||
camera$: constant(undefined),
|
||||
edgeToEdge$: constant(false),
|
||||
showHeader$: constant(false),
|
||||
hangup: (): void => {},
|
||||
@@ -113,16 +109,6 @@ const pipLayout: Layout = {
|
||||
spotlight: {} as SpotlightTileViewModel,
|
||||
};
|
||||
|
||||
const noPipeline = constant<BackgroundEffectsState>({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
});
|
||||
|
||||
/** The provider's verdict, which the SDK's check stands for here. */
|
||||
function pipelineAsTheSdkSays(): Behavior<BackgroundEffectsState> {
|
||||
return constant({ supported: sdkSupportMock(), processor: undefined });
|
||||
}
|
||||
|
||||
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
@@ -154,7 +140,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -197,7 +183,7 @@ describe("createCallFooterViewModel", () => {
|
||||
select: vi.fn(),
|
||||
},
|
||||
}),
|
||||
noPipeline,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -216,7 +202,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(layout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -239,7 +225,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -285,8 +271,7 @@ describe("createCallFooterViewModel", () => {
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipelineAsTheSdkSays(),
|
||||
/* camera$ */ constant(null),
|
||||
constant(false),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
@@ -371,52 +356,21 @@ describe("createCallFooterViewModel", () => {
|
||||
|
||||
it("passes on the wait for the first effect", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
wait: "settling",
|
||||
});
|
||||
const settling$ = new BehaviorSubject(true);
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipeline$,
|
||||
/* camera$ */ constant(null),
|
||||
settling$,
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(true);
|
||||
pipeline$.next({ ...pipeline$.value, wait: undefined });
|
||||
settling$.next(false);
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(false);
|
||||
});
|
||||
|
||||
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipeline$,
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffect$.value).toBe("blur");
|
||||
|
||||
pipeline$.next({ supported: false, processor: undefined });
|
||||
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
expect(vm.backgroundEffect$.value).toBe("none");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("blur");
|
||||
});
|
||||
|
||||
it("adding is unavailable at the limit", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const added$ = addedBackgrounds.added$ as BehaviorSubject<
|
||||
@@ -473,44 +427,6 @@ describe("createCallFooterViewModel", () => {
|
||||
expect(store.remove).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("previews the lobby's camera while there is one", () => {
|
||||
const track = mockVideoTrack({});
|
||||
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
camera$,
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
// The lobby releases the camera when it is turned off
|
||||
camera$.next(null);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
});
|
||||
|
||||
it("previews the call's camera only while it is not muted", () => {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
const track = mockVideoTrack({ muted$ });
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
muted$.next(true);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
muted$.next(false);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
});
|
||||
|
||||
it("availability is the same before and during a call", () => {
|
||||
for (const supported of [true, false]) {
|
||||
sdkSupportMock.mockReturnValue(supported);
|
||||
@@ -520,7 +436,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipelineAsTheSdkSays(),
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
|
||||
import {
|
||||
supportsBackgroundProcessors,
|
||||
supportsModernBackgroundProcessors,
|
||||
} from "@livekit/track-processors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
} from "../tracks/backgroundImages";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import {
|
||||
@@ -87,9 +87,7 @@ function buildDeviceBehaviors(
|
||||
mediaDevices: MediaDevices,
|
||||
/** return empty arrays for audioOptions and videoOptions*/
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
/** The camera as the pipeline has it, for the menu's preview. */
|
||||
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioOptions$"
|
||||
@@ -109,7 +107,6 @@ function buildDeviceBehaviors(
|
||||
| "addBackgroundImage$"
|
||||
| "backgroundImageRefusal$"
|
||||
| "removeBackgroundEffect$"
|
||||
| "cameraTrack$"
|
||||
> {
|
||||
const options$ = (
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
@@ -126,14 +123,10 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
);
|
||||
|
||||
// Also false once the pipeline has failed to build this session.
|
||||
const supported$ = backgroundEffectsState$.pipe(
|
||||
map(({ supported }) => supported === true),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const supported = supportsBackgroundProcessors();
|
||||
const slow = !supportsModernBackgroundProcessors();
|
||||
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
|
||||
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
|
||||
const offered$ = disableSwitcher$.pipe(
|
||||
map((switcherDisabled) => !switcherDisabled && supported),
|
||||
);
|
||||
// A new object for each refusal, so the same one twice is shown twice.
|
||||
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
|
||||
@@ -164,10 +157,8 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
|
||||
backgroundEffect$: scope.behavior(
|
||||
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
|
||||
map(([raw, supported]) =>
|
||||
supported ? serializeEffect(parseEffect(raw)) : "none",
|
||||
),
|
||||
backgroundEffectSetting.value$.pipe(
|
||||
map((raw) => (supported ? serializeEffect(parseEffect(raw)) : "none")),
|
||||
),
|
||||
),
|
||||
selectBackgroundEffect$: scope.behavior(
|
||||
@@ -195,10 +186,7 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
),
|
||||
backgroundEffectSettling$: scope.behavior(
|
||||
backgroundEffectsState$.pipe(
|
||||
map(({ wait }) => wait !== undefined),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
backgroundEffectSettling$.pipe(distinctUntilChanged()),
|
||||
false,
|
||||
),
|
||||
// Kept and offered, not put on: that waits for the user to choose it.
|
||||
@@ -220,17 +208,6 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
),
|
||||
backgroundImageRefusal$: refusal$,
|
||||
// None while muted: the call mutes its track rather than releasing it.
|
||||
cameraTrack$: scope.behavior(
|
||||
camera$.pipe(
|
||||
switchMap((camera) =>
|
||||
camera
|
||||
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
|
||||
: of(null),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
// Never the one in force: taking it away would leave nothing chosen.
|
||||
removeBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
@@ -260,8 +237,8 @@ function buildDeviceBehaviors(
|
||||
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
|
||||
* @param muteStates - Audio and video mute state + toggles.
|
||||
* @param mediaDevices - Available and selected input devices.
|
||||
* @param backgroundEffectsState$ - The background pipeline's state: whether
|
||||
* effects run here, and the wait for the first.
|
||||
* @param backgroundEffectSettling$ - Whether the first effect chosen is still
|
||||
* being prepared.
|
||||
* @param reactionIdentifier - The local user's reaction identifier string, or
|
||||
* undefined when reactions are not supported (hides the reaction button).
|
||||
* @param options - `showControls`: whether the call controls should be shown.
|
||||
@@ -272,7 +249,7 @@ export function createCallFooterViewModel(
|
||||
callModel: CallViewModel,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
reactionIdentifier: string | undefined,
|
||||
options: { showControls: boolean; header: HeaderStyle },
|
||||
): ViewModel<FooterSnapshot> {
|
||||
@@ -291,8 +268,7 @@ export function createCallFooterViewModel(
|
||||
scope,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
backgroundEffectsState$,
|
||||
callModel.camera$,
|
||||
backgroundEffectSettling$,
|
||||
),
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
@@ -355,9 +331,8 @@ export function createCallFooterViewModel(
|
||||
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
|
||||
* @param muteStates - Audio and video mute state + toggles.
|
||||
* @param mediaDevices - Available and selected input devices.
|
||||
* @param backgroundEffectsState$ - The background pipeline's state: whether
|
||||
* effects run here, and the wait for the first.
|
||||
* @param camera$ - The lobby's camera, for the camera menu's preview.
|
||||
* @param backgroundEffectSettling$ - Whether the first effect chosen is still
|
||||
* being prepared.
|
||||
* @param openSettings - Callback to open the settings modal, or undefined.
|
||||
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
|
||||
* @param showLogo - Whether to show the Element Call logo.
|
||||
@@ -366,8 +341,7 @@ export function createLobbyFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
camera$: Behavior<LocalVideoMediaTrack | null>,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
openSettings: (() => void) | undefined,
|
||||
hangup: (() => void) | undefined,
|
||||
showLogo: boolean,
|
||||
@@ -415,8 +389,7 @@ export function createLobbyFooterViewModel(
|
||||
scope,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
backgroundEffectsState$,
|
||||
camera$,
|
||||
backgroundEffectSettling$,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,35 +97,3 @@ Please see LICENSE in the repository root for full details.
|
||||
.refusal {
|
||||
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
|
||||
}
|
||||
|
||||
.selfPreview {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-block-end: var(--cpd-space-2x);
|
||||
overflow: clip;
|
||||
border-start-start-radius: var(--cpd-space-3x);
|
||||
border-start-end-radius: var(--cpd-space-3x);
|
||||
background: var(--cpd-color-bg-subtle-secondary);
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
.selfPreview > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
object-fit: cover;
|
||||
/* Rounded itself too: a video can be drawn on a layer of its own, which the
|
||||
frame's rounded clip doesn't always reach. */
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Up to the menu's frame, over the padding at its top. */
|
||||
.selfPreviewPinned,
|
||||
.deviceListLeadsWithPreview {
|
||||
margin-block-start: calc(-1 * var(--cpd-space-5x));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
import meterStyles from "./MicrophoneLevelMeter.module.css";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
@@ -61,50 +60,6 @@ const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
/** A camera for the preview: a figure against a room, drawn to a stream. */
|
||||
const FakeCamera: FC = () => {
|
||||
const [stream, setStream] = useState<MediaStream>();
|
||||
useEffect(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 360;
|
||||
const context = canvas.getContext("2d")!;
|
||||
const draw = (): void => {
|
||||
context.fillStyle = "#6b7f8e";
|
||||
context.fillRect(0, 0, 640, 360);
|
||||
context.fillStyle = "#c9a27e";
|
||||
context.beginPath();
|
||||
context.arc(320, 150, 62, 0, 2 * Math.PI);
|
||||
context.fill();
|
||||
context.fillStyle = "#34495e";
|
||||
context.beginPath();
|
||||
context.ellipse(320, 380, 150, 140, 0, Math.PI, 2 * Math.PI);
|
||||
context.fill();
|
||||
};
|
||||
draw();
|
||||
// Redrawn, so the stream keeps delivering frames.
|
||||
const timer = setInterval(draw, 200);
|
||||
const captured = canvas.captureStream(10);
|
||||
setStream(captured);
|
||||
return (): void => {
|
||||
clearInterval(timer);
|
||||
captured.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<video
|
||||
data-testid="preview-camera"
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
ref={(video): void => {
|
||||
if (video && stream && video.srcObject !== stream)
|
||||
video.srcObject = stream;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
|
||||
const WithACallArea: FC<{ children: ReactNode; blockSize?: number }> = ({
|
||||
children,
|
||||
@@ -216,8 +171,8 @@ export const VideoMute: Story = {
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||
],
|
||||
|
||||
selectedOption: "1",
|
||||
selfPreview: <SelfPreview track={null} />,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -244,8 +199,6 @@ export const VideoUnmute: Story = {
|
||||
selectedBackgroundEffect: "none",
|
||||
onSelectBackgroundEffect: fn(),
|
||||
onAddBackgroundImage: fn(),
|
||||
// As the footer always gives the camera menu one.
|
||||
selfPreview: <FakeCamera />,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -391,9 +344,6 @@ export const BackgroundEffectsSettling: Story = {
|
||||
checked: false,
|
||||
}))
|
||||
await expect(other).toHaveAttribute("aria-busy", "false");
|
||||
await expect(menu.queryByTestId("preview-camera")).toBeNull();
|
||||
const frames = document.body.querySelectorAll(`.${styles.selfPreview} svg`);
|
||||
await expect(frames).toHaveLength(1);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -551,113 +501,6 @@ export const RemovingWithLiveSelection: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Where the call leaves room, the camera holds the top while the options scroll. */
|
||||
export const BackgroundEffectsWithPreview: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
// Enough that the list fills its share, so the preview's cost counts.
|
||||
options: Array.from({ length: 8 }, (_, i) => ({
|
||||
label: { type: "name" as const, name: `Camera ${i + 1}` },
|
||||
id: `${i + 1}`,
|
||||
})),
|
||||
},
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<WithACallArea blockSize={640}>
|
||||
<Story />
|
||||
</WithACallArea>
|
||||
),
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const picture = await within(document.body).findByTestId("preview-camera");
|
||||
const preview = picture.parentElement!;
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
|
||||
const box = preview.getBoundingClientRect();
|
||||
await expect(list.contains(preview)).toBe(false);
|
||||
await expect(box.top - frame.top).toBeLessThanOrEqual(2);
|
||||
await expect(frame.width - box.width).toBeLessThanOrEqual(2);
|
||||
await expect(box.width / box.height).toBeCloseTo(16 / 9, 1);
|
||||
await expect(box.bottom).toBeLessThanOrEqual(
|
||||
list.getBoundingClientRect().top,
|
||||
);
|
||||
|
||||
const listShare = Math.round(640 * 0.6);
|
||||
// The list runs on through the menu's padding at its foot, which isn't
|
||||
// part of its share.
|
||||
const listEnd =
|
||||
list.getBoundingClientRect().bottom -
|
||||
Number.parseFloat(getComputedStyle(list).paddingBlockEnd);
|
||||
await expect(listEnd - box.top).toBeLessThanOrEqual(listShare + 1);
|
||||
|
||||
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
await expect(preview.getBoundingClientRect().top).toBe(box.top);
|
||||
},
|
||||
};
|
||||
|
||||
/** With the camera off the preview says so, and effects can still be chosen. */
|
||||
export const BackgroundEffectsPreviewWithTheCameraOff: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
enabled: false,
|
||||
selfPreview: <SelfPreview track={null} />,
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
await menu.findByRole("group", { name: "Background effects" });
|
||||
const preview = document.body.querySelector(`.${styles.selfPreview}`)!;
|
||||
await expect(preview.querySelector("video")).toBeNull();
|
||||
await expect(preview.querySelector("svg")).not.toBeNull();
|
||||
|
||||
await userEvent.click(menu.getByRole("menuitemradio", { name: "Blur" }));
|
||||
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
|
||||
},
|
||||
};
|
||||
|
||||
/** In a call too short to hold it, the camera leads the options and scrolls away. */
|
||||
export const BackgroundEffectsPreviewScrollsWithTheList: Story = {
|
||||
args: VideoUnmute.args,
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<WithACallArea blockSize={300}>
|
||||
<Story />
|
||||
</WithACallArea>
|
||||
),
|
||||
],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const picture = await within(document.body).findByTestId("preview-camera");
|
||||
const preview = picture.parentElement!;
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
await expect(list.contains(preview)).toBe(true);
|
||||
const cameras = within(document.body).getByRole("group", {
|
||||
name: "Camera",
|
||||
});
|
||||
await expect(
|
||||
preview.compareDocumentPosition(cameras) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
|
||||
list.scrollTop = list.scrollHeight;
|
||||
await expect(preview.getBoundingClientRect().bottom).toBeLessThan(
|
||||
list.getBoundingClientRect().top,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** In a short call the effects scroll into view with the list. */
|
||||
export const BackgroundEffectsScrollWhenTheyDoNotFit: Story = {
|
||||
args: {
|
||||
|
||||
@@ -14,12 +14,10 @@ import {
|
||||
type FC,
|
||||
useEffect,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
InlineSpinner,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuScrollArea,
|
||||
@@ -89,8 +87,6 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
onRemoveBackgroundEffect?: (id: string) => void;
|
||||
/** Why the last file offered couldn't be used; a new object each time. */
|
||||
backgroundImageRefusal?: { text: string };
|
||||
/** The user's own camera, shown at the top of the camera menu. */
|
||||
selfPreview?: ReactNode;
|
||||
/**
|
||||
* For any toggle and option this method will be called.
|
||||
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
|
||||
@@ -107,16 +103,9 @@ const LIST_SHARE_OF_CALL = 0.6;
|
||||
/** Smallest device list height in px, so a short call still shows more than one device. */
|
||||
const MIN_LIST_HEIGHT = 160;
|
||||
|
||||
/** The menu's width as its stylesheet sets it, to place the preview before
|
||||
the menu is drawn. */
|
||||
const MENU_WIDTH = 296;
|
||||
|
||||
/** Space kept between the menu and the call area's sides. */
|
||||
const MENU_MARGIN = 16;
|
||||
|
||||
/** Below the preview, as its stylesheet draws it. */
|
||||
const PREVIEW_GAP = 12;
|
||||
|
||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
enabled,
|
||||
busy,
|
||||
@@ -135,7 +124,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
onAddBackgroundImage,
|
||||
onRemoveBackgroundEffect,
|
||||
backgroundImageRefusal,
|
||||
selfPreview,
|
||||
onSelect,
|
||||
}) => {
|
||||
// Requested but not yet selected. Keyed by kind too, since Chrome uses
|
||||
@@ -214,53 +202,21 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
|
||||
// Measured on the call area: CSS can't size the portalled menu against it.
|
||||
const rootElement = useRootElement();
|
||||
const [listShare, setListShare] = useState<number>();
|
||||
const [previewBlock, setPreviewBlock] = useState(0);
|
||||
const [listMaxHeight, setListMaxHeight] = useState<number>();
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
// Followed, since a host can resize the call while the menu is open.
|
||||
const subscription = observeElementSize$(rootElement)
|
||||
.pipe(
|
||||
map(({ width, height }) => ({
|
||||
height: Math.round(height * LIST_SHARE_OF_CALL),
|
||||
width: Math.min(MENU_WIDTH, Math.round(width - 2 * MENU_MARGIN)),
|
||||
})),
|
||||
distinctUntilChanged(
|
||||
(a, b) => a.height === b.height && a.width === b.width,
|
||||
map(({ height }) =>
|
||||
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(({ height, width }) => {
|
||||
setListShare(height);
|
||||
setPreviewBlock(Math.round((width * 9) / 16) + PREVIEW_GAP);
|
||||
});
|
||||
.subscribe(setListMaxHeight);
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [menuOpen, rootElement]);
|
||||
|
||||
// Paid for out of the list's share, so a preview makes the menu no taller.
|
||||
const hasPreview =
|
||||
iconsAndLabels === "video" &&
|
||||
selfPreview !== undefined &&
|
||||
listShare !== undefined;
|
||||
const previewPinned =
|
||||
hasPreview && listShare - previewBlock >= MIN_LIST_HEIGHT;
|
||||
const listMaxHeight =
|
||||
listShare === undefined
|
||||
? undefined
|
||||
: Math.max(
|
||||
MIN_LIST_HEIGHT,
|
||||
listShare - (previewPinned ? previewBlock : 0),
|
||||
);
|
||||
const preview = hasPreview && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(styles.selfPreview, {
|
||||
[styles.selfPreviewPinned]: previewPinned,
|
||||
})}
|
||||
>
|
||||
{backgroundEffectSettling ? <InlineSpinner size={32} /> : selfPreview}
|
||||
</div>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
|
||||
}, [menuOpen, devices]);
|
||||
@@ -451,12 +407,9 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
{previewPinned && preview}
|
||||
<MenuScrollArea
|
||||
ref={trackFocusSource}
|
||||
className={classNames(styles.deviceList, {
|
||||
[styles.deviceListLeadsWithPreview]: hasPreview && !previewPinned,
|
||||
})}
|
||||
className={styles.deviceList}
|
||||
data-testid="device-list"
|
||||
style={
|
||||
{
|
||||
@@ -465,7 +418,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{hasPreview && !previewPinned && preview}
|
||||
{audioMeter && speakerOptions && (
|
||||
<MenuScrollSection title={t("settings.devices.speaker")}>
|
||||
{deviceItems(
|
||||
|
||||
@@ -1,10 +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.
|
||||
*/
|
||||
|
||||
.mirrored {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
@@ -1,47 +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, render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import styles from "./SelfPreview.module.css";
|
||||
import { mockVideoTrack } from "../utils/test";
|
||||
|
||||
describe("SelfPreview", () => {
|
||||
it("detaches only its own element from a camera shown twice", () => {
|
||||
const track = mockVideoTrack({});
|
||||
const first = render(<SelfPreview track={track} />);
|
||||
const second = render(<SelfPreview track={track} />);
|
||||
const [a, b] = [first, second].map((r) =>
|
||||
r.container.querySelector("video"),
|
||||
);
|
||||
expect(track.attach).toHaveBeenCalledWith(a);
|
||||
expect(track.attach).toHaveBeenCalledWith(b);
|
||||
|
||||
first.unmount();
|
||||
expect(track.detach).toHaveBeenCalledExactlyOnceWith(a);
|
||||
});
|
||||
|
||||
it("mirrors the camera while it faces the user, and only then", () => {
|
||||
const facingMode$ = new BehaviorSubject<"user" | "environment">("user");
|
||||
const { container } = render(
|
||||
<SelfPreview track={mockVideoTrack({ facingMode$ })} />,
|
||||
);
|
||||
const video = (): HTMLVideoElement => container.querySelector("video")!;
|
||||
expect(video().classList.contains(styles.mirrored)).toBe(true);
|
||||
act(() => facingMode$.next("environment"));
|
||||
expect(video().classList.contains(styles.mirrored)).toBe(false);
|
||||
});
|
||||
|
||||
it("says the camera is off while there is none", () => {
|
||||
const { container } = render(<SelfPreview track={null} />);
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +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 FC, useEffect, useRef } from "react";
|
||||
import { constant, type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { VideoCallOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./SelfPreview.module.css";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
const noFacingMode = constant(undefined);
|
||||
|
||||
interface Props {
|
||||
/** The camera as the pipeline has it, or none while the camera is off. */
|
||||
track: LocalVideoMediaTrack | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own camera with the effect in force: the pipeline's own track,
|
||||
* shown a second time rather than captured again.
|
||||
*/
|
||||
export const SelfPreview: FC<Props> = ({ track }) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
useEffect(() => {
|
||||
const element = videoRef.current;
|
||||
if (!track || !element) return;
|
||||
track.attach(element);
|
||||
// This element only: the pre-join preview may be showing the same camera
|
||||
return (): void => {
|
||||
track.detach(element);
|
||||
};
|
||||
}, [track]);
|
||||
const facingMode = useBehavior(track?.facingMode$ ?? noFacingMode);
|
||||
|
||||
if (!track) return <VideoCallOffIcon width={32} height={32} />;
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
className={facingMode === "user" ? styles.mirrored : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -108,22 +108,6 @@ describe("Config.initWith", () => {
|
||||
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
|
||||
});
|
||||
|
||||
it("falls back to the defaults when config.json is not JSON", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("<!doctype html><title>Element</title>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
);
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
Reflect.set(Config, "internalInstance", undefined);
|
||||
|
||||
await expect(Config.init()).resolves.toBeUndefined();
|
||||
|
||||
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces a config initialized earlier", () => {
|
||||
Config.initWith({ ssla: "https://first.invalid/ssla" });
|
||||
Config.initWith({ ssla: "https://second.invalid/ssla" });
|
||||
|
||||
@@ -156,10 +156,5 @@ async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
logger.warn(`Config at ${fetchTarget} is not JSON, using defaults`, e);
|
||||
return {};
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -1,62 +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 { expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { HomePage } from "./HomePage";
|
||||
import { ClientContextProvider } from "../ClientContext";
|
||||
import { type ClientSessionState } from "../state/ClientSession";
|
||||
|
||||
vi.mock("./RegisteredView", () => ({
|
||||
RegisteredView: ({ client }: { client: MatrixClient }) => (
|
||||
<span>registered as {client.getUserId()}</span>
|
||||
),
|
||||
}));
|
||||
vi.mock("./UnauthenticatedView", () => ({
|
||||
UnauthenticatedView: () => <span>unauthenticated</span>,
|
||||
}));
|
||||
|
||||
test("waits for the session to be restored", () => {
|
||||
renderHome({ state: "loading" });
|
||||
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the session's error", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
renderHome({ state: "error", error: new Error("Opened elsewhere") });
|
||||
expect(screen.getByText("Something went wrong")).toBeVisible();
|
||||
});
|
||||
|
||||
test("welcomes a signed-in user", () => {
|
||||
renderHome({
|
||||
state: "signedIn",
|
||||
client: { getUserId: () => "@alice:example.org" } as MatrixClient,
|
||||
passwordlessUser: false,
|
||||
});
|
||||
expect(screen.getByText("registered as @alice:example.org")).toBeVisible();
|
||||
});
|
||||
|
||||
test("invites a signed-out user to start a call", () => {
|
||||
renderHome({ state: "signedOut" });
|
||||
expect(screen.getByText("unauthenticated")).toBeVisible();
|
||||
});
|
||||
|
||||
function renderHome(state: ClientSessionState): void {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientContextProvider
|
||||
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
|
||||
>
|
||||
<HomePage />
|
||||
</ClientContextProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
+12
-11
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type FC } from "react";
|
||||
|
||||
import { useClientSessionState } from "../ClientContext";
|
||||
import { useClientState } from "../ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "../FullScreenView";
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
import { RegisteredView } from "./RegisteredView";
|
||||
@@ -18,16 +18,17 @@ export const HomePage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("common.home"));
|
||||
|
||||
const session = useClientSessionState();
|
||||
const clientState = useClientState();
|
||||
|
||||
switch (session.state) {
|
||||
case "loading":
|
||||
return <LoadingPage />;
|
||||
case "error":
|
||||
return <ErrorPage error={session.error} />;
|
||||
case "signedIn":
|
||||
return <RegisteredView client={session.client} />;
|
||||
case "signedOut":
|
||||
return <UnauthenticatedView />;
|
||||
if (!clientState) {
|
||||
return <LoadingPage />;
|
||||
} else if (clientState.state === "error") {
|
||||
return <ErrorPage error={clientState.error} />;
|
||||
} else {
|
||||
return clientState.authenticated ? (
|
||||
<RegisteredView client={clientState.authenticated.client} />
|
||||
) : (
|
||||
<UnauthenticatedView />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user