mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-23 00:02:26 +00:00
Compare commits
161
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6b8207218 | ||
|
|
956671eef5 | ||
|
|
28e1a9e22f | ||
|
|
27dcfb8bb7 | ||
|
|
038991fa97 | ||
|
|
2994bb4ed0 | ||
|
|
d84dcc75de | ||
|
|
7c3f3d3ac7 | ||
|
|
7e4e260273 | ||
|
|
4aa249daf5 | ||
|
|
b4bd1f81a0 | ||
|
|
de8b5d98ec | ||
|
|
7685947a69 | ||
|
|
b158c4fcd7 | ||
|
|
dc8f8e585a | ||
|
|
ee7dcdee7e | ||
|
|
8d1ed31b0b | ||
|
|
54e948fdc0 | ||
|
|
441fe5accd | ||
|
|
37d25d4293 | ||
|
|
079185b232 | ||
|
|
558fbe2188 | ||
|
|
022415e98e | ||
|
|
a1575cb554 | ||
|
|
97b5e61ad8 | ||
|
|
f8258f6a1d | ||
|
|
68107d119e | ||
|
|
e96f578b63 | ||
|
|
cfaae8f7e2 | ||
|
|
1a90e813e8 | ||
|
|
9d0a12c38a | ||
|
|
a1c98ac4e6 | ||
|
|
b72493f47f | ||
|
|
49223e720b | ||
|
|
c5044119ce | ||
|
|
f366e7cbe2 | ||
|
|
e336ee5cfa | ||
|
|
17c3a7cb5d | ||
|
|
53a0e273d6 | ||
|
|
83e1396bdd | ||
|
|
d5bbb43e3a | ||
|
|
24b6a632dd | ||
|
|
17c3223883 | ||
|
|
c79675e6a9 | ||
|
|
f260755f46 | ||
|
|
3927c2b7dc | ||
|
|
4e4ab23cda | ||
|
|
3f030e64dd | ||
|
|
d8becaf868 | ||
|
|
994cd36c44 | ||
|
|
7ac5373af9 | ||
|
|
32fcede65e | ||
|
|
6438c18b73 | ||
|
|
f07e9f5c70 | ||
|
|
c97e3bf7a6 | ||
|
|
674692c926 | ||
|
|
c3d07b89bd | ||
|
|
f89cbc140e | ||
|
|
3bd892ebd3 | ||
|
|
f322af2742 | ||
|
|
a9fa48f68c | ||
|
|
471c76c0e7 | ||
|
|
799619557b | ||
|
|
5f914b8a01 | ||
|
|
aa897ae9bc | ||
|
|
4881fd5fc6 | ||
|
|
49c29f160a | ||
|
|
aa8f73e632 | ||
|
|
2bb870f6e7 | ||
|
|
e44dfe1898 | ||
|
|
b5fac4a2ff | ||
|
|
66f326fcf0 | ||
|
|
3cdb452d5d | ||
|
|
f49c1cd3d4 | ||
|
|
dc983e5493 | ||
|
|
9cc9eb0889 | ||
|
|
ca4f0cdeae | ||
|
|
4790af08a4 | ||
|
|
6ffab92f0f | ||
|
|
f1ea4cc5a2 | ||
|
|
5b13e1da81 | ||
|
|
d0a2ff12f1 | ||
|
|
64d7eb99cc | ||
|
|
cad00aef08 | ||
|
|
6bd6e3f953 | ||
|
|
b9f79be106 | ||
|
|
5a43eaf5a6 | ||
|
|
63ccfa50be | ||
|
|
18e0065086 | ||
|
|
d7ab2b27b6 | ||
|
|
3afe52bd4a | ||
|
|
d89c058275 | ||
|
|
2af87a760d | ||
|
|
5c6d2f0891 | ||
|
|
31eafb394b | ||
|
|
61e353d919 | ||
|
|
1a999eaa54 | ||
|
|
9910496b90 | ||
|
|
2b9fdfe4d5 | ||
|
|
14c561af8d | ||
|
|
4fd2f66949 | ||
|
|
53e038b09b | ||
|
|
3b7ccb63e2 | ||
|
|
1f07f949b5 | ||
|
|
a60036b3d7 | ||
|
|
2eb3a2fd2d | ||
|
|
8b6adf067e | ||
|
|
b408610277 | ||
|
|
2faa6c3dac | ||
|
|
9b3c3569ec | ||
|
|
a4654d9d00 | ||
|
|
6e5f71c796 | ||
|
|
067f3d3038 | ||
|
|
6d70221fe6 | ||
|
|
427376f78b | ||
|
|
ccfd36923d | ||
|
|
89567726f8 | ||
|
|
9cd111b5f2 | ||
|
|
096aaac9f3 | ||
|
|
8835a6d75d | ||
|
|
bfb868aec3 | ||
|
|
4e8fb6e4cb | ||
|
|
7c53a6be5a | ||
|
|
64b932f128 | ||
|
|
bfad85a3af | ||
|
|
be822adec8 | ||
|
|
4c0f02ef47 | ||
|
|
ca6319f06d | ||
|
|
1d57a8eee1 | ||
|
|
0bb9632b60 | ||
|
|
cbe195d449 | ||
|
|
e800fec74d | ||
|
|
9d0725ad31 | ||
|
|
241412f29b | ||
|
|
d96d00b64a | ||
|
|
cde88a7c93 | ||
|
|
d5733e8274 | ||
|
|
3befe3d34b | ||
|
|
3ad778e7e8 | ||
|
|
9a286032f2 | ||
|
|
363a8feab2 | ||
|
|
bd8c3142a6 | ||
|
|
16c60c8b6b | ||
|
|
63ac1ba03e | ||
|
|
9e190e9e57 | ||
|
|
f287fb3800 | ||
|
|
f9b601504b | ||
|
|
9140365251 | ||
|
|
81233e5cfb | ||
|
|
e296d88969 | ||
|
|
fbce73ae55 | ||
|
|
ce79274ccc | ||
|
|
8db2e8ac41 | ||
|
|
6600c891f3 | ||
|
|
33cfd2d8ad | ||
|
|
5a8714805c | ||
|
|
232e3f7ad4 | ||
|
|
10adf6658b | ||
|
|
29742006df | ||
|
|
5c497cc6a8 | ||
|
|
8ebb0b5a90 |
@@ -102,6 +102,9 @@
|
||||
- name: "backport-candidate"
|
||||
description: "Something that is a candidate for backport to a particular release branch"
|
||||
color: "0B8D85"
|
||||
- name: "Claude-Review"
|
||||
description: "Request code review from Claude"
|
||||
color: "e57157"
|
||||
- name: "customer-retainer"
|
||||
color: "F44A5F"
|
||||
- name: "dependencies"
|
||||
|
||||
@@ -44,7 +44,9 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
run: "scripts/layered.sh --ignore-pnpmfile"
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
- name: Build Element Call
|
||||
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
|
||||
env:
|
||||
@@ -62,5 +64,6 @@ jobs:
|
||||
with:
|
||||
name: build-output-${{ inputs.package }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
# We'll only use this in a triggered job, then we're done with it
|
||||
retention-days: 1
|
||||
|
||||
@@ -73,7 +73,10 @@ jobs:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build_sdk_element_call:
|
||||
# Use the embedded package vite build
|
||||
# The 'sdk' package is the CallViewModel bundle that predates the sdk/
|
||||
# package (build:sdk:production, sdk-target-based-on-call-view-model/), not
|
||||
# the package itself. The name is shared with pr-deploy.yaml, which always
|
||||
# runs from main, so it stays until the bundle's consumers have moved over.
|
||||
uses: ./.github/workflows/build-element-call.yaml
|
||||
with:
|
||||
package: sdk
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Claude
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
# The action exchanges the GitHub OIDC token for a short-lived Anthropic
|
||||
# access token. GitHub API access goes through CLAUDE_GH_TOKEN instead.
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
review:
|
||||
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-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
# The review looks at the individual commits, not just the combined diff
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Review
|
||||
uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245
|
||||
with:
|
||||
anthropic_federation_rule_id: fdrl_01XQmWo4nka1ZTqZdsdeScyr
|
||||
anthropic_organization_id: dfbc9a23-d636-4653-8970-035204ef60fa
|
||||
anthropic_service_account_id: svac_018Ay5qaA7ZW8X7XsbdekqUM
|
||||
anthropic_workspace_id: wrkspc_01Aw8LHjBS6fZysu9yqzYifW
|
||||
# The token used here must grant read access to code, issues, and metadata as well as
|
||||
# read and write access to pull requests.
|
||||
github_token: ${{ secrets.CLAUDE_GH_TOKEN }}
|
||||
use_sticky_comment: true
|
||||
track_progress: true
|
||||
prompt: |
|
||||
Review this pull request under the following (and no further) angles:
|
||||
|
||||
1. Scope:
|
||||
|
||||
- Does the PR do one thing?
|
||||
- Is the PR free of unrelated fixes and drive-by refactorings?
|
||||
- If the PR is large, could it reasonably be split into smaller PRs
|
||||
that still deliver value?
|
||||
|
||||
2. Commits:
|
||||
|
||||
- Is the work split into self-contained commits of reasonable size?
|
||||
- Are the commits arranged in a sensible order?
|
||||
- Have required refactorings been split out into separate commits?
|
||||
- Is the history free of "wip", checkpoint and fixup-style commits
|
||||
which only correct an earlier commit in the same PR?
|
||||
|
||||
3. Code:
|
||||
|
||||
- Are the code changes functionally correct?
|
||||
- Do the changes introduce new security vulnerabilities or metadata
|
||||
leaks?
|
||||
- Is the new code performant enough?
|
||||
- Don't check anything that is already validated via other GitHub
|
||||
workflows like linting, formatting or tests passing.
|
||||
|
||||
Read the commits themselves with `git log` and `git diff` against
|
||||
the base branch of the PR.
|
||||
|
||||
Drop any findings that are outside the above review angles or that don't
|
||||
require resolution. Grade the severity of each finding as either "🟡 low",
|
||||
"🟠 medium" or "🔴 high". Devise at least one possible resolution for each
|
||||
finding.
|
||||
|
||||
Report the graded findings and resolutions from the "Code" angle as inline
|
||||
comments. Add a footer "_-- Finding from automated review_" to each inline
|
||||
comment.
|
||||
|
||||
Finally, report your overall result as PR comment. Start your message with
|
||||
"# Automated review". Then add one section per review angle with:
|
||||
|
||||
- A verdict of "✅ PASS" or "❌ NEEDS WORK"
|
||||
- A bullet list summarizing the remaining non-inlined graded findings and
|
||||
resolutions (as nested bullets).
|
||||
|
||||
Be brief and concise in all your communication. Do not mention what you have
|
||||
not covered and don't bring up issues that have already been fixed.
|
||||
|
||||
Use Markdown links for anything that can be navigated to on GitHub such
|
||||
as files, commits, issues, pull requests, workflow runs, etc.
|
||||
claude_args: |
|
||||
--model claude-fable-5-1
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git log:*),Bash(git diff:*),Bash(git show:*)"
|
||||
--max-turns 50
|
||||
@@ -1,6 +1,12 @@
|
||||
name: Lint, format & type check
|
||||
on:
|
||||
pull_request: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
matrix-js-sdk-sha:
|
||||
type: string
|
||||
required: false
|
||||
description: The matrix-js-sdk commit to build against instead of the locked one
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint, format & type check
|
||||
@@ -9,6 +15,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
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
|
||||
@@ -19,7 +27,10 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
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: Formatting
|
||||
run: "pnpm run format:check"
|
||||
- name: i18n
|
||||
|
||||
@@ -3,6 +3,25 @@ on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
matrix-js-sdk-sha:
|
||||
type: string
|
||||
required: false
|
||||
description: The matrix-js-sdk commit to build against instead of the locked one
|
||||
disable_coverage:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
description: Skip generating and uploading coverage reports
|
||||
disable_e2e_tests:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
description: Skip the end-to-end tests
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: false
|
||||
jobs:
|
||||
vitest:
|
||||
name: Run unit tests
|
||||
@@ -11,6 +30,8 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
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
|
||||
@@ -21,7 +42,10 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
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: Get Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
|
||||
- name: Cache Playwright binaries
|
||||
@@ -35,8 +59,11 @@ jobs:
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm playwright install --with-deps
|
||||
- name: Vitest
|
||||
run: "pnpm run test:coverage"
|
||||
run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE"
|
||||
env:
|
||||
ENABLE_COVERAGE: ${{ !inputs.disable_coverage }}
|
||||
- name: Upload to codecov
|
||||
if: ${{ !inputs.disable_coverage }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -45,11 +72,14 @@ jobs:
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests
|
||||
if: ${{ !inputs.disable_e2e_tests }}
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
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
|
||||
@@ -59,7 +89,10 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: pnpm install --frozen-lockfile --ignore-pnpmfile
|
||||
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: Get Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
|
||||
- name: Cache Playwright binaries
|
||||
|
||||
@@ -41,3 +41,6 @@ yarn-error.log
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# Nested checkout made by scripts/layered.sh
|
||||
/matrix-js-sdk/
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
],
|
||||
"element-call/no-observablescope-leak": "error",
|
||||
"element-call/no-top-level-logger-get-child": "error",
|
||||
"element-call/sdk-import-boundary": "error",
|
||||
"jsdoc/empty-tags": "error",
|
||||
"jsdoc/check-property-names": "error",
|
||||
"jsdoc/require-param-description": "warn",
|
||||
|
||||
@@ -34,16 +34,16 @@ You can find the latest development version continuously deployed to
|
||||
## ✨ Key Features
|
||||
|
||||
✅ **Decentralized & Federated** – No central authority; works across Matrix
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
|
||||
in Matrix clients as a widget, or (experimentally) mount it as a React component
|
||||
inside your own application.
|
||||
✅ **WebRTC-based** – No additional software required.
|
||||
inside your own application.
|
||||
✅ **WebRTC-based** – No additional software required.
|
||||
✅ **Scalable with LiveKit** – Supports large meetings via SFU
|
||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||
✅ **Raise Hand** – Participants can signal when they want to speak, helping to
|
||||
organize the flow of the meeting.
|
||||
organize the flow of the meeting.
|
||||
✅ **Emoji Reactions** – Users can react with emojis 👍️ 🎉 👏 🤘, adding
|
||||
engagement and interactivity to the conversation.
|
||||
|
||||
@@ -297,8 +297,8 @@ The package is not published yet. A host installs it as a git dependency on the
|
||||
```
|
||||
|
||||
whose `prepare` script runs the build on install. That build needs pnpm (via
|
||||
Corepack) on the host's machine, runs a full `pnpm install` of this repository
|
||||
and is memory-hungry, since it inherits the `--max-old-space-size` setting of
|
||||
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
|
||||
@@ -306,8 +306,28 @@ 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` and `livekit-client` itself, since the bundle leaves
|
||||
them external.
|
||||
`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)
|
||||
|
||||
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).
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:homeserver-1" # Cover all local users
|
||||
regex: "@.*:synapse\\.othersite\\.m\\.localhost" # Cover all local users
|
||||
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
|
||||
url: "http://auth-server-1:16080"
|
||||
# Enable membership look-ups via /is_joined.
|
||||
|
||||
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
regex: "@.*:homeserver" # Cover all local users
|
||||
regex: "@.*:synapse\\.m\\.localhost" # Cover all local users
|
||||
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
|
||||
url: "http://auth-server:6080"
|
||||
# Enable membership look-ups via /is_joined.
|
||||
|
||||
@@ -6,6 +6,11 @@ rtc:
|
||||
port_range_start: 50300
|
||||
port_range_end: 50400
|
||||
use_external_ip: false
|
||||
# Advertise the loopback address instead of the container IP, which is not
|
||||
# routable from the host when Docker runs in a VM (colima, Docker Desktop).
|
||||
# The browser then reaches the SFU through the published ports. Firefox
|
||||
# ignores loopback candidates, so this only works in Chromium-based browsers.
|
||||
node_ip: 127.0.0.1
|
||||
turn:
|
||||
enabled: false
|
||||
domain: localhost
|
||||
|
||||
@@ -6,6 +6,11 @@ rtc:
|
||||
port_range_start: 50100
|
||||
port_range_end: 50200
|
||||
use_external_ip: false
|
||||
# Advertise the loopback address instead of the container IP, which is not
|
||||
# routable from the host when Docker runs in a VM (colima, Docker Desktop).
|
||||
# The browser then reaches the SFU through the published ports. Firefox
|
||||
# ignores loopback candidates, so this only works in Chromium-based browsers.
|
||||
node_ip: 127.0.0.1
|
||||
turn:
|
||||
enabled: false
|
||||
domain: localhost
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
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 } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type ReactNode } from "react";
|
||||
import { Tooltip, TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { CallPortalRoot } from "./CallPortalRoot";
|
||||
|
||||
// A button whose accessible name comes from its tooltip, as every icon button
|
||||
// in the call does: the name is lost whenever the tooltip has nowhere to go.
|
||||
const LabelledButton = (): ReactNode => (
|
||||
<TooltipProvider>
|
||||
<Tooltip label="Microphone">
|
||||
<button>🎤</button>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
test("floats into the host document with no root", () => {
|
||||
render(
|
||||
<CallPortalRoot root={undefined}>
|
||||
<LabelledButton />
|
||||
</CallPortalRoot>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Microphone" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("floats into the root it is given", () => {
|
||||
const root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
render(
|
||||
<CallPortalRoot root={root}>
|
||||
<LabelledButton />
|
||||
</CallPortalRoot>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Microphone" });
|
||||
const label = document.getElementById(
|
||||
button.getAttribute("aria-labelledby")!,
|
||||
);
|
||||
expect(root.contains(label)).toBe(true);
|
||||
root.remove();
|
||||
});
|
||||
|
||||
test("waits for a root that is not available yet", () => {
|
||||
render(
|
||||
<CallPortalRoot root={null}>
|
||||
<LabelledButton />
|
||||
</CallPortalRoot>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Microphone" })).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
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, type ReactNode } from "react";
|
||||
import { PortalRoot } from "@vector-im/compound-web";
|
||||
|
||||
interface Props {
|
||||
root: HTMLElement | null | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Compound floats the call's menus and tooltips. With no `root` the call
|
||||
* is not wrapped at all and they go into the host document's body; `null` is
|
||||
* Compound's "not available yet", and the floating parts wait for it.
|
||||
*/
|
||||
export const CallPortalRoot: FC<Props> = ({ root, children }): ReactNode =>
|
||||
root === undefined ? (
|
||||
children
|
||||
) : (
|
||||
<PortalRoot root={root}>{children}</PortalRoot>
|
||||
);
|
||||
@@ -34,10 +34,18 @@ component rather than a page makes. */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Compound's overlay container, which holds tooltips and popovers, has to fill
|
||||
the container for the elements inside it to be positioned against it. The
|
||||
standalone page does the same for the container under `#root`. */
|
||||
.root > [data-overlay-container] {
|
||||
position: relative;
|
||||
/* The call itself. It is a stacking context of its own, as the standalone
|
||||
page's `#root` is, so that the layers the call view uses for its header, footer
|
||||
and overlays stay below the modals and toasts, which are portalled into the
|
||||
root and carry no layer of their own.
|
||||
|
||||
What Compound floats — tooltips, menus — is not in here at all: it goes into
|
||||
the host's body, as it would for any other component on the host's page, and is
|
||||
styled and layered by the host's copy of Compound. */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* What a host needs in order to talk about Element Call, without Element Call:
|
||||
* the component's props and the types they are made of, the handle and the
|
||||
* host bridge, the intents a call can be started with and the configuration
|
||||
* each implies.
|
||||
*
|
||||
* This is an entry point of its own, `@element-hq/element-call-component/api`,
|
||||
* so that a host can import it on a path that must stay light — a call model
|
||||
* that needs `BackgroundStyle.Solid` as a value, say — and load the component
|
||||
* itself, and everything it brings, only when a call is shown. The main entry
|
||||
* point re-exports all of it, so a host that does not care may import
|
||||
* everything from there.
|
||||
*
|
||||
* Nothing in here may reach anything that is not a type or a constant: the
|
||||
* point of this module is what it does not import.
|
||||
*/
|
||||
|
||||
import type { Ref } from "react";
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import type { UrlProperties } from "../src/UrlParams";
|
||||
import type { ElementCallHandle, ElementCallHostBridge } from "./host";
|
||||
import {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
} from "../src/UrlConfiguration";
|
||||
|
||||
// How the host and Element Call talk to each other, and what they say
|
||||
export type { ElementCallHandle, ElementCallHostBridge } from "./host";
|
||||
export type { DeviceMuteRequest, DeviceMuteState } from "../src/HostBridge";
|
||||
export type { JoinCallData } from "../src/widget";
|
||||
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||
// below, which is per call
|
||||
export type { ConfigOptions } from "../src/config/ConfigOptions";
|
||||
// The values that appear in ElementCallConfiguration and in the intent, and
|
||||
// what each intent means by default
|
||||
export {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
};
|
||||
|
||||
/**
|
||||
* How Element Call should behave. Everything is optional; anything left out
|
||||
* takes the default that {@link ElementCallProps.intent} implies.
|
||||
*
|
||||
* This is the behaviour a widget can be configured with through its URL, plus
|
||||
* the one fact about the call a host has a say in here, the background. The
|
||||
* rest of what a widget's URL carries — who the user is, how to reach the
|
||||
* homeserver, where to report analytics, the shared secret of a room that is
|
||||
* encrypted with one — a component host supplies by other routes, or not at
|
||||
* all; and what can change while the call is running, the theme and the
|
||||
* language, is a prop of its own.
|
||||
*/
|
||||
export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||
Partial<Pick<UrlProperties, "background">>;
|
||||
|
||||
export interface ElementCallProps {
|
||||
/**
|
||||
* The client to place the call with. Element Call does not authenticate
|
||||
* anyone or manage a session of its own; this one is the host's.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
/**
|
||||
* What the user asked for — whether they started the call or joined one that
|
||||
* was already running, and whether it is a call in a group or a DM. Element
|
||||
* Call decides what each of those means: whether to show the lobby first,
|
||||
* whether to ring, and so on.
|
||||
*
|
||||
* Defaults to joining an existing group call, which is the most conservative
|
||||
* reading, but a host that knows which button the user pressed should say so.
|
||||
*/
|
||||
intent?: UserIntent;
|
||||
/**
|
||||
* How Element Call should behave, overriding whatever {@link intent} implies.
|
||||
* A host that finds itself setting a lot of these probably wants a different
|
||||
* intent instead.
|
||||
*
|
||||
* Compared by value, so it is fine to write this inline; only a change to
|
||||
* what it says restarts anything.
|
||||
*/
|
||||
config?: ElementCallConfiguration;
|
||||
/**
|
||||
* What Element Call tells the host while the call is running: that the user
|
||||
* has joined or hung up, that it would like to be kept on screen, and so on.
|
||||
* Without one, Element Call assumes nobody is listening.
|
||||
*/
|
||||
hostBridge?: ElementCallHostBridge;
|
||||
/**
|
||||
* What the host tells Element Call: to hang up, to mute, to join. Available
|
||||
* once the component has rendered.
|
||||
*/
|
||||
ref?: Ref<ElementCallHandle>;
|
||||
/**
|
||||
* The theme to show Element Call in, `light` or `dark`. Left out, Element
|
||||
* Call picks. Changes take effect at once, and cost nothing else.
|
||||
*/
|
||||
theme?: string;
|
||||
/**
|
||||
* The language to show Element Call in, as a BCP 47 tag: one of the
|
||||
* `supportedLanguages` the main entry point exports, or something that falls
|
||||
* back to one (`de-AT` to `de`). Left out, the browser's language is used.
|
||||
*
|
||||
* Translations are one thing shared by every Element Call on the page, so
|
||||
* the most recently set language wins for all of them.
|
||||
*/
|
||||
language?: string;
|
||||
/**
|
||||
* Where the parts of Element Call that float above it — its menus and
|
||||
* tooltips, which Compound portals out of the call — are rendered. Left out,
|
||||
* they go into the host document's body, as they do for any other component
|
||||
* on the host's page.
|
||||
*
|
||||
* A host that moves Element Call's DOM into another document, such as a
|
||||
* Document Picture-in-Picture window, passes that document's body here so
|
||||
* that a menu opens in the window its button is in. Changes take effect at
|
||||
* once. `null` means the root is not available yet, as from a callback ref
|
||||
* that has not fired: menus and tooltips wait for it rather than falling
|
||||
* back to the body.
|
||||
*/
|
||||
portalRoot?: HTMLElement | null;
|
||||
}
|
||||
@@ -40,10 +40,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
* Element Call as a component.
|
||||
*
|
||||
* As a page of its own, Element Call can style the document: normalize.css and
|
||||
* Compound speak of `html`, `body` and bare elements, and the design tokens are
|
||||
* declared on `:root`. As a component, all of that would land on the host's
|
||||
* document too. This rewrites every selector so that it matches only the root
|
||||
* or its descendants:
|
||||
* its own base styles speak of `html`, `body` and bare elements, and its custom
|
||||
* properties are declared on `:root`. As a component, all of that would land on
|
||||
* the host's document too. This rewrites every selector so that it matches only
|
||||
* the root or its descendants:
|
||||
*
|
||||
* - `html`, `body` and `:root` become the root element, which is what stands in
|
||||
* for the document inside a host.
|
||||
@@ -55,9 +55,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
* CSS modules are scoped by their class names already, so only their selectors
|
||||
* that would match by element alone — `pre` rather than `.pre` — are touched.
|
||||
*
|
||||
* The root's fonts and design tokens are still inherited by everything inside
|
||||
* it, the way they were from `body` and `:root`, and `@font-face` declarations
|
||||
* stay global, which they are by nature.
|
||||
* The root's custom properties are still inherited by everything inside it,
|
||||
* the way they were from `:root`. Compound's own stylesheet — the design
|
||||
* tokens, the component styles, the fonts — is not in this build at all: the
|
||||
* host supplies it, unscoped, along with its copy of Compound.
|
||||
*/
|
||||
export function scopeStylesToRoot(): Plugin {
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,16 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
// What a host provides along with its copy of Compound: its stylesheet, the
|
||||
// design tokens and the fonts they name. The component does not bundle them.
|
||||
import "@fontsource/inter/400.css";
|
||||
import "@fontsource/inter/500.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
import "@fontsource/inter/700.css";
|
||||
import "@fontsource/inconsolata/400.css";
|
||||
import "@fontsource/inconsolata/700.css";
|
||||
import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css";
|
||||
import "@vector-im/compound-web/dist/style.css";
|
||||
import { type ConfigOptions, initializeElementCall } from "../index";
|
||||
import { Harness } from "./Harness";
|
||||
// After Element Call's, so that the host has the last word on its own page
|
||||
|
||||
+51
-127
@@ -17,33 +17,37 @@ Please see LICENSE in the repository root for full details.
|
||||
* host instead, or is confined to the container it is mounted in.
|
||||
*/
|
||||
|
||||
// The design tokens, fonts and element defaults every Element Call stylesheet
|
||||
// The element defaults and custom properties every Element Call stylesheet
|
||||
// builds on. Written for a page, they speak of `html`, `body` and bare
|
||||
// elements; the component build confines them, and every other stylesheet in
|
||||
// this bundle, to the root element below (see build/scopeStylesToRoot.ts), so
|
||||
// that the host's document is left as it was.
|
||||
//
|
||||
// Compound is not in here. The component uses the host's copy of it (a peer
|
||||
// dependency, left external by the build), and the host loads Compound's
|
||||
// stylesheet, design tokens and fonts along with it: what Compound floats into
|
||||
// the host's body — tooltips, menus — is then styled by the same stylesheet as
|
||||
// everything else there, and Element Call's root takes the tokens and fonts of
|
||||
// the theme class it carries, as any element on the host's page would.
|
||||
//
|
||||
// Where these land relative to the component stylesheets is the bundler's
|
||||
// choice — the standalone app puts them first, this build puts them in the
|
||||
// middle — so nothing in base.css may depend on winning or losing against a
|
||||
// component's own rules at equal specificity. It currently does not: what it
|
||||
// declares unlayered is custom properties on Element Call's root, which
|
||||
// components inherit rather than compete with, and everything from Compound
|
||||
// sits in a `@layer`, which loses to unlayered rules either way.
|
||||
// components inherit rather than compete with.
|
||||
import "../src/base.css";
|
||||
|
||||
import {
|
||||
type FC,
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
@@ -62,10 +66,8 @@ import { RootElementProvider, useRootElement } from "../src/RootElementContext";
|
||||
import {
|
||||
configurationForIntent,
|
||||
componentProperties,
|
||||
type UrlConfiguration,
|
||||
type UrlParams,
|
||||
UrlParamsProvider,
|
||||
type UrlProperties,
|
||||
UserIntent,
|
||||
useUrlParams,
|
||||
} from "../src/UrlParams";
|
||||
@@ -79,102 +81,17 @@ import { i18n } from "../src/utils/i18n";
|
||||
import { useTheme } from "../src/useTheme";
|
||||
import { useStableValue } from "../src/useStableValue";
|
||||
import styles from "./ElementCall.module.css";
|
||||
import {
|
||||
type ElementCallHandle,
|
||||
type ElementCallHostBridge,
|
||||
useComponentHostBridge,
|
||||
} from "./host";
|
||||
import { useComponentHostBridge } from "./host";
|
||||
import { CallPortalRoot } from "./CallPortalRoot";
|
||||
import { type ElementCallProps } from "./api";
|
||||
import { supportedLanguages, translationsBackend } from "./localization";
|
||||
|
||||
// The languages Element Call can be shown in
|
||||
export { supportedLanguages } from "./localization";
|
||||
|
||||
// How the host and Element Call talk to each other, and what they say
|
||||
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
|
||||
export {
|
||||
type DeviceMuteRequest,
|
||||
type DeviceMuteState,
|
||||
} from "../src/HostBridge";
|
||||
export { type JoinCallData } from "../src/widget";
|
||||
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||
// above, which is per call
|
||||
export { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
// The values that appear in ElementCallConfiguration and in the intent
|
||||
export {
|
||||
BackgroundStyle,
|
||||
HeaderStyle,
|
||||
UserIntent,
|
||||
type UrlConfiguration,
|
||||
} from "../src/UrlParams";
|
||||
|
||||
/**
|
||||
* How Element Call should behave. Everything is optional; anything left out
|
||||
* takes the default that {@link ElementCallProps.intent} implies.
|
||||
*
|
||||
* This is the behaviour a widget can be configured with through its URL, plus
|
||||
* the one fact about the call a host has a say in here, the background. The
|
||||
* rest of what a widget's URL carries — who the user is, how to reach the
|
||||
* homeserver, where to report analytics, the shared secret of a room that is
|
||||
* encrypted with one — a component host supplies by other routes, or not at
|
||||
* all; and what can change while the call is running, the theme and the
|
||||
* language, is a prop of its own.
|
||||
*/
|
||||
export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||
Partial<Pick<UrlProperties, "background">>;
|
||||
|
||||
export interface ElementCallProps {
|
||||
/**
|
||||
* The client to place the call with. Element Call does not authenticate
|
||||
* anyone or manage a session of its own; this one is the host's.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
/**
|
||||
* What the user asked for — whether they started the call or joined one that
|
||||
* was already running, and whether it is a call in a group or a DM. Element
|
||||
* Call decides what each of those means: whether to show the lobby first,
|
||||
* whether to ring, and so on.
|
||||
*
|
||||
* Defaults to joining an existing group call, which is the most conservative
|
||||
* reading, but a host that knows which button the user pressed should say so.
|
||||
*/
|
||||
intent?: UserIntent;
|
||||
/**
|
||||
* How Element Call should behave, overriding whatever {@link intent} implies.
|
||||
* A host that finds itself setting a lot of these probably wants a different
|
||||
* intent instead.
|
||||
*
|
||||
* Compared by value, so it is fine to write this inline; only a change to
|
||||
* what it says restarts anything.
|
||||
*/
|
||||
config?: ElementCallConfiguration;
|
||||
/**
|
||||
* What Element Call tells the host while the call is running: that the user
|
||||
* has joined or hung up, that it would like to be kept on screen, and so on.
|
||||
* Without one, Element Call assumes nobody is listening.
|
||||
*/
|
||||
hostBridge?: ElementCallHostBridge;
|
||||
/**
|
||||
* What the host tells Element Call: to hang up, to mute, to join. Available
|
||||
* once the component has rendered.
|
||||
*/
|
||||
ref?: Ref<ElementCallHandle>;
|
||||
/**
|
||||
* The theme to show Element Call in, `light` or `dark`. Left out, Element
|
||||
* Call picks. Changes take effect at once, and cost nothing else.
|
||||
*/
|
||||
theme?: string;
|
||||
/**
|
||||
* The language to show Element Call in, as a BCP 47 tag: one of
|
||||
* {@link supportedLanguages}, or something that falls back to one (`de-AT`
|
||||
* to `de`). Left out, the browser's language is used.
|
||||
*
|
||||
* Translations are one thing shared by every Element Call on the page, so
|
||||
* the most recently set language wins for all of them.
|
||||
*/
|
||||
language?: string;
|
||||
}
|
||||
// Everything a host needs to talk about Element Call — the props, the handle,
|
||||
// the host bridge, the intents and what each means — also available without
|
||||
// the component itself from `@element-hq/element-call-component/api`
|
||||
export * from "./api";
|
||||
|
||||
/**
|
||||
* Prepares the things Element Call needs before it can be shown: translations,
|
||||
@@ -238,6 +155,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
ref,
|
||||
theme,
|
||||
language,
|
||||
portalRoot,
|
||||
}): ReactNode => {
|
||||
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
|
||||
|
||||
@@ -321,34 +239,40 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
<UrlParamsProvider value={params}>
|
||||
<div ref={setContainer} className={styles.root}>
|
||||
{ready && (
|
||||
<RootElementProvider value={container}>
|
||||
{/* Whatever goes wrong in here is shown in here. Left to
|
||||
propagate, an error would unmount the host's own tree. */}
|
||||
<ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
// A broken call should not hold the host on screen
|
||||
onError={() => void hostBridge.setAlwaysOnScreen(false)}
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={params.confineToRoom}
|
||||
preload={params.preload}
|
||||
skipLobby={params.skipLobby}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
</TooltipProvider>
|
||||
</Decoration>
|
||||
</ErrorBoundary>
|
||||
</RootElementProvider>
|
||||
// Stands in for the standalone page's `#root`: see `.content`
|
||||
// in ElementCall.module.css
|
||||
<div className={styles.content}>
|
||||
<CallPortalRoot root={portalRoot}>
|
||||
<RootElementProvider value={container}>
|
||||
{/* Whatever goes wrong in here is shown in here. Left to
|
||||
propagate, an error would unmount the host's own tree. */}
|
||||
<ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
// A broken call should not hold the host on screen
|
||||
onError={() => void hostBridge.setAlwaysOnScreen(false)}
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<CallView
|
||||
client={client}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={params.confineToRoom}
|
||||
preload={params.preload}
|
||||
skipLobby={params.skipLobby}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
</TooltipProvider>
|
||||
</Decoration>
|
||||
</ErrorBoundary>
|
||||
</RootElementProvider>
|
||||
</CallPortalRoot>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</UrlParamsProvider>
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"types": "./dist/types/component/index.d.ts",
|
||||
"default": "./dist/element-call.js"
|
||||
},
|
||||
"./api": {
|
||||
"types": "./dist/types/component/api.d.ts",
|
||||
"default": "./dist/api.js"
|
||||
},
|
||||
"./style.css": "./dist/element-call.css"
|
||||
},
|
||||
"sideEffects": [
|
||||
@@ -34,7 +38,8 @@
|
||||
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"livekit-client": "^2.18.1",
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-web": "^10.3.1",
|
||||
"matrix-js-sdk": "*",
|
||||
"react": "^19",
|
||||
"react-dom": "^19"
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"rootDir": "..",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
// The entry point, plus the ambient declarations (CSS modules, `?react` SVGs,
|
||||
// `import.meta.env`, …) that the sources it reaches rely on.
|
||||
"include": ["./index.tsx", "../src/@types/*.d.ts"],
|
||||
// The entry points, plus the ambient declarations (CSS modules, `?react` SVGs,
|
||||
// `import.meta.env`, …) that the sources they reach rely on.
|
||||
"include": ["./index.tsx", "./api.ts", "../src/@types/*.d.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_mode": "compatibility",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.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.7.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:cfbe7ceb346918257ef07cb25f7b9b03ec0575ecb5ce51dfeeef873e67c60c95
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:e464b2aca73e248195b24b8c6f62cd331100302284d60cc735405807fb2730be
|
||||
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:cfbe7ceb346918257ef07cb25f7b9b03ec0575ecb5ce51dfeeef873e67c60c95
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:e464b2aca73e248195b24b8c6f62cd331100302284d60cc735405807fb2730be
|
||||
|
||||
@@ -72,8 +72,24 @@ Code that builds in only one is a bug.
|
||||
|
||||
- `build:full` — standalone app, also widget mode.
|
||||
- `build:embedded` — `@element-hq/element-call-embedded`.
|
||||
- `build:sdk` — SDK library, entry `sdk/main.ts`.
|
||||
- `build:sdk` — `@element-hq/matrixrtc-sdk`, sources in `sdk/`, entry `sdk/index.ts`.
|
||||
Packaged exactly like the component (own pnpm project, run pnpm from the repo
|
||||
root, types from `sdk/tsconfig.build.json`, externals checked by
|
||||
`pnpm lint:externals`). Interface only so far; the design is `sdk-plan.md`.
|
||||
- `build:sdk-target-based-on-call-view-model` — the SDK demo that predates the
|
||||
package, one bundle of the `CallViewModel`; entry
|
||||
`sdk-target-based-on-call-view-model/main.ts`. CI's `sdk` package
|
||||
(`build:sdk:production` / `build:sdk:development`, the Netlify `-sdk` preview)
|
||||
is still this bundle, not `build:sdk`.
|
||||
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
|
||||
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
|
||||
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
|
||||
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
|
||||
`matrix-js-sdk` / `@vector-im/compound-web` /
|
||||
`@vector-im/compound-design-tokens` subpath the externals list omits.
|
||||
`livekit-client` is bundled on purpose: no host imports it, and the pinned
|
||||
version travels with the component.
|
||||
Compound's stylesheets are loaded by `src/index.css` for the page and by the
|
||||
host for the component, never by `src/base.css`. Two entry
|
||||
points: `index.tsx` (the component) and `api.ts` (types, enums and
|
||||
`configurationForIntent`, for a host that must not load the component). `api.ts`
|
||||
may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`.
|
||||
|
||||
@@ -39,6 +39,9 @@ translations. The browser is Playwright's, so a fresh clone needs
|
||||
- `playwright/component/` — the component in a host page, via the harness on port
|
||||
3001 that Playwright starts as a second web server. Catches container-relative
|
||||
layout, styles escaping the root, two instances on a page, host-bridge reports.
|
||||
- `playwright/sdk/` — the MatrixRTC SDK through its harness on port 3002, a page
|
||||
with none of Element Call on it. The smoke test is what the implementation is
|
||||
built against.
|
||||
- `playwright/mobile/` — Pixel 7, `mobile` project only.
|
||||
|
||||
Test what a user observes: the peer sees the change, it survives a reconnect, it is
|
||||
@@ -50,4 +53,5 @@ pnpm test # unit + storybook
|
||||
pnpm backend # Synapse + LiveKit, required for e2e
|
||||
pnpm test:playwright # or :open
|
||||
pnpm dev:component # component harness, port 3001
|
||||
pnpm dev:sdk # SDK harness, port 3002
|
||||
```
|
||||
|
||||
@@ -60,6 +60,21 @@ git config --local core.hooksPath .githooks
|
||||
|
||||
This will add the hook path for this repository only to .gihooks. which is a tracked (by git) folder containing the pre-commit hook.
|
||||
|
||||
## Testing against a matrix-js-sdk branch in CI
|
||||
|
||||
Linking covers local development. In CI, `scripts/layered.sh` plays the same
|
||||
role for `matrix-js-sdk`: after the usual frozen install, `scripts/fetchdep.sh`
|
||||
looks for a matrix-js-sdk branch with the same name as the pull request's head
|
||||
branch (in the fork first, when the pull request comes from one). If there is
|
||||
one, it is cloned, packed and installed over the locked version. If there is
|
||||
none, the install stays as `pnpm-lock.yaml` pins it.
|
||||
|
||||
This means a change that needs a not yet merged matrix-js-sdk change can be tested
|
||||
by giving both branches the same name. This mirrors how element-web tests against
|
||||
matrix-js-sdk. Once the matrix-js-sdk branch is merged and deleted, the pull
|
||||
request falls back to the lockfile and needs a lockfile bump to stay green,
|
||||
which is also what keeps `main` consistent after the merge.
|
||||
|
||||
## Background
|
||||
|
||||
Information, why this approach is used can be found in the [linking concept reasoning](./linking_concept_reasoning.md) document.
|
||||
|
||||
@@ -24,7 +24,7 @@ homeserver does not support sticky events. This is the default.
|
||||
MSC4354 and all clients on v0.17.0 or later. The local membership requests its
|
||||
token from the Matrix 2.0 JWT endpoint of the
|
||||
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
||||
and identifies the room by a hashed identity instead of a `livekit_alias`.
|
||||
and identifies the room by a hashed identity instead of the Matrix `room_id`.
|
||||
(Remote memberships always try the new endpoint first and fall back to the
|
||||
legacy one, so remote participants can be on either.)
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st
|
||||
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
|
||||
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
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 { dirname, resolve, sep } from "node:path";
|
||||
import { ESLintUtils } from "@typescript-eslint/utils";
|
||||
|
||||
/**
|
||||
* Packages a host supplies and the SDK must not know about: UI, i18n and
|
||||
* Element Call's design system.
|
||||
*/
|
||||
const BANNED_PACKAGES = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-i18next",
|
||||
"i18next",
|
||||
"@vector-im/",
|
||||
];
|
||||
|
||||
/**
|
||||
* Packages only a media backend may import. The session speaks MatrixRTC and
|
||||
* sees media through `sdk/src/media-backend/api.ts`; a backend under
|
||||
* `sdk/src/media-backend/<name>/` is the one place its protocol's library appears.
|
||||
*/
|
||||
const BACKEND_PACKAGES = ["livekit-client", "@livekit/"];
|
||||
|
||||
/**
|
||||
* Keeps the SDK free of Element Call: nothing under `sdk/` may import a file
|
||||
* outside `sdk/` by relative path (that is `src/`, `component/`,
|
||||
* `playwright/`) or one of the packages above. The development harness under
|
||||
* `sdk/dev/` is a host, not part of the library, and is exempt. Keeps the
|
||||
* session free of any one media backend, too: outside `sdk/src/media-backend/`,
|
||||
* a backend's packages may only be imported for their types, and only in
|
||||
* the public API, the errors and the test helpers that have to name them.
|
||||
*/
|
||||
const rule = ESLintUtils.RuleCreator(
|
||||
() => "https://github.com/element-hq/element-call",
|
||||
)({
|
||||
name: "sdk-import-boundary",
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow the SDK importing Element Call (anything outside sdk/) or host-supplied packages.",
|
||||
},
|
||||
messages: {
|
||||
outsideSdk:
|
||||
"The SDK must not import '{{specifier}}': it resolves outside sdk/. Copy or move what it needs into sdk/src.",
|
||||
bannedPackage:
|
||||
"The SDK must not import '{{specifier}}': a host supplies that, the SDK has no UI.",
|
||||
backendPackage:
|
||||
"Only a media backend under sdk/src/media-backend/ may import '{{specifier}}'; the session sees media through media-backend/api.ts.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
const filename = context.filename;
|
||||
const sdkRoot = resolve(context.cwd, "sdk") + sep;
|
||||
if (!filename.startsWith(sdkRoot)) return {};
|
||||
if (filename.startsWith(resolve(sdkRoot, "dev") + sep)) return {};
|
||||
const inBackend = filename.startsWith(
|
||||
resolve(sdkRoot, "src", "media-backend") + sep,
|
||||
);
|
||||
|
||||
const matchesPackage = (specifier, pkg) =>
|
||||
specifier === pkg ||
|
||||
specifier.startsWith(pkg.endsWith("/") ? pkg : `${pkg}/`);
|
||||
|
||||
const check = (node, specifier, typeOnly = false) => {
|
||||
if (typeof specifier !== "string") return;
|
||||
if (
|
||||
!inBackend &&
|
||||
!typeOnly &&
|
||||
BACKEND_PACKAGES.some((pkg) => matchesPackage(specifier, pkg))
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "backendPackage",
|
||||
data: { specifier },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (specifier.startsWith(".")) {
|
||||
const target = resolve(dirname(filename), specifier);
|
||||
if (!target.startsWith(sdkRoot))
|
||||
context.report({
|
||||
node,
|
||||
messageId: "outsideSdk",
|
||||
data: { specifier },
|
||||
});
|
||||
} else if (
|
||||
BANNED_PACKAGES.some((pkg) => matchesPackage(specifier, pkg))
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: "bannedPackage",
|
||||
data: { specifier },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
check(
|
||||
node,
|
||||
node.source.value,
|
||||
node.importKind === "type" ||
|
||||
node.specifiers.every((s) => s.importKind === "type"),
|
||||
);
|
||||
},
|
||||
ExportNamedDeclaration(node) {
|
||||
if (node.source) check(node, node.source.value);
|
||||
},
|
||||
ExportAllDeclaration(node) {
|
||||
check(node, node.source.value);
|
||||
},
|
||||
ImportExpression(node) {
|
||||
if (node.source.type === "Literal") check(node, node.source.value);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default rule;
|
||||
@@ -4,5 +4,6 @@ module.exports = {
|
||||
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
|
||||
"no-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
|
||||
.default,
|
||||
"sdk-import-boundary": require("./SdkImportBoundary").default,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ export default {
|
||||
"vite.config.ts",
|
||||
"vite-embedded.config.ts",
|
||||
"vite-sdk.config.ts",
|
||||
"vite-sdk-dev.config.ts",
|
||||
"vite-sdk-target-based-on-call-view-model.config.ts",
|
||||
"vite-component.config.ts",
|
||||
"vite-component-dev.config.ts",
|
||||
],
|
||||
|
||||
+3
-3
@@ -59,10 +59,10 @@
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
|
||||
"crypto_version": "Crypto version: {{version}}",
|
||||
"custom_livekit_url": {
|
||||
"current_url": "Currently set to: ",
|
||||
"custom_transport": {
|
||||
"current": "Currently set to: ",
|
||||
"from_config": "Currently, no overwrite is set. Url from config is used.",
|
||||
"label": "Custom Livekit-url",
|
||||
"label": "Custom transport JSON",
|
||||
"reset": "Reset overwrite",
|
||||
"save": "Save",
|
||||
"saving": "Saving..."
|
||||
|
||||
+11
-8
@@ -7,16 +7,20 @@
|
||||
"dev:full": "vite",
|
||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||
"dev:sdk": "vite --config vite-sdk-dev.config.ts",
|
||||
"build": "pnpm build:full",
|
||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
|
||||
"build:full:production": "pnpm build:full",
|
||||
"build:full:development": "pnpm build:full --mode development",
|
||||
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
||||
"build:embedded:production": "pnpm build:embedded",
|
||||
"build:embedded:development": "pnpm build:embedded --mode development",
|
||||
"build:sdk:development": "pnpm build:sdk --mode development",
|
||||
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
|
||||
"build:sdk:production": "pnpm build:sdk",
|
||||
"build:sdk": "pnpm build:sdk:js && pnpm build:sdk:types",
|
||||
"build:sdk:js": "pnpm build:full --config vite-sdk.config.js",
|
||||
"build:sdk:types": "tsc -p sdk/tsconfig.build.json",
|
||||
"build:sdk-target-based-on-call-view-model": "pnpm build:full --config vite-sdk-target-based-on-call-view-model.config.js",
|
||||
"build:sdk:production": "pnpm build:sdk-target-based-on-call-view-model",
|
||||
"build:sdk:development": "pnpm build:sdk-target-based-on-call-view-model --mode development",
|
||||
"build:component": "pnpm build:component:js && pnpm build:component:types",
|
||||
"build:component:js": "pnpm build:full --config vite-component.config.js",
|
||||
"build:component:types": "tsc -p component/tsconfig.build.json",
|
||||
@@ -26,8 +30,8 @@
|
||||
"format": "oxfmt",
|
||||
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
|
||||
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
|
||||
"lint:oxlint": "oxlint src component playwright",
|
||||
"lint:oxlint-fix": "oxlint --fix src component playwright",
|
||||
"lint:oxlint": "oxlint src component sdk playwright",
|
||||
"lint:oxlint-fix": "oxlint --fix src component sdk playwright",
|
||||
"lint:knip": "knip",
|
||||
"lint:externals": "node scripts/check-component-externals.mjs",
|
||||
"lint:types": "tsc",
|
||||
@@ -84,7 +88,7 @@
|
||||
"@typescript-eslint/utils": "^8.61.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^11.0.0",
|
||||
"@vector-im/compound-web": "^10.0.0",
|
||||
"@vector-im/compound-web": "^10.4.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
@@ -129,7 +133,6 @@
|
||||
"uuid": "^14.0.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-polyfills": "^0.28.0",
|
||||
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||
|
||||
+27
-2
@@ -12,6 +12,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
|
||||
import { SDK_HARNESS_URL } from "./playwright/sdk/harness.ts";
|
||||
|
||||
const baseURL = process.env.USE_DOCKER
|
||||
? "http://localhost:8080"
|
||||
@@ -38,8 +39,20 @@ export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/**
|
||||
* 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; `html` is uploaded as
|
||||
* an artifact and must never try to open a browser there.
|
||||
*/
|
||||
reporter: process.env.CI
|
||||
? [["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,
|
||||
/* Under the job's 60 minute limit, so a hung run still ends with a report. */
|
||||
globalTimeout: process.env.CI ? 50 * 60 * 1000 : undefined,
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
@@ -146,5 +159,17 @@ export default defineConfig({
|
||||
timeout: 500,
|
||||
},
|
||||
},
|
||||
{
|
||||
// The harness that uses the MatrixRTC SDK without Element Call. A Vite
|
||||
// dev server for the same reason as the component's.
|
||||
command: "pnpm dev:sdk",
|
||||
url: SDK_HARNESS_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
ignoreHTTPSErrors: true,
|
||||
gracefulShutdown: {
|
||||
signal: "SIGTERM",
|
||||
timeout: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
|
||||
.getByRole("button", { name: "Microphone" })
|
||||
.click({ timeout: 60_000 });
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
const list = page.locator("[role='menu'] div[role='none']").first();
|
||||
const list = page.getByRole("menu").getByTestId("device-list");
|
||||
await expect(list).toBeVisible();
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,12 @@ export async function expectWithin(
|
||||
outer: Locator,
|
||||
): Promise<void> {
|
||||
await expect(inner).toBeVisible();
|
||||
// Wait for animations to settle.
|
||||
await inner.evaluate(async (element) => {
|
||||
for (const animation of element.getAnimations({ subtree: true }))
|
||||
await animation.finished;
|
||||
});
|
||||
|
||||
const innerBox = await inner.boundingBox();
|
||||
const outerBox = await outer.boundingBox();
|
||||
if (innerBox === null || outerBox === null)
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { createJTWToken } from "./fixtures/jwt-token";
|
||||
import { createJWTToken } from "./fixtures/jwt-token";
|
||||
|
||||
test("Should show error screen if fails to get JWT token", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
@@ -95,7 +95,7 @@ test("Should show error screen if call creation is restricted", async ({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
url: "wss://badurltotricktest/livekit/sfu",
|
||||
jwt: createJTWToken("@fake:user", "!fake:room"),
|
||||
jwt: createJWTToken("@fake:user", "!fake:room"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
export function createJTWToken(sub: string, room: string): string {
|
||||
export function createJWTToken(sub: string, room: string): string {
|
||||
return [
|
||||
{}, // header
|
||||
{
|
||||
|
||||
@@ -132,7 +132,7 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
|
||||
await ewPage1
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
|
||||
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { startPair, tileOf } from "./harness";
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test("a message sent beside the media reaches the peer, and only the peer", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { pages, users } = await startPair(browser, "sdkdata");
|
||||
const [sender, receiver] = pages;
|
||||
// Both are in the call before anything is sent, so the receiver knows the
|
||||
// sender as a member and does not drop the message
|
||||
for (const page of pages)
|
||||
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(tileOf(receiver, users[0])).toBeVisible();
|
||||
|
||||
await sender.getByPlaceholder("message").fill("hello from the SDK");
|
||||
await sender.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
const message = receiver.getByTestId("message");
|
||||
await expect(message).toHaveText(
|
||||
`${users[0].displayName}: hello from the SDK`,
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
await expect(message).toHaveAttribute("data-topic", "chat");
|
||||
await expect(message).toHaveAttribute("data-user-id", users[0].userId);
|
||||
// Our own messages are not echoed back
|
||||
await expect(sender.getByTestId("message")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
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 Browser,
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
import { SynapseAdmin } from "../utils/synapse-admin.ts";
|
||||
|
||||
/**
|
||||
* Where the SDK harness is served — `sdk/dev`, a page that uses the SDK the way
|
||||
* a host would, with none of Element Call in it.
|
||||
*/
|
||||
export const SDK_HARNESS_URL = "https://localhost:3002";
|
||||
|
||||
const HOMESERVER_URL = "https://synapse.m.localhost";
|
||||
const PASSWORD = "foobarbaz1!";
|
||||
|
||||
/** A registered user, with the session the registration opened. */
|
||||
export interface User {
|
||||
username: string;
|
||||
displayName: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers two users through the Synapse admin API and has the first create a
|
||||
* public room, so that the second can join it by id without an invite. The
|
||||
* registration logs each user in, which spares the harness a `/login` call
|
||||
* that the homeserver rate-limits.
|
||||
*/
|
||||
export async function createUsersAndRoom(
|
||||
name: string,
|
||||
): Promise<{ users: [User, User]; roomId: string }> {
|
||||
const admin = SynapseAdmin.forHomeserver(HOMESERVER_URL);
|
||||
const users = (await Promise.all(
|
||||
["a", "b"].map(async (letter, index): Promise<User> => {
|
||||
const username = `${name}_${letter}_${Date.now()}`;
|
||||
const displayName = `${name} ${"AB"[index]}`;
|
||||
const registration = await admin.registerUser(
|
||||
username,
|
||||
PASSWORD,
|
||||
displayName,
|
||||
);
|
||||
return {
|
||||
username,
|
||||
displayName,
|
||||
userId: registration.user_id,
|
||||
deviceId: registration.device_id,
|
||||
accessToken: registration.access_token,
|
||||
};
|
||||
}),
|
||||
)) as [User, User];
|
||||
|
||||
const response = await fetch(
|
||||
`${HOMESERVER_URL}/_matrix/client/v3/createRoom`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${users[0].accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `${name}'s session`,
|
||||
preset: "public_chat",
|
||||
// Per-participant media keys travel in encrypted to-device messages,
|
||||
// which only reach devices the sender's crypto tracks, and it tracks
|
||||
// the members of encrypted rooms
|
||||
initial_state: [
|
||||
{
|
||||
type: "m.room.encryption",
|
||||
state_key: "",
|
||||
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||
},
|
||||
],
|
||||
// Every member has to be allowed to write its own membership, which
|
||||
// a public room does not grant by default; the same levels Element
|
||||
// Call gives a room it creates for a call
|
||||
power_level_content_override: {
|
||||
state_default: 0,
|
||||
events_default: 0,
|
||||
users_default: 0,
|
||||
events: { "org.matrix.msc3401.call.member": 0 },
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Could not create a room: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
const { room_id: roomId } = (await response.json()) as { room_id: string };
|
||||
|
||||
return { users, roomId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the harness signed in as the given user and waits until the SDK
|
||||
* reports the session as connected. The status line carries the SDK's error
|
||||
* if it does not get there, so the failure says why.
|
||||
*/
|
||||
export async function startHarness(
|
||||
page: Page,
|
||||
user: User,
|
||||
roomId: string,
|
||||
): Promise<void> {
|
||||
const query = new URLSearchParams({
|
||||
homeserver: HOMESERVER_URL,
|
||||
accessToken: user.accessToken,
|
||||
userId: user.userId,
|
||||
deviceId: user.deviceId,
|
||||
room: roomId,
|
||||
});
|
||||
await page.goto(`${SDK_HARNESS_URL}/?${query.toString()}`);
|
||||
await page.getByRole("button", { name: "Start" }).click();
|
||||
await waitForConnected(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* A login, a crypto setup and an initial sync happen before the session can
|
||||
* connect. An error is final, so it is not worth waiting out the timeout for
|
||||
* "connected" after one.
|
||||
*/
|
||||
export async function waitForConnected(page: Page): Promise<void> {
|
||||
const status = page.getByTestId("status");
|
||||
await expect(status).toHaveText(/^(connected|Error)/, { timeout: 120_000 });
|
||||
const text = await status.textContent();
|
||||
if (text !== "connected")
|
||||
throw new Error(`The harness did not connect to the session: ${text}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A context of its own per user, since a browser profile holds one login. No
|
||||
* permissions to grant: each browser is launched with fake media that is
|
||||
* handed out without asking (see playwright.config.ts).
|
||||
*/
|
||||
export async function newPage(browser: Browser): Promise<Page> {
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
return context.newPage();
|
||||
}
|
||||
|
||||
/** Two users in one session, each on a page of their own. */
|
||||
export async function startPair(
|
||||
browser: Browser,
|
||||
name: string,
|
||||
): Promise<{ pages: [Page, Page]; users: [User, User]; roomId: string }> {
|
||||
const { users, roomId } = await createUsersAndRoom(name);
|
||||
const pages = await Promise.all([newPage(browser), newPage(browser)]);
|
||||
await Promise.all(
|
||||
pages.map(async (page, i) => startHarness(page, users[i], roomId)),
|
||||
);
|
||||
return { pages: pages as [Page, Page], users, roomId };
|
||||
}
|
||||
|
||||
/** The tile a page shows for a user. */
|
||||
export function tileOf(page: Page, user: User): Locator {
|
||||
return page.locator(`[data-testid="member"][data-user-id="${user.userId}"]`);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
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 } from "@playwright/test";
|
||||
|
||||
import { startPair, tileOf, waitForConnected } from "./harness.ts";
|
||||
|
||||
/**
|
||||
* Members coming and going: a member that leaves disappears for the peer, one
|
||||
* that comes back is shown again, and the harness is left in the state its
|
||||
* status line claims.
|
||||
*/
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test("a member that leaves disappears for the peer", async ({ browser }) => {
|
||||
const {
|
||||
pages: [leaver, stayer],
|
||||
users: [leaving, staying],
|
||||
} = await startPair(browser, "sdkleave");
|
||||
await expect(stayer.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
await leaver.getByRole("button", { name: "Leave" }).click();
|
||||
await expect(leaver.getByTestId("status")).toHaveText("Left");
|
||||
await expect(leaver.getByTestId("member")).toHaveCount(0);
|
||||
|
||||
await expect(tileOf(stayer, leaving)).toHaveCount(0, { timeout: 60_000 });
|
||||
await expect(tileOf(stayer, staying)).toHaveCount(1);
|
||||
await expect(stayer.getByTestId("status")).toHaveText("connected");
|
||||
});
|
||||
|
||||
test("a member that reloads is shown again with its media", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const {
|
||||
pages: [reloader, watcher],
|
||||
users: [reloading],
|
||||
} = await startPair(browser, "sdkreload");
|
||||
await expect(watcher.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// The page comes back on the same device, so the new membership replaces
|
||||
// the old one rather than sitting next to it
|
||||
await reloader.reload();
|
||||
await reloader.getByRole("button", { name: "Start" }).click();
|
||||
await waitForConnected(reloader);
|
||||
|
||||
await expect(tileOf(watcher, reloading)).toHaveCount(1, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
const video = tileOf(watcher, reloading).locator("video");
|
||||
await expect
|
||||
.poll(async () => video.evaluate((v: HTMLVideoElement) => v.videoWidth), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await expect(reloader.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, type Locator, type Page, test } from "@playwright/test";
|
||||
|
||||
import { startPair, tileOf, type User } from "./harness.ts";
|
||||
|
||||
/**
|
||||
* What a member's media looks like from the other side: it plays, it is
|
||||
* encrypted, it follows the mute switches, and the SFU sends only as much as
|
||||
* the tile on screen can show. Each test reads the labels the harness puts on
|
||||
* its media elements, which come straight from the SDK's track behaviors.
|
||||
*
|
||||
* One session serves every test here: logging two browsers in and connecting
|
||||
* them takes most of a minute, and none of the tests leaves the session in a
|
||||
* state the next cannot start from.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: "serial", timeout: 300_000 });
|
||||
|
||||
let pages: [Page, Page];
|
||||
let users: [User, User];
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
({ pages, users } = await startPair(browser, "sdkmedia"));
|
||||
for (const page of pages)
|
||||
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
for (const page of pages ?? []) await page.context().close();
|
||||
});
|
||||
|
||||
test("the peer's camera and microphone play", async () => {
|
||||
const peer = remoteTile(1, 0);
|
||||
const video = peer.locator("video");
|
||||
await expect
|
||||
.poll(async () => video.evaluate((v: HTMLVideoElement) => v.videoWidth), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await expect(video).toHaveJSProperty("paused", false);
|
||||
const audio = peer.locator("audio");
|
||||
await expect
|
||||
.poll(async () =>
|
||||
audio.evaluate((a: HTMLAudioElement) => a.srcObject !== null),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(audio).toHaveJSProperty("paused", false);
|
||||
});
|
||||
|
||||
test("the peer's tracks are encrypted", async () => {
|
||||
const peer = remoteTile(1, 0);
|
||||
await expect(peer.locator("video")).toHaveAttribute("data-encrypted", "true");
|
||||
await expect(peer.locator("audio")).toHaveAttribute("data-encrypted", "true");
|
||||
});
|
||||
|
||||
test("muting the microphone and the camera is seen by the peer", async () => {
|
||||
const [page] = pages;
|
||||
const peer = remoteTile(1, 0);
|
||||
const video = peer.locator("video");
|
||||
const audio = peer.locator("audio");
|
||||
await expect(video).toHaveAttribute("data-muted", "false");
|
||||
await expect(audio).toHaveAttribute("data-muted", "false");
|
||||
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await expect(audio).toHaveAttribute("data-muted", "true", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(video).toHaveAttribute("data-muted", "false");
|
||||
|
||||
await page.getByRole("button", { name: "Camera" }).click();
|
||||
await expect(video).toHaveAttribute("data-muted", "true", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await page.getByRole("button", { name: "Camera" }).click();
|
||||
await expect(audio).toHaveAttribute("data-muted", "false", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(video).toHaveAttribute("data-muted", "false", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("the peer's microphone is active while it sends sound, and not while muted", async () => {
|
||||
// Chromium's fake microphone never registers as a speaker at the SFU, and
|
||||
// Firefox's steady tone does so in about two runs out of three, with or
|
||||
// without the browser's audio processing. The pipeline is proven when it
|
||||
// passes; it needs a louder fake device or a lower SFU threshold to be
|
||||
// relied on.
|
||||
test.fixme(true, "the fake microphones do not reliably register at the SFU");
|
||||
const [page] = pages;
|
||||
const audio = remoteTile(1, 0).locator("audio");
|
||||
// The SFU only announces a change of speakers, so a tone that has been
|
||||
// playing since before the peer subscribed is not announced to it. Muting
|
||||
// and unmuting is the change that is.
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await expect(audio).toHaveAttribute("data-muted", "true", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(audio).toHaveAttribute("data-active", "false");
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await expect(audio).toHaveAttribute("data-active", "true", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
await expect(audio).toHaveAttribute("data-active", "false", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Microphone" }).click();
|
||||
});
|
||||
|
||||
test("a larger tile receives a higher resolution", async () => {
|
||||
const peer = remoteTile(1, 0);
|
||||
const video = peer.locator("video");
|
||||
// The tile starts small enough for the lowest simulcast layer
|
||||
const small = await receivedWidth(video, (width) => width > 0);
|
||||
await resize(peer, 1280);
|
||||
const large = await receivedWidth(video, (width) => width > small);
|
||||
expect(large).toBeGreaterThan(small);
|
||||
await resize(peer, 240);
|
||||
await receivedWidth(video, (width) => width < large);
|
||||
});
|
||||
|
||||
test("a hidden tile stops receiving video, and a shown one resumes", async () => {
|
||||
const peer = remoteTile(1, 0);
|
||||
const video = peer.locator("video");
|
||||
await expect(frames(video)).resolves.toBeGreaterThan(0);
|
||||
|
||||
await peer.evaluate((tile) => (tile.style.display = "none"));
|
||||
await expect
|
||||
.poll(async () => framesStill(video), { timeout: 60_000 })
|
||||
.toBe(true);
|
||||
|
||||
await peer.evaluate((tile) => (tile.style.display = ""));
|
||||
await expect
|
||||
.poll(async () => !(await framesStill(video)), { timeout: 60_000 })
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
/** The tile on one page for the user of the other page. */
|
||||
function remoteTile(viewer: 0 | 1, shown: 0 | 1): Locator {
|
||||
return tileOf(pages[viewer], users[shown]);
|
||||
}
|
||||
|
||||
async function resize(tile: Locator, width: number): Promise<void> {
|
||||
await tile.evaluate((t, w) => (t.style.width = `${w}px`), width);
|
||||
}
|
||||
|
||||
/** The width of the video as decoded, once it satisfies the condition. */
|
||||
async function receivedWidth(
|
||||
video: Locator,
|
||||
until: (width: number) => boolean,
|
||||
): Promise<number> {
|
||||
// Switching simulcast layers takes the SFU a few seconds
|
||||
let width = 0;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
width = await video.evaluate((v: HTMLVideoElement) => v.videoWidth);
|
||||
return until(width);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
return width;
|
||||
}
|
||||
|
||||
async function frames(video: Locator): Promise<number> {
|
||||
return Number(await video.getAttribute("data-frames"));
|
||||
}
|
||||
|
||||
/** Whether no frame arrived over a couple of seconds. */
|
||||
async function framesStill(video: Locator): Promise<boolean> {
|
||||
const before = await frames(video);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
return (await frames(video)) === before;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
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 } from "@playwright/test";
|
||||
|
||||
import { startPair, tileOf } from "./harness.ts";
|
||||
|
||||
/**
|
||||
* The MatrixRTC SDK driven through its harness in `sdk/dev`: no Element Call
|
||||
* on the page, only the SDK's public API.
|
||||
*/
|
||||
|
||||
// Two browsers each log in, set up crypto and sync before anything is on
|
||||
// screen, then wait for media to connect
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test("two browsers see each other in one session", async ({ browser }) => {
|
||||
const { pages, users } = await startPair(browser, "sdksmoke");
|
||||
|
||||
// Each page shows itself and the other, each tile named by its user
|
||||
for (const page of pages) {
|
||||
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
for (const user of users)
|
||||
await expect(tileOf(page, user)).toContainText(user.displayName);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,493 @@
|
||||
/*
|
||||
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 { SpaHelpers } from "./spa-helpers";
|
||||
import { type RtcMode } from "./widget/test-helpers.ts";
|
||||
|
||||
const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
|
||||
const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
|
||||
|
||||
const otherHomeserver = {
|
||||
base_url: "https://synapse.othersite.m.localhost",
|
||||
server_name: "synapse.othersite.m.localhost",
|
||||
};
|
||||
const otherSfuUrl = "wss://matrix-rtc.othersite.m.localhost/livekit/sfu";
|
||||
|
||||
interface Transport {
|
||||
type: "livekit";
|
||||
url?: string;
|
||||
livekit_service_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport shapes a homeserver may advertise while migrating to the
|
||||
* latest MSC4195. `legacy` is what the e2e Synapse advertises on its own.
|
||||
*/
|
||||
const shapes = {
|
||||
url: { type: "livekit", url: sfuUrl },
|
||||
both: { type: "livekit", url: sfuUrl, livekit_service_url: serviceUrl },
|
||||
legacy: null,
|
||||
} satisfies Record<string, Transport | null>;
|
||||
type Shape = keyof typeof shapes;
|
||||
|
||||
/** Which endpoint a page asks for its SFU tokens. */
|
||||
type Flow = "cs-api" | "service-legacy" | "service-default";
|
||||
|
||||
const cases: {
|
||||
hostShape: Shape;
|
||||
guestShape: Shape;
|
||||
/** The MatrixRTC mode both sides use; the default is compatibility mode. */
|
||||
mode?: RtcMode;
|
||||
hostFlows: Flow[];
|
||||
guestFlows: Flow[];
|
||||
}[] = [
|
||||
{
|
||||
hostShape: "url",
|
||||
guestShape: "url",
|
||||
hostFlows: ["cs-api"],
|
||||
guestFlows: ["cs-api"],
|
||||
},
|
||||
{
|
||||
// Publishers keep the legacy participant identity that clients predating
|
||||
// MSC4195 expect, which only the JWT service assigns.
|
||||
hostShape: "both",
|
||||
guestShape: "both",
|
||||
hostFlows: ["service-legacy"],
|
||||
guestFlows: ["service-legacy"],
|
||||
},
|
||||
{
|
||||
// The MatrixRTC mode has no bearing on the choice.
|
||||
hostShape: "both",
|
||||
guestShape: "both",
|
||||
mode: "2_0",
|
||||
hostFlows: ["service-legacy"],
|
||||
guestFlows: ["service-legacy"],
|
||||
},
|
||||
{
|
||||
hostShape: "url",
|
||||
guestShape: "legacy",
|
||||
hostFlows: ["cs-api", "service-default"],
|
||||
guestFlows: ["service-legacy", "cs-api"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { hostShape, guestShape, mode, hostFlows, guestFlows } of cases) {
|
||||
test(`One to one call with ${hostShape} host and ${guestShape} guest transport${mode === "2_0" ? " in Matrix 2.0 mode" : ""}`, async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host starts the call.
|
||||
await advertiseTransport(page, shapes[hostShape]);
|
||||
const hostRequests = recordAuthRequests(page);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true, mode);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// The guest joins.
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guestPage = await guestContext.newPage();
|
||||
await advertiseTransport(guestPage, shapes[guestShape]);
|
||||
const guestRequests = recordAuthRequests(guestPage);
|
||||
await SpaHelpers.joinCallFromInviteLink(
|
||||
guestPage,
|
||||
inviteLink,
|
||||
"Pevara",
|
||||
mode,
|
||||
);
|
||||
|
||||
// Wait for the call to connect and render.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
// The host and guest ran through the expected flows.
|
||||
expect(flowsOf(hostRequests)).toEqual(new Set(hostFlows));
|
||||
expect(flowsOf(guestRequests)).toEqual(new Set(guestFlows));
|
||||
|
||||
// All requests succeeded and the SFU URL was set on C-S requests.
|
||||
for (const request of [...hostRequests, ...guestRequests]) {
|
||||
expect(request.status).toBe(200);
|
||||
if (request.flow === "cs-api") expect(request.sfuUrl).toBe(sfuUrl);
|
||||
// Clients predating MSC4195 match a publisher by the legacy identity,
|
||||
// which only the JWT service's legacy endpoint assigns. The other flows
|
||||
// hash it.
|
||||
expect(request.identity).toMatch(
|
||||
request.flow === "service-legacy" ? legacyIdentity : hashedIdentity,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("Subscriber falls back to the JWT service when the homeserver lacks MSC4195", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host starts the call with both transport shapes.
|
||||
await advertiseTransport(page, shapes.both);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// The guest joins but their C-S requests fail.
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guestPage = await guestContext.newPage();
|
||||
await rejectCsApiTokens(guestPage);
|
||||
const guestRequests = recordAuthRequests(guestPage);
|
||||
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
|
||||
|
||||
// Wait for the call to connect and render.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
// The guest ran through all flows.
|
||||
expect(flowsOf(guestRequests)).toEqual(
|
||||
new Set<Flow>(["cs-api", "service-legacy", "service-default"]),
|
||||
);
|
||||
|
||||
// The C-S token requests failed.
|
||||
for (const request of guestRequests) {
|
||||
expect(request.status).toBe(request.flow === "cs-api" ? 404 : 200);
|
||||
}
|
||||
|
||||
// The C-S token requests preceeded the fallback request.
|
||||
const flows = guestRequests.map((r) => r.flow);
|
||||
expect(flows.indexOf("service-default")).toBeGreaterThan(
|
||||
flows.indexOf("cs-api"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Publisher shows an error when the homeserver lacks MSC4195", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host tries to start a call with only the C-S transport shape available but
|
||||
// their C-S requests fail.
|
||||
await advertiseTransport(page, shapes.url);
|
||||
await rejectCsApiTokens(page);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
|
||||
|
||||
// We land on an error page.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Something went wrong" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The authorization service for your media server (SFU) is out of date.",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Delegation follows the flow that authenticated the publisher: the homeserver
|
||||
* for a `url`-only transport, the JWT service wherever there is one.
|
||||
*/
|
||||
const delegationCases: { shape: Shape; via: Delegation["via"] }[] = [
|
||||
{ shape: "url", via: "homeserver" },
|
||||
{ shape: "legacy", via: "service" },
|
||||
{ shape: "both", via: "service" },
|
||||
];
|
||||
|
||||
for (const { shape, via } of delegationCases) {
|
||||
test(`Delayed leave event delegated through the ${via} for a ${shape} transport gets sent on disconnect`, async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host starts the call.
|
||||
await advertiseTransport(page, shapes[shape]);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// The guest joins.
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guestPage = await guestContext.newPage();
|
||||
await advertiseTransport(guestPage, shapes[shape]);
|
||||
const delayedLeave = recordDelayedLeave(guestPage);
|
||||
const delegations = recordDelegations(guestPage);
|
||||
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
|
||||
|
||||
// Wait for the call to connect and render.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
// The guest handed its delayed leave over, after probing for delegation
|
||||
// support with bodiless requests, which count as supported unless they
|
||||
// 404. The homeserver is always probed; the JWT service only when there
|
||||
// is one.
|
||||
await expect
|
||||
.poll(() => delegations.at(-1))
|
||||
.toEqual({
|
||||
via,
|
||||
status: 200,
|
||||
delayId: delayedLeave.delayId,
|
||||
...(via === "homeserver" && { sfuUrl }),
|
||||
});
|
||||
const probes = delegations.slice(0, -1);
|
||||
expect(probes.some((probe) => probe.via === via)).toBe(true);
|
||||
for (const probe of probes) {
|
||||
expect(probe.status).toBeGreaterThanOrEqual(400);
|
||||
expect(probe.status).not.toBe(404);
|
||||
}
|
||||
|
||||
// The delay is long enough that a leave within the window below cannot
|
||||
// come from the delayed event expiring on its own.
|
||||
expect(delayedLeave.delayMs).toBeGreaterThan(60_000);
|
||||
|
||||
// The guest drops without leaving.
|
||||
await guestPage.reload();
|
||||
|
||||
// The host eventually sees the guest go once the delayed event fires.
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Subscribers get tokens for a remote homeserver's SFU over federation", async ({
|
||||
browser,
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
skipOnFirefox(browserName);
|
||||
|
||||
// The host lives on the other homeserver and publishes on its SFU.
|
||||
await useHomeserver(page, otherHomeserver);
|
||||
await advertiseTransport(page, { type: "livekit", url: otherSfuUrl });
|
||||
const hostRequests = recordAuthRequests(page);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(page);
|
||||
|
||||
// The guest lives on the default homeserver and joins over federation.
|
||||
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const guestPage = await guestContext.newPage();
|
||||
await advertiseTransport(guestPage, shapes.url);
|
||||
const guestRequests = recordAuthRequests(guestPage);
|
||||
await SpaHelpers.joinCallFromInviteLink(
|
||||
guestPage,
|
||||
`${inviteLink}&viaServers=${otherHomeserver.server_name}`,
|
||||
"Pevara",
|
||||
);
|
||||
|
||||
// Wait for the call to connect and render.
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
// Each side got a token for its own SFU and for the other side's SFU, all
|
||||
// through its own homeserver.
|
||||
for (const requests of [hostRequests, guestRequests]) {
|
||||
expect(flowsOf(requests)).toEqual(new Set<Flow>(["cs-api"]));
|
||||
expect(new Set(requests.map((r) => r.sfuUrl))).toEqual(
|
||||
new Set([sfuUrl, otherSfuUrl]),
|
||||
);
|
||||
for (const request of requests) expect(request.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
/** Skips the calling test if we're running on Firefox. */
|
||||
function skipOnFirefox(browserName: string): void {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
}
|
||||
|
||||
/** Points this page's Element Call at another homeserver. */
|
||||
async function useHomeserver(
|
||||
page: Page,
|
||||
homeserver: typeof otherHomeserver,
|
||||
): Promise<void> {
|
||||
await page.route("**/config.json", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const config = (await response.json()) as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: {
|
||||
...config,
|
||||
default_server_config: { "m.homeserver": homeserver },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps recorded requests to a set of flows. Publisher and subscriber connections
|
||||
* start concurrently so using arrays for comparison isn't convenient.
|
||||
*/
|
||||
function flowsOf(requests: AuthRequest[]): Set<Flow> {
|
||||
return new Set(requests.map((r) => r.flow));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the transports the homeserver advertises to this page. The real
|
||||
* response is fetched first so its CORS headers carry over.
|
||||
*/
|
||||
async function advertiseTransport(
|
||||
page: Page,
|
||||
transport: Transport | null,
|
||||
): Promise<void> {
|
||||
if (transport === null) return;
|
||||
await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, json: { rtc_transports: [transport] } });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the homeserver look like one without MSC4195: its `get_token`
|
||||
* endpoint answers as Synapse does for an unknown route.
|
||||
*/
|
||||
async function rejectCsApiTokens(page: Page): Promise<void> {
|
||||
await page.route("**/rtc/livekit/get_token", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.continue();
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
json: { errcode: "M_UNRECOGNIZED", error: "Unrecognized request" },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** A recorded delayed leave event. */
|
||||
interface DelayedLeave {
|
||||
delayMs?: number;
|
||||
delayId?: string;
|
||||
}
|
||||
|
||||
/** Records the delayed leave event the page schedules on joining. */
|
||||
function recordDelayedLeave(page: Page): DelayedLeave {
|
||||
const delayedLeave: DelayedLeave = {};
|
||||
page.on("response", (response) => {
|
||||
const delay = new URL(response.request().url()).searchParams.get(
|
||||
"org.matrix.msc4140.delay",
|
||||
);
|
||||
if (delay === null || response.status() !== 200) return;
|
||||
delayedLeave.delayMs = Number(delay);
|
||||
response
|
||||
.json()
|
||||
.then((body: { delay_id: string }) => {
|
||||
delayedLeave.delayId = body.delay_id;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
return delayedLeave;
|
||||
}
|
||||
|
||||
/** A recorded delegation request for a delayed leave event. */
|
||||
interface Delegation {
|
||||
/** Who was asked to send the leave event on the guest's behalf. */
|
||||
via: "homeserver" | "service";
|
||||
status: number;
|
||||
sfuUrl?: string;
|
||||
delayId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records requests that delegate a delayed leave event, or probe for support:
|
||||
* the MSC4195 `delegate_delayed_leave` endpoint of the homeserver, and the JWT
|
||||
* service's `delegate_delayed_leave` probe and `/sfu/get` with delay fields.
|
||||
*/
|
||||
function recordDelegations(page: Page): Delegation[] {
|
||||
const delegations: Delegation[] = [];
|
||||
page.on("response", (response) => {
|
||||
const request = response.request();
|
||||
if (request.method() !== "POST") return;
|
||||
const url = request.url();
|
||||
const body = request.postDataJSON() as {
|
||||
url?: string;
|
||||
delay_id?: string;
|
||||
} | null;
|
||||
if (url.endsWith("/rtc/livekit/delegate_delayed_leave")) {
|
||||
delegations.push({
|
||||
via: "homeserver",
|
||||
status: response.status(),
|
||||
...(body && { sfuUrl: body.url, delayId: body.delay_id }),
|
||||
});
|
||||
} else if (url.endsWith("/livekit/jwt/delegate_delayed_leave")) {
|
||||
delegations.push({ via: "service", status: response.status() });
|
||||
} else if (url.endsWith("/livekit/jwt/sfu/get") && body?.delay_id) {
|
||||
delegations.push({
|
||||
via: "service",
|
||||
status: response.status(),
|
||||
delayId: body.delay_id,
|
||||
});
|
||||
}
|
||||
});
|
||||
return delegations;
|
||||
}
|
||||
|
||||
/** A recorded token request. */
|
||||
interface AuthRequest {
|
||||
flow: Flow;
|
||||
status: number;
|
||||
/** The SFU a request to the MSC4195 `get_token` endpoint asked for. */
|
||||
sfuUrl?: string;
|
||||
/** The participant identity the issued token carries. */
|
||||
identity?: string;
|
||||
}
|
||||
|
||||
/** The legacy `<user_id>:<device_id>` participant identity. */
|
||||
const legacyIdentity = /^@[^:]+:[^:]+:[A-Z]+$/;
|
||||
/** A hashed participant identity: unpadded base64 of a SHA-256. */
|
||||
const hashedIdentity = /^[A-Za-z0-9+/]{43}$/;
|
||||
|
||||
/** Reads the subject of a JWT without verifying it. */
|
||||
function jwtSubject(jwt: string): string {
|
||||
const [, payload] = jwt.split(".");
|
||||
return (
|
||||
JSON.parse(Buffer.from(payload, "base64url").toString()) as {
|
||||
sub: string;
|
||||
}
|
||||
).sub;
|
||||
}
|
||||
|
||||
/** Records token requests, including the identity each token carries. */
|
||||
function recordAuthRequests(page: Page): AuthRequest[] {
|
||||
const requests: AuthRequest[] = [];
|
||||
page.on("response", (response) => {
|
||||
const request = response.request();
|
||||
if (request.method() !== "POST") return;
|
||||
const url = request.url();
|
||||
let entry: AuthRequest;
|
||||
if (url.endsWith("/rtc/livekit/get_token")) {
|
||||
entry = {
|
||||
flow: "cs-api",
|
||||
status: response.status(),
|
||||
sfuUrl: (request.postDataJSON() as { url: string }).url,
|
||||
};
|
||||
} else if (url.endsWith("/livekit/jwt/sfu/get")) {
|
||||
entry = { flow: "service-legacy", status: response.status() };
|
||||
} else if (url.endsWith("/livekit/jwt/get_token")) {
|
||||
entry = { flow: "service-default", status: response.status() };
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
requests.push(entry);
|
||||
if (entry.status !== 200) return;
|
||||
response
|
||||
.json()
|
||||
.then((body: { jwt: string }) => {
|
||||
entry.identity = jwtSubject(body.jwt);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
@@ -37,8 +37,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
|
||||
await TestHelpers.acceptRoomInvite(roomName, timo.page);
|
||||
|
||||
await florian.page.pause();
|
||||
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
rtcMode1,
|
||||
@@ -74,8 +72,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
|
||||
// await florian.page.pause();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -223,7 +223,7 @@ export class TestHelpers {
|
||||
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
|
||||
await page.getByRole("menuitem", { name: "New Room" }).click({
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
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.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
|
||||
const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
|
||||
|
||||
/** Which endpoint a page asks for its SFU tokens. */
|
||||
type Flow = "cs-api" | "service-legacy" | "service-default";
|
||||
|
||||
widgetTest(
|
||||
"Call between a url + livekit_service_url transport and a legacy one",
|
||||
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();
|
||||
|
||||
const [host, guest] = await Promise.all([
|
||||
addUser("Androl", HOST1),
|
||||
addUser("Pevara", HOST1),
|
||||
]);
|
||||
|
||||
// The host's homeserver has started advertising a `url` next to its JWT
|
||||
// service; the guest's still advertises the JWT service alone.
|
||||
await advertiseTransport(host.page, {
|
||||
type: "livekit",
|
||||
url: sfuUrl,
|
||||
livekit_service_url: serviceUrl,
|
||||
});
|
||||
// Element Web answers the widget from a cache it filled at login, so make
|
||||
// it fetch the transports again now that they are rerouted
|
||||
await host.clientHandle.evaluate(async (client) => {
|
||||
await client.cachedRtcTransports.refresh();
|
||||
});
|
||||
const hostFlows = recordFlows(host.page);
|
||||
const guestFlows = recordFlows(guest.page);
|
||||
|
||||
const roomName = "Transport Room";
|
||||
await TestHelpers.createRoom(roomName, host.page, [guest.mxId]);
|
||||
await TestHelpers.acceptRoomInvite(roomName, guest.page);
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(host.page, false);
|
||||
await TestHelpers.joinCallFromLobby(host.page);
|
||||
await TestHelpers.joinCallInCurrentRoom(guest.page);
|
||||
|
||||
for (const user of [host, guest]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
|
||||
// Element Web does not expose the MSC4195 endpoints to widgets yet, so
|
||||
// neither side reached the CS API. The host published through the JWT
|
||||
// service's legacy endpoint and subscribed to the guest through its
|
||||
// default one; the guest published the same way and fell back to the
|
||||
// default endpoint for the host's transport.
|
||||
expect(hostFlows).toEqual(new Set(["service-legacy", "service-default"]));
|
||||
expect(guestFlows).toEqual(new Set(["service-legacy", "service-default"]));
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Replaces the transports the homeserver advertises to this page, including
|
||||
* to the widget host, which fetches them on the widget's behalf. The real
|
||||
* response is fetched first so its CORS headers carry over.
|
||||
*/
|
||||
async function advertiseTransport(
|
||||
page: Page,
|
||||
transport: { type: "livekit"; url?: string; livekit_service_url?: string },
|
||||
): Promise<void> {
|
||||
await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, json: { rtc_transports: [transport] } });
|
||||
});
|
||||
}
|
||||
|
||||
/** Records which token endpoints the page's widget asks. */
|
||||
function recordFlows(page: Page): Set<Flow> {
|
||||
const flows = new Set<Flow>();
|
||||
page.on("response", (response) => {
|
||||
const request = response.request();
|
||||
if (request.method() !== "POST") return;
|
||||
const url = request.url();
|
||||
if (url.endsWith("/rtc/livekit/get_token")) flows.add("cs-api");
|
||||
else if (url.endsWith("/livekit/jwt/sfu/get")) flows.add("service-legacy");
|
||||
else if (url.endsWith("/livekit/jwt/get_token"))
|
||||
flows.add("service-default");
|
||||
});
|
||||
return flows;
|
||||
}
|
||||
Generated
+59
-94
@@ -132,10 +132,10 @@ importers:
|
||||
version: 10.3.1(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0(@types/react@19.2.18)(react@19.2.8)
|
||||
version: 11.2.0(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-web':
|
||||
specifier: ^10.0.0
|
||||
version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
specifier: ^10.4.0
|
||||
version: 10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.2.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
@@ -192,10 +192,10 @@ importers:
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#develop
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.19.0
|
||||
version: 1.20.0
|
||||
node-stdlib-browser:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
@@ -222,7 +222,7 @@ importers:
|
||||
version: 10.6.1(postcss@8.5.26)
|
||||
postcss-selector-parser:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.3
|
||||
version: 7.1.6
|
||||
posthog-js:
|
||||
specifier: 1.408.2
|
||||
version: 1.408.2
|
||||
@@ -268,9 +268,6 @@ importers:
|
||||
vite:
|
||||
specifier: ^8.0.0
|
||||
version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)
|
||||
vite-plugin-generate-file:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.1
|
||||
vite-plugin-html:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
|
||||
@@ -1194,8 +1191,8 @@ packages:
|
||||
'@types/dom-mediacapture-transform': ^0.1.9
|
||||
livekit-client: ^1.12.0 || ^2.1.0
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0':
|
||||
resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
|
||||
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
@@ -3226,8 +3223,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>= 16.8.0'
|
||||
|
||||
'@vector-im/compound-design-tokens@11.0.0':
|
||||
resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==}
|
||||
'@vector-im/compound-design-tokens@11.2.0':
|
||||
resolution: {integrity: sha512-XGwO63ZqFVXdkhCVLm3wa/tpx2Lw8pyaQYskObrLZIUkjs3aUmgq8CNhGcjkqRJXYGsLNJE2azov4DaM74h2PQ==}
|
||||
peerDependencies:
|
||||
'@adobe/leonardo-contrast-colors': ^1.0.0
|
||||
'@types/react': '*'
|
||||
@@ -3243,13 +3240,13 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@vector-im/compound-web@10.1.0':
|
||||
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
|
||||
'@vector-im/compound-web@10.4.0':
|
||||
resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==}
|
||||
peerDependencies:
|
||||
'@fontsource/inconsolata': ^5
|
||||
'@fontsource/inter': ^5
|
||||
'@types/react': '*'
|
||||
'@vector-im/compound-design-tokens': '>=1.6.1 <11.0.0'
|
||||
'@vector-im/compound-design-tokens': '>=1.6.1 <12.0.0'
|
||||
react: ^18 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
@@ -3659,8 +3656,8 @@ packages:
|
||||
constants-browserify@1.0.0:
|
||||
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
|
||||
|
||||
content-type@3.0.0:
|
||||
resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==}
|
||||
content-type@3.1.1:
|
||||
resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
@@ -4465,10 +4462,6 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@4.3.0:
|
||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.3.1:
|
||||
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
|
||||
hasBin: true
|
||||
@@ -4680,13 +4673,13 @@ 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/24929be0e741be6a5d0a7226f1c682e245263b8a:
|
||||
resolution: {gitHosted: true, integrity: sha512-0EGwbXzvG88sBOxq4ZlCRwT4VpQj2xkizfyy0A3bTSK0ysb6/gXJ/o1WPFoqCjRGMDgPq4gAsk92xGtHA7EhPg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a}
|
||||
version: 42.2.0
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570:
|
||||
resolution: {gitHosted: true, integrity: sha512-b1o6ywaPY9jakj+3GIgx3samM9AbqohzPQ5nZKbyOGYB16IHQtNMyjEo/PY0fW/s0uwBVApEHNb/MMtN40c/rQ==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570}
|
||||
version: 43.0.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
matrix-widget-api@1.19.0:
|
||||
resolution: {integrity: sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw==}
|
||||
matrix-widget-api@1.20.0:
|
||||
resolution: {integrity: sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==}
|
||||
|
||||
md5.js@1.3.5:
|
||||
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
|
||||
@@ -4703,14 +4696,6 @@ packages:
|
||||
resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
|
||||
hasBin: true
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mimic-function@5.0.1:
|
||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4895,8 +4880,8 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-retry@8.0.0:
|
||||
resolution: {integrity: sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==}
|
||||
p-retry@8.0.1:
|
||||
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
p-try@2.2.0:
|
||||
@@ -5175,8 +5160,8 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.4
|
||||
|
||||
postcss-selector-parser@7.1.3:
|
||||
resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==}
|
||||
postcss-selector-parser@7.1.6:
|
||||
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
@@ -5904,9 +5889,6 @@ packages:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
vite-plugin-generate-file@0.3.1:
|
||||
resolution: {integrity: sha512-tiA3gkPM21MS2+RyqsBMT33GSlM9LM1TJjf6vGvV/e/ml3e3vTKfuH3l2N0NpUgcayvj1fXnmlo5YBuahA6bsg==}
|
||||
|
||||
vite-plugin-html@3.2.2:
|
||||
resolution: {integrity: sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q==}
|
||||
peerDependencies:
|
||||
@@ -6415,9 +6397,9 @@ snapshots:
|
||||
|
||||
'@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)':
|
||||
dependencies:
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
'@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -6523,9 +6505,9 @@ snapshots:
|
||||
|
||||
'@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)':
|
||||
dependencies:
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
'@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -6627,7 +6609,7 @@ snapshots:
|
||||
'@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)':
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
'@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -6671,13 +6653,13 @@ snapshots:
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
|
||||
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.3)':
|
||||
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.6)':
|
||||
dependencies:
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.3)':
|
||||
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.6)':
|
||||
dependencies:
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
'@csstools/utilities@2.0.0(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -7101,7 +7083,7 @@ snapshots:
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)':
|
||||
dependencies:
|
||||
@@ -8685,12 +8667,12 @@ snapshots:
|
||||
'@use-gesture/core': 10.3.1
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8)':
|
||||
'@vector-im/compound-design-tokens@11.2.0(@types/react@19.2.18)(react@19.2.8)':
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
'@vector-im/compound-web@10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.2.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -8702,7 +8684,7 @@ snapshots:
|
||||
'@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens': 11.0.0(@types/react@19.2.18)(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens': 11.2.0(@types/react@19.2.18)(react@19.2.8)
|
||||
classnames: 2.5.1
|
||||
react: 19.2.8
|
||||
vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -9163,7 +9145,7 @@ snapshots:
|
||||
|
||||
constants-browserify@1.0.0: {}
|
||||
|
||||
content-type@3.0.0: {}
|
||||
content-type@3.1.1: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
@@ -9234,13 +9216,13 @@ snapshots:
|
||||
css-blank-pseudo@7.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
css-has-pseudo@7.0.3(postcss@8.5.26):
|
||||
dependencies:
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
css-prefers-color-scheme@10.0.0(postcss@8.5.26):
|
||||
@@ -10034,10 +10016,6 @@ snapshots:
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.3.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
js-yaml@4.3.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
@@ -10249,21 +10227,21 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.5.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
another-json: 0.2.0
|
||||
bs58: 6.0.0
|
||||
content-type: 3.0.0
|
||||
content-type: 3.1.1
|
||||
loglevel: 1.9.2
|
||||
matrix-events-sdk: 0.0.1
|
||||
matrix-widget-api: 1.19.0
|
||||
p-retry: 8.0.0
|
||||
matrix-widget-api: 1.20.0
|
||||
p-retry: 8.0.1
|
||||
sdp-transform: 3.0.0
|
||||
unhomoglyph: 1.0.6
|
||||
|
||||
matrix-widget-api@1.19.0:
|
||||
matrix-widget-api@1.20.0:
|
||||
dependencies:
|
||||
'@types/events': 3.0.3
|
||||
events: 3.3.0
|
||||
@@ -10286,12 +10264,6 @@ snapshots:
|
||||
bn.js: 4.12.3
|
||||
brorand: 1.1.0
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
@@ -10584,7 +10556,7 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-retry@8.0.0:
|
||||
p-retry@8.0.1:
|
||||
dependencies:
|
||||
is-network-error: 1.3.2
|
||||
|
||||
@@ -10694,7 +10666,7 @@ snapshots:
|
||||
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-clamp@4.1.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10745,12 +10717,12 @@ snapshots:
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-dir-pseudo-class@9.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-double-position-gradients@6.0.4(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10762,12 +10734,12 @@ snapshots:
|
||||
postcss-focus-visible@10.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-focus-within@9.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-font-variant@5.0.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10799,10 +10771,10 @@ snapshots:
|
||||
|
||||
postcss-nesting@13.0.2(postcss@8.5.26):
|
||||
dependencies:
|
||||
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.3)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.6)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-opacity-percentage@3.0.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10900,7 +10872,7 @@ snapshots:
|
||||
postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10909,9 +10881,9 @@ snapshots:
|
||||
postcss-selector-not@8.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-selector-parser: 7.1.6
|
||||
|
||||
postcss-selector-parser@7.1.3:
|
||||
postcss-selector-parser@7.1.6:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
@@ -11652,13 +11624,6 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
vite-plugin-generate-file@0.3.1:
|
||||
dependencies:
|
||||
ejs: 3.1.10
|
||||
js-yaml: 4.3.0
|
||||
mime-types: 2.1.35
|
||||
picocolors: 1.1.1
|
||||
|
||||
vite-plugin-html@3.2.2(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
|
||||
@@ -6,50 +6,72 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks that the component build leaves the packages a host must supply to
|
||||
* the host.
|
||||
* Checks that the library builds leave the packages a host must supply to the
|
||||
* host.
|
||||
*
|
||||
* A host application already has React, the Matrix SDK and LiveKit, and a
|
||||
* second copy of any of them is worse than dead weight: React would hold two
|
||||
* sets of hooks, and the Matrix client would run two sync loops. So the
|
||||
* component build lists them as external — but that list has to name every
|
||||
* subpath, since the bundler silently ignores the pattern and callback forms
|
||||
* of the option, and an import it does not cover is bundled with no warning at
|
||||
* all. That is the failure this guards against.
|
||||
* A host application already has React, the Matrix SDK and Compound,
|
||||
* and a second copy of any of them is worse than dead weight: React would hold
|
||||
* two sets of hooks, the Matrix client would run two sync loops, and a second
|
||||
* Compound would style the tooltips it floats into the host's body with class
|
||||
* names the host's stylesheet does not know. So each library build lists them
|
||||
* as external — but that list has to name every subpath, since the bundler
|
||||
* silently ignores the pattern and callback forms of the option, and an import
|
||||
* it does not cover is bundled with no warning at all. That is the failure
|
||||
* this guards against.
|
||||
*
|
||||
* It reads the list from the build config itself, so there is one copy of it,
|
||||
* and compares it against every import of those packages in the source.
|
||||
* It reads each list from the build config itself, so there is one copy of it
|
||||
* per build, and compares it against every import of those packages in the
|
||||
* source.
|
||||
*
|
||||
* The comparison is deliberately over-approximate: it looks at all of `src`
|
||||
* rather than only the modules the component actually pulls in, so it will
|
||||
* sometimes ask for a subpath that only the standalone app imports. Listing
|
||||
* one the component never imports costs nothing — the bundler ignores it —
|
||||
* whereas missing one costs a duplicate package.
|
||||
* rather than only the modules a build actually pulls in, so it will sometimes
|
||||
* ask for a subpath that only the standalone app imports. Listing one a build
|
||||
* never imports costs nothing — the bundler ignores it — whereas missing one
|
||||
* costs a duplicate package.
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { loadConfigFromFile } from "vite";
|
||||
|
||||
const CONFIG = "vite-component.config.ts";
|
||||
const SOURCES = ["src", "component"];
|
||||
|
||||
/** The packages whose duplication would break a host, rather than merely enlarge it. */
|
||||
const MUST_BE_EXTERNAL = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"matrix-js-sdk",
|
||||
"livekit-client",
|
||||
/**
|
||||
* The library builds, each with the directories it is built from and the
|
||||
* packages whose duplication would break a host rather than merely enlarge it.
|
||||
* A development harness is a host, not part of the library: it is the one that
|
||||
* imports what a host supplies (Compound's stylesheets, say).
|
||||
*/
|
||||
const TARGETS = [
|
||||
{
|
||||
config: "vite-component.config.ts",
|
||||
what: "the component build",
|
||||
sources: ["src", "component"],
|
||||
excluded: ["component/dev"],
|
||||
mustBeExternal: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"matrix-js-sdk",
|
||||
"@vector-im/compound-web",
|
||||
"@vector-im/compound-design-tokens",
|
||||
],
|
||||
},
|
||||
{
|
||||
config: "vite-sdk.config.ts",
|
||||
what: "the SDK build",
|
||||
sources: ["src", "sdk"],
|
||||
excluded: ["sdk/dev"],
|
||||
mustBeExternal: ["matrix-js-sdk", "livekit-client", "rxjs"],
|
||||
},
|
||||
];
|
||||
|
||||
const isTestFile = (name) =>
|
||||
name.includes(".test.") || name.includes(".stories.");
|
||||
|
||||
/** Every source file under the given directories, recursively. */
|
||||
async function* sourceFiles(dir) {
|
||||
async function* sourceFiles(dir, excluded) {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* sourceFiles(path);
|
||||
if (excluded.includes(path)) continue;
|
||||
if (entry.isDirectory()) yield* sourceFiles(path, excluded);
|
||||
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
|
||||
yield path;
|
||||
}
|
||||
@@ -81,60 +103,60 @@ function imports(source) {
|
||||
* with the host's copy of anything. Worker sub-builds do not inherit this
|
||||
* option anyway.
|
||||
*/
|
||||
const mustBeExternal = (specifier) =>
|
||||
const mustBeExternal = (specifier, packages) =>
|
||||
!specifier.includes("?") &&
|
||||
MUST_BE_EXTERNAL.some(
|
||||
(pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`),
|
||||
);
|
||||
packages.some((pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`));
|
||||
|
||||
const loaded = await loadConfigFromFile(
|
||||
{ command: "build", mode: "production" },
|
||||
CONFIG,
|
||||
);
|
||||
if (loaded === null) {
|
||||
console.error(`Could not load ${CONFIG}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
|
||||
if (declared.size === 0) {
|
||||
console.error(
|
||||
`${CONFIG} declares nothing external. Either the option moved, or the ` +
|
||||
`list is empty; either way this check is not looking at what it thinks.`,
|
||||
/** @returns Whether the target's externals list covers its imports. */
|
||||
async function check({
|
||||
config,
|
||||
what,
|
||||
sources,
|
||||
excluded,
|
||||
mustBeExternal: packages,
|
||||
}) {
|
||||
const loaded = await loadConfigFromFile(
|
||||
{ command: "build", mode: "production" },
|
||||
config,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Where each missing specifier is imported, so the message can point at it
|
||||
const missing = new Map();
|
||||
const seen = new Set();
|
||||
for (const dir of SOURCES)
|
||||
for await (const file of sourceFiles(dir)) {
|
||||
const source = await readFile(file, "utf8");
|
||||
for (const specifier of imports(source)) {
|
||||
if (!mustBeExternal(specifier)) continue;
|
||||
seen.add(specifier);
|
||||
if (declared.has(specifier)) continue;
|
||||
const files = missing.get(specifier) ?? [];
|
||||
files.push(file);
|
||||
missing.set(specifier, files);
|
||||
}
|
||||
if (loaded === null) {
|
||||
console.error(`Could not load ${config}`);
|
||||
return false;
|
||||
}
|
||||
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
|
||||
if (declared.size === 0) {
|
||||
console.error(
|
||||
`${config} declares nothing external. Either the option moved, or the ` +
|
||||
`list is empty; either way this check is not looking at what it thinks.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
// Where each missing specifier is imported, so the message can point at it
|
||||
const missing = new Map();
|
||||
for (const dir of sources)
|
||||
for await (const file of sourceFiles(dir, excluded)) {
|
||||
const source = await readFile(file, "utf8");
|
||||
for (const specifier of imports(source)) {
|
||||
if (!mustBeExternal(specifier, packages)) continue;
|
||||
if (declared.has(specifier)) continue;
|
||||
const files = missing.get(specifier) ?? [];
|
||||
files.push(file);
|
||||
missing.set(specifier, files);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size === 0) return true;
|
||||
console.error(
|
||||
`${CONFIG} does not declare these imports external, so the component ` +
|
||||
`build would bundle its own copy of them:\n`,
|
||||
`${config} does not declare these imports external, so ${what} ` +
|
||||
`would bundle its own copy of them:\n`,
|
||||
);
|
||||
for (const [specifier, files] of [...missing].sort())
|
||||
console.error(` ${specifier}\n imported by ${files.join(", ")}`);
|
||||
console.error(`\nAdd each one to the \`external\` list in ${CONFIG}.`);
|
||||
process.exit(1);
|
||||
console.error(`\nAdd each one to the \`external\` list in ${config}.\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deliberately no complaint about declarations nothing imports. Some of them
|
||||
// cannot be seen from the source at all — `react/jsx-runtime` is injected by
|
||||
// the JSX transform — and an extra declaration is inert, so there is nothing
|
||||
// to warn about.
|
||||
console.log(
|
||||
`${declared.size} external declarations cover all ${seen.size} imports of ${MUST_BE_EXTERNAL.join(", ")}.`,
|
||||
);
|
||||
let ok = true;
|
||||
for (const target of TARGETS) ok = (await check(target)) && ok;
|
||||
if (!ok) process.exit(1);
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Clones the branch of a dependency's repository that has the same name as
|
||||
# the pull request branch under test, so a change spanning both repositories
|
||||
# can be tested before either side is merged. Inspired by scripts/fetchdep.sh
|
||||
# element-web.
|
||||
#
|
||||
# Usage: scripts/fetchdep.sh <org> <repo> [ref]
|
||||
#
|
||||
# The clone lands in ./<repo>. When the pull request comes from a fork and
|
||||
# PR_HEAD_REPO_OWNER was set, the fork's own copy of the repository is tried
|
||||
# before <org>'s. Exits 0 when a branch was cloned and 1 when there is none,
|
||||
# so the caller can keep the locked version in that case. Without a ref,
|
||||
# nothing is closed outside a pull request.
|
||||
|
||||
set -eu
|
||||
|
||||
org=$1
|
||||
repo=$2
|
||||
ref=${3:-}
|
||||
|
||||
rm -rf "$repo"
|
||||
|
||||
# If a ref was passed, try to clone it or die.
|
||||
if [ -n "$ref" ]; then
|
||||
echo "fetchdep.sh: Using $org/$repo at $ref"
|
||||
git clone --depth 1 "https://github.com/$org/$repo.git" "$repo"
|
||||
git -C "$repo" fetch --depth 1 origin "$ref"
|
||||
git -C "$repo" -c advice.detachedHead=false checkout FETCH_HEAD
|
||||
git -C "$repo" log -1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise ensure that we're on a pull request.
|
||||
head_ref=${GITHUB_HEAD_REF:-}
|
||||
if [ -z "$head_ref" ]; then
|
||||
echo "fetchdep.sh: Not a pull request, nothing to match"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bots name their branches the same way in every repository, so a match on
|
||||
# one of theirs would be a coincidence rather than a paired change.
|
||||
case "$head_ref" in
|
||||
renovate/* | dependabot/*)
|
||||
echo "fetchdep.sh: Not matching bot branch $head_ref"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Clones a branch if it exists and ends the script on success.
|
||||
clone() {
|
||||
local try_org=$1
|
||||
local branch=$2
|
||||
echo "fetchdep.sh: Trying to use $try_org/$repo#$branch"
|
||||
# Disable auth prompts: https://serverfault.com/a/665959
|
||||
if GIT_TERMINAL_PROMPT=0 git clone "https://github.com/$try_org/$repo.git" "$repo" --branch "$branch" --depth 1; then
|
||||
git -C "$repo" log -1
|
||||
# Surface the match on the workflow run summary.
|
||||
echo "::notice title=Matched $repo branch::Using $try_org/$repo#$branch at $(git -C "$repo" rev-parse HEAD)"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Try cloning from the fork, then from the org.
|
||||
head_owner=${PR_HEAD_REPO_OWNER:-}
|
||||
if [ -n "$head_owner" ] && [ "$head_owner" != "${GITHUB_REPOSITORY_OWNER:-}" ]; then
|
||||
clone "$head_owner" "$head_ref"
|
||||
fi
|
||||
clone "$org" "$head_ref"
|
||||
|
||||
# Nothing worked. Die.
|
||||
echo "fetchdep.sh: No branch named $head_ref in $org/$repo"
|
||||
exit 1
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Installs dependencies, then swaps matrix-js-sdk for a branch of the same
|
||||
# name as the pull request branch under test if one exists. This is what
|
||||
# lets CI test an Element Call branch together with a matrix-js-sdk branch
|
||||
# before either is merged. Inspired by scripts/layered.sh in element-web.
|
||||
#
|
||||
# Usage: scripts/layered.sh [pnpm install flags]
|
||||
#
|
||||
# The layering is only performed when package.json uses the develop branch of
|
||||
# matrix-js-sdk and the run is for a pull request whose branch has a matrix-js-sdk
|
||||
# counterpart. Otherwise the dependency is left as pnpm-lock.yaml pins it.
|
||||
#
|
||||
# The matrix-js-sdk checkout is nested inside this directory because some CI
|
||||
# systems do not allow moving above the primary checkout. It is git-ignored.
|
||||
#
|
||||
# Set JS_SDK_GITHUB_BASE_REF to a ref to use that exact ref of matrix-js-sdk
|
||||
# instead of looking for a matching branch.
|
||||
#
|
||||
# This script is only meant to be used in CI. For local development use
|
||||
# see docs/linking.md.
|
||||
|
||||
set -ex
|
||||
|
||||
# Paths below are relative to the repository root.
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
pnpm install --frozen-lockfile "$@"
|
||||
|
||||
js_sdk_dep=$(jq -r '.devDependencies["matrix-js-sdk"]' < package.json)
|
||||
|
||||
# Only layer when package.json follows the branch head rather than pinning a
|
||||
# release, so a release build keeps its pinned version.
|
||||
if [ "$js_sdk_dep" != "github:matrix-org/matrix-js-sdk#develop" ]; then
|
||||
echo "layered.sh: Skipping matrix-js-sdk fetch and install as package.json pins $js_sdk_dep"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! scripts/fetchdep.sh matrix-org matrix-js-sdk "${JS_SDK_GITHUB_BASE_REF:-}"; then
|
||||
echo "layered.sh: No matching matrix-js-sdk branch, keeping the locked version"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# matrix-js-sdk pins its pnpm version in `devEngines` only. Under corepack,
|
||||
# pnpm does not switch versions on its own and corepack, finding no
|
||||
# `packageManager` field there, would walk up to this project's and use
|
||||
# ours. Pinning it in the clone makes every pnpm invocation inside that
|
||||
# directory, including the `prepare` script run by pack, use the right one.
|
||||
js_sdk_pnpm="pnpm@$(jq -r '.devEngines.packageManager.version' < matrix-js-sdk/package.json)"
|
||||
jq --arg pm "$js_sdk_pnpm" '.packageManager = $pm' matrix-js-sdk/package.json > matrix-js-sdk/package.json.tmp
|
||||
mv matrix-js-sdk/package.json.tmp matrix-js-sdk/package.json
|
||||
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
# Install matrix-js-sdk's build dependencies. `prepare` runs on pack below,
|
||||
# so it is skipped here.
|
||||
(cd matrix-js-sdk && pnpm install --frozen-lockfile --ignore-scripts)
|
||||
|
||||
# Rather than `pnpm link`, build matrix-js-sdk into a tarball and install
|
||||
# that. This better reflects a clean install: dependencies may be hoisted
|
||||
# and shared, and there is no tsconfig.json to pick up sources from.
|
||||
(cd matrix-js-sdk && pnpm pack --out matrix-js-sdk.tgz)
|
||||
pnpm install "$(pwd)/matrix-js-sdk/matrix-js-sdk.tgz" "$@"
|
||||
@@ -0,0 +1,66 @@
|
||||
# SDK mode (EXPERIMENTAL, superseded)
|
||||
|
||||
> Superseded by the `sdk/` package (`@element-hq/matrixrtc-sdk`) and its harness in
|
||||
> `sdk/dev`; see `sdk-plan.md`. This folder still builds, with its own script, until
|
||||
> that package can do what this does.
|
||||
|
||||
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
||||
|
||||
It allows to use matrixRTC in combination with livekit without relying on element call.
|
||||
|
||||
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
|
||||
|
||||
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver allowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
|
||||
|
||||
## Getting started
|
||||
|
||||
To get started run
|
||||
|
||||
```
|
||||
pnpm install
|
||||
pnpm build:sdk-target-based-on-call-view-model
|
||||
```
|
||||
|
||||
in the repository root. (`pnpm build:sdk` builds the `sdk/` package instead.)
|
||||
|
||||
CI's `sdk` package (`build:sdk:production`, `build:sdk:development`, the
|
||||
`build-output-sdk` artifact and the `pr<N>-sdk` Netlify preview) is still this
|
||||
bundle, not the `sdk/` package, until its consumers have moved over.
|
||||
|
||||
It will create `dist` containing the compiled js file, `matrixrtc-sdk.js`.
|
||||
|
||||
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
|
||||
|
||||
Now you just need to add the widget to element web via:
|
||||
|
||||
```
|
||||
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||
```
|
||||
|
||||
## Widgets
|
||||
|
||||
The sdk mode is particularly interesting to be used in widgets. In widgets you do not need to pay attention to matrix login/cs api ...
|
||||
To create a widget see the example `index.html` file in this folder. And add it to EW via:
|
||||
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
|
||||
|
||||
### url parameters
|
||||
|
||||
The url parameters are needed to pass initial data to the widget. They will automatically be used
|
||||
by the matrixRTCSdk to start the postmessage widget api (communication between the client (e.g. Element Web) and the widget)
|
||||
|
||||
```
|
||||
widgetId = $matrix_widget_id
|
||||
userId = $matrix_user_id
|
||||
deviceId = $org.matrix.msc3819.matrix_device_id
|
||||
baseUrl = $org.matrix.msc4039.matrix_base_url
|
||||
```
|
||||
|
||||
`parentUrl = // will be inserted automatically`
|
||||
|
||||
Full template use as `<widgetUrl>`:
|
||||
|
||||
```
|
||||
http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||
```
|
||||
|
||||
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
|
||||
@@ -8,7 +8,8 @@ Please see LICENSE in the repository root for full details.
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* This file is the entrypoint for the sdk build of element call: `pnpm build:sdk`
|
||||
* This file is the entrypoint for the sdk build of element call:
|
||||
* `pnpm build:sdk-target-based-on-call-view-model`
|
||||
* use in widgets.
|
||||
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
||||
* can be used to join a rtc session and exchange realtime data.
|
||||
@@ -231,7 +232,7 @@ export async function createMatrixRTCSdk(
|
||||
complete: () => {
|
||||
logger.info("Livekit room items subscription completed");
|
||||
for (const item of callViewModel.livekitRoomItems$.value) {
|
||||
logger.info("unregistering room item from room", item.url);
|
||||
logger.info("unregistering room item from room", item.transport);
|
||||
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
|
||||
}
|
||||
},
|
||||
+94
-41
@@ -1,59 +1,112 @@
|
||||
# SDK mode (EXPERIMENTAL)
|
||||
# MatrixRTC SDK (EXPERIMENTAL)
|
||||
|
||||
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
||||
`@element-hq/matrixrtc-sdk` is the call model under Element Call, on its own:
|
||||
it joins a MatrixRTC slot in a Matrix room, writes and watches the memberships,
|
||||
connects to the media transport (LiveKit today), publishes the local media and
|
||||
exposes every member and their tracks as observables. It has no UI. Element
|
||||
Call's own `CallViewModel` is one consumer of it; any host can build a different
|
||||
view on top. The design is in [`SdkArchitecture.md`](./SdkArchitecture.md), what
|
||||
moved out of Element Call's `src/` in [`SdkMigration.md`](./SdkMigration.md).
|
||||
|
||||
It allows to use matrixRTC in combination with livekit without relying on element call.
|
||||
**Status:** first implementation. `createRTCSlot` and `slot.join()` join the
|
||||
session, connect to the transport, publish the local media and expose every
|
||||
member's media; the development harness and its e2e tests in `playwright/sdk`
|
||||
drive it, and Element Call's own `CallViewModel` is built on it. It depends on
|
||||
nothing in Element Call's `src/`.
|
||||
|
||||
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
|
||||
## Using it
|
||||
|
||||
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver allowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
|
||||
```ts
|
||||
import {
|
||||
createRTCSlot,
|
||||
E2eeType,
|
||||
MatrixRTCMode,
|
||||
ObservableScope,
|
||||
trackBySource$,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
## Getting started
|
||||
const scope = new ObservableScope();
|
||||
const slot = createRTCSlot(
|
||||
scope,
|
||||
client, // a matrix-js-sdk MatrixClient, logged in and syncing
|
||||
room, // the matrix-js-sdk Room, from client.getRoom() once the join has synced
|
||||
{
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
},
|
||||
);
|
||||
const participation = slot.join({
|
||||
publish: [{ source: "microphone" }, { source: "camera" }],
|
||||
});
|
||||
|
||||
To get started run
|
||||
// A <video> for each remote member's camera and an <audio> for its microphone.
|
||||
// A track plays on an element once attached to it; detach before the element
|
||||
// leaves the DOM. (`tileFor`, `setName` and `removeTilesExcept` are the host's.)
|
||||
participation.remoteMembers$.subscribe((members) => {
|
||||
for (const member of members) {
|
||||
const { video, audio } = tileFor(member.memberId);
|
||||
member.displayName$.subscribe((name) => setName(member.memberId, name));
|
||||
trackBySource$(scope, member.tracks$, "camera").subscribe((track) =>
|
||||
track?.attach(video),
|
||||
);
|
||||
trackBySource$(scope, member.tracks$, "microphone").subscribe((track) =>
|
||||
track?.attach(audio),
|
||||
);
|
||||
}
|
||||
removeTilesExcept(members.map((m) => m.memberId));
|
||||
});
|
||||
|
||||
```
|
||||
pnpm install
|
||||
pnpm build:sdk
|
||||
// later
|
||||
participation.leave();
|
||||
scope.end();
|
||||
```
|
||||
|
||||
in the repository root.
|
||||
`participation.localMember$` is one member of the same shape, null until our own
|
||||
membership is in the room; its microphone would only echo.
|
||||
|
||||
It will create a `dist` folder containing the compiled js file.
|
||||
[`dev/main.ts`](./dev/main.ts) is this example as a working page.
|
||||
|
||||
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
|
||||
## Reading the code
|
||||
|
||||
Now you just need to add the widget to element web via:
|
||||
Three files define the surface. Read them in this order.
|
||||
|
||||
```
|
||||
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||
- [`src/api.ts`](./src/api.ts): the MatrixRTC side. An `RTCSlot` is the
|
||||
namespace in a room that memberships join into; `join()` returns an
|
||||
`RTCParticipation`, which lasts until its `leave()` and exposes `state$`,
|
||||
`localMember$` and `remoteMembers$`. Members, transports and the participation
|
||||
state machine live here. `createRTCSlot` in `RTCSlot.ts` is the only way to get
|
||||
an implementation.
|
||||
- [`src/media-api.ts`](./src/media-api.ts): media as a host sees it, on
|
||||
`remoteMembers$` and `localMember$`. `PublishRequest` says what to publish
|
||||
(microphone, camera, screen share); `MediaTrack` is how it arrives. The
|
||||
underlying tracks stay hidden: the host hands an element to `attach` and the
|
||||
SDK plays into it. The local variants carry the controls (`setEnabled`,
|
||||
`setDevice`, `setProcessor`).
|
||||
- [`src/media-backend/api.ts`](./src/media-backend/api.ts): internal, the seam
|
||||
between the session and whatever carries the media. The session speaks
|
||||
MatrixRTC; a `MediaBackend` speaks one media protocol and hands back the
|
||||
`MemberMedia` of each member through `mediaFor()`. A host rarely touches it;
|
||||
it matters when working on the LiveKit backend under
|
||||
`src/media-backend/livekit/`, the only place that imports `livekit-client`, or
|
||||
when writing a new backend.
|
||||
|
||||
## Development
|
||||
|
||||
Everything runs from the repository root. This directory is a pnpm project of its
|
||||
own only so that a host can install it as a git dependency (see
|
||||
`pnpm-workspace.yaml`).
|
||||
|
||||
```sh
|
||||
pnpm dev:sdk # the harness, https://localhost:3002
|
||||
pnpm build:sdk # dist/matrixrtc-sdk.js and dist/types
|
||||
pnpm test:playwright playwright/sdk # needs `pnpm backend`
|
||||
```
|
||||
|
||||
## Widgets
|
||||
## Installing
|
||||
|
||||
The sdk mode is particularly interesting to be used in widgets. In widgets you do not need to pay attention to matrix login/cs api ...
|
||||
To create a widget see the example `index.html` file in this folder. And add it to EW via:
|
||||
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
|
||||
|
||||
### url parameters
|
||||
|
||||
The url parameters are needed to pass initial data to the widget. They will automatically be used
|
||||
by the matrixRTCSdk to start the postmessage widget api (communication between the client (e.g. Element Web) and the widget)
|
||||
|
||||
```
|
||||
widgetId = $matrix_widget_id
|
||||
perParticipantE2EE = true
|
||||
userId = $matrix_user_id
|
||||
deviceId = $org.matrix.msc3819.matrix_device_id
|
||||
baseUrl = $org.matrix.msc4039.matrix_base_url
|
||||
```sh
|
||||
pnpm add github:element-hq/element-call#<ref>&path:/sdk
|
||||
```
|
||||
|
||||
`parentUrl = // will be inserted automatically`
|
||||
|
||||
Full template use as `<widgetUrl>`:
|
||||
|
||||
```
|
||||
http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||
```
|
||||
|
||||
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
|
||||
The host supplies `matrix-js-sdk`, `livekit-client` and `rxjs`; the build leaves
|
||||
them external.
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
# MatrixRTC SDK architecture
|
||||
|
||||
`@element-hq/matrixrtc-sdk` is the model under Element Call, on its own: one
|
||||
`MatrixRTCClient` per room that joins and leaves the MatrixRTC session, discovers
|
||||
and connects to the media transports, publishes the local media, distributes the
|
||||
encryption keys, and hands out every member with its media as observables. It has
|
||||
no UI, reads no configuration and no settings, and never names LiveKit in its
|
||||
interface.
|
||||
|
||||
Element Call becomes one consumer of it: `CallViewModel` keeps what makes a session
|
||||
a _call_ (ringing, auto-leave, layout, sounds, reactions, settings, the host
|
||||
bridge) and takes everything else from the client. The dependency direction is
|
||||
therefore `Element Call → sdk`, never the reverse. What moved out of Element
|
||||
Call's `src/` to get there is listed in [`SdkMigration.md`](./SdkMigration.md).
|
||||
|
||||
## The architecture
|
||||
|
||||
```
|
||||
host application (Element Call, a whiteboard, watch-together, …)
|
||||
│
|
||||
│ createMatrixRTCClient(scope, matrixClient, room, options)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MatrixRTCClient │
|
||||
│ │
|
||||
│ public API, all Behaviors: │
|
||||
│ join() leave() state$ │
|
||||
│ localMember$ remoteMembers$ memberCount$ sendData() data$ │
|
||||
│ keyRotationSuppressed$ connectedTransports$ │
|
||||
│ │
|
||||
│ per member RTCMember { id userId deviceId membership$ displayName$ │
|
||||
│ avatarUrl$ transport$ tracks$ encryptionError$ }│
|
||||
│ per track MediaTrack { source kind muted$ encrypted stats$ attach() } │
|
||||
│ │
|
||||
│ ┌───────────────────────────┐ MediaBackend ┌───────────────────────────┐ │
|
||||
│ │ session: MatrixRTC │ media-backend/ │ media backend │ │
|
||||
│ │ │ api.ts │ media-backend/livekit/ │ │
|
||||
│ │ js-sdk MatrixRTCSession, │ │ today, or options.backend │ │
|
||||
│ │ later the rust-rtc │ prepareLocal │ │ │
|
||||
│ │ crate behind a bridge │ Transport ───▶│ auth: MSC4195 CS API or │ │
|
||||
│ │ memberships from room │ delegateDelayed │ JWT service, by shape │ │
|
||||
│ │ state and sticky events │ Leave ───────▶│ and role; delegation │ │
|
||||
│ │ own membership, delayed │ │ ConnectionMap: one │ │
|
||||
│ │ leave, retries │ mediaKeys$ ────▶│ LiveKit room per │ │
|
||||
│ │ discovery of the local │ mediaFor$ ─────▶│ transport locator, │ │
|
||||
│ │ transport (homeserver) │ ◀─ tracks$, │ local published, │ │
|
||||
│ │ membership state machine │ encryptionError$│ remote subscribed │ │
|
||||
│ │ (LocalMembership) │ local.publish, │ Publisher: local tracks, │ │
|
||||
│ │ transport registry │ setPublishing▶│ upstream paused until │ │
|
||||
│ │ (TransportMetadata) │ ◀─ connections$ │ joined │ │
|
||||
│ │ data packet → member │ ◀─ data$ │ LivekitMemberMedia: │ │
|
||||
│ │ │ │ participant → tracks │ │
|
||||
│ └───────────────────────────┘ └───────────────────────────┘ │
|
||||
│ │
|
||||
│ RTCMember = membership + mediaFor$(membership): the backend matches the │
|
||||
│ member's backendIdentities to the identity it sees on the transport │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
MatrixClient (sync, room state, to-device, OpenID) LiveKit SFU
|
||||
```
|
||||
|
||||
The left box is MatrixRTC: who is in the session, with which transport, the
|
||||
keys, the delayed leave, and the membership state machine. The right box is a
|
||||
media backend: whatever carries the media, behind the `MediaBackend` interface in
|
||||
`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.
|
||||
|
||||
### How Element Call uses it
|
||||
|
||||
```
|
||||
Element Call, as app, widget or component
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ views: InCallView, tiles, header, footer, settings │
|
||||
│ ▲ Behaviors │
|
||||
│ CallViewModel │
|
||||
│ layout, ringing and notifications, auto-leave, sounds, │
|
||||
│ reactions, hand raise, settings, audio routing, host bridge │
|
||||
│ ▲ state$, localMember$, remoteMembers$, tracks$ │
|
||||
│ MatrixRTCClient @element-hq/matrixrtc-sdk │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
│ MatrixClient + Room │ livekit-client
|
||||
▼ ▼
|
||||
homeserver SFU
|
||||
```
|
||||
|
||||
All three modes already hold a `MatrixClient` and a `Room` by the time a call
|
||||
starts: standalone after `useLoadGroupCall`, the component from the host's `client`
|
||||
prop, the widget from `createRoomWidgetClient`. The view model passes them on and
|
||||
builds its media view models from `RTCMember` and its `tracks$` instead of from
|
||||
LiveKit participants.
|
||||
|
||||
### Joining, in order
|
||||
|
||||
```
|
||||
host MatrixRTCClient / backend homeserver / JWT service LiveKit SFU
|
||||
│ create ───▶│ │ │
|
||||
│ │ GET rtc/transports (session) ────────▶│ │
|
||||
│ │◀── first transport of the backend's type │
|
||||
│ │ prepareLocalTransport (backend): │ │
|
||||
│ │ publisher token: CS API get_token, │ │
|
||||
│ │ or OpenID token then /sfu/get ─────▶│ │
|
||||
│ │◀── sfu url, jwt, alias, identity ─────│ = resolved$ of the │
|
||||
│ │ probe delegate_delayed_leave ──────▶│ local transport │
|
||||
│ │ connect(url, jwt) ───────────────────────────────────────────▶│
|
||||
│ join() ───▶│ (once the publisher holds its token)│ │
|
||||
│ │ membership state event + delayed leave ──▶│ │
|
||||
│ │ delegateDelayedLeave(delayId) (backend) ─▶│ │
|
||||
│ │ create tracks, resume upstream ──────────────────────────────▶│
|
||||
│ │ media key to every other member (to-device) ──▶│ │
|
||||
│ │◀── other memberships (sync) ──────────│◀── participants ────────│
|
||||
│◀─ state$ connected, localMember$, remoteMembers$ with tracks$ ───────────────│
|
||||
```
|
||||
|
||||
The session discovers the transport from the homeserver and the backend prepares
|
||||
it, both before `join()`, so that the membership can name it and the first frames
|
||||
go out as soon as the state event is sent. Preparing is the backend's own
|
||||
business: for LiveKit the token exchange for the publisher connection, and the
|
||||
probe that decides whether the service that issued the token can take over the
|
||||
delayed leave, which picks the delayed leave timings the join uses. The join
|
||||
waits for the publisher to hold its token, so that the LiveKit room exists
|
||||
before a subscriber, who lacks the permission to create it, asks for a token
|
||||
of its own. Until `join()` the tracks exist but their upstream is paused: a host
|
||||
can show a preview without anyone hearing it. The upstream flows exactly while the
|
||||
membership is `joined`: a sync outage pauses it, and `leave()` pauses it for good
|
||||
and sends the leave.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
The words the SDK is written in. They name the shape after the slot and
|
||||
participation split in
|
||||
[`client-into-slot-session-split-plan.md`](./client-into-slot-session-split-plan.md);
|
||||
the sections below still describe `MatrixRTCClient` until that lands.
|
||||
|
||||
| Word | Meaning here | Elsewhere |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||
| slot | an `RTCSlot`: the namespace in a room that memberships join into, `application` plus `id`. It exists while the room does, with nobody in it too. Holds `members$`, its open or closed `status$`, and `join()` | the js-sdk `MatrixRTCSession` is one per slot; the MSC4143 slot event |
|
||||
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `state$`, `localMember$`, `remoteMembers$`, the connected transports and the data channel. Made by `slot.join()`, never reused | "session" is not used here, because in the js-sdk it means the slot |
|
||||
| member | an `RTCMember`: one membership in a slot with what the SDK derives from it, `rtcBackendIdentity`, `userId`, `deviceId`, `memberId`, `displayName$`, `avatarUrl$`, `transport$` and `applicationData$`. No media. What the slot hands out in `members$` | the js-sdk `CallMembership` behind it, which never leaves the SDK; Element Call's old "matrixLivekitMember" |
|
||||
| local member, remote member | `LocalRTCMember` and `RemoteRTCMember`: an `RTCMember` plus its `MemberMedia`, which only a participation can carry. The local one's tracks carry the controls. What the participation hands out in `localMember$` and `remoteMembers$` | LiveKit `LocalParticipant` and `RemoteParticipant`, which never leave the SDK |
|
||||
| media | a `MemberMedia`: what a member sends once it has arrived on its transport, `tracks$`, null while waiting, and `encryptionError$` beside it. The media backend supplies exactly these two fields per member | Element Call's `MediaViewModel` is a view of it |
|
||||
| track | a `MediaTrack`: one publication of a member, audio or video, with `source`, `kind`, `id` and the behaviors on it. `LocalAudioMediaTrack` and `LocalVideoMediaTrack` add the controls over our own | LiveKit `TrackPublication` and `Track`, which never leave the SDK |
|
||||
| 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 |
|
||||
|
||||
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
|
||||
`RTCMember`, `matrixRTCMode`.
|
||||
|
||||
## Reactive primitives
|
||||
|
||||
The whole public API is made of `Behavior`s: an rxjs observable that always holds a
|
||||
current value (`.value`) and replays it on subscription. A consumer subscribes to
|
||||
what it renders and reads `.value` where it needs the present state. Everything is
|
||||
bound to an `ObservableScope` the host creates; ending the scope ends the client,
|
||||
its connections and its tracks. Both come from the SDK (`Behavior`, `constant`,
|
||||
`ObservableScope`); the host needs rxjs and nothing else.
|
||||
|
||||
## What a host must provide
|
||||
|
||||
Found the hard way while building the harness; each one fails in a way that looks
|
||||
like an SDK bug.
|
||||
|
||||
- **The room the sync loop maintains.** `client.getRoom(roomId)` once the join has
|
||||
synced. `joinRoom` returns a detached copy for a room joined just now, and a
|
||||
client built on it never sees a member.
|
||||
- **Power to send the membership event.** Every member needs to write
|
||||
`org.matrix.msc3401.call.member` (and the sticky event in Matrix 2.0 mode). A
|
||||
plain room only lets its creator send state; Element Call's rooms set the level
|
||||
to 0.
|
||||
- **An encrypted room for per-participant keys.** Media keys travel in encrypted
|
||||
to-device messages, which the crypto only delivers to devices it tracks, which
|
||||
are the members of encrypted rooms. In an unencrypted room the key never arrives
|
||||
and every remote track decodes nothing.
|
||||
- **A logged-in, syncing client with crypto set up**, and the transport advertised
|
||||
by the homeserver (`rtc/transports`). The SDK has no fallback url of its own.
|
||||
|
||||
## The client: `MatrixRTCClient`
|
||||
|
||||
Rule of thumb for what belongs here: if it would be the same for a different
|
||||
application on top of the same MatrixRTC session, it is in the client. If it is
|
||||
specific to calls, or depends on the window, a setting, a platform, or a React
|
||||
context, it is in the view model.
|
||||
|
||||
### Creating one
|
||||
|
||||
```ts
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
export interface MatrixRTCClientOptions {
|
||||
encryptionSystem: EncryptionSystem;
|
||||
/** Resolved by the host; the SDK reads neither config.json nor settings. */
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
/** Published at the join; `publish` on the local member adds to it from then on. */
|
||||
publish: PublishRequest[];
|
||||
/**
|
||||
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
|
||||
* join itself, so it is here even though it is named after calls; reacting
|
||||
* to a notification (ringing, timeouts, declines) is the application's job.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/** The application the session belongs to, as named in the membership. Default `m.call`. */
|
||||
application?: string;
|
||||
/** The application's slot in the room. Default `ROOM`. */
|
||||
slot?: string;
|
||||
/**
|
||||
* Whatever the application wants to say about itself in the membership.
|
||||
* Opaque to the SDK. Today the js-sdk carries one key, `m.call.intent`
|
||||
* (`"audio"` or `"video"`); anything else is dropped until it can.
|
||||
*/
|
||||
applicationData?: Record<string, unknown>;
|
||||
/** Session timings the host has configured; the defaults otherwise. */
|
||||
timings?: Partial<SessionTimings>;
|
||||
/** Limits on what is published; LiveKit's defaults otherwise. */
|
||||
mediaQuality?: MediaQuality;
|
||||
/** Use this transport instead of asking the homeserver. */
|
||||
transport?: Transport;
|
||||
/** Use this transport when the homeserver advertises none of the backend's type. */
|
||||
fallbackTransport?: Transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* One thing to publish: a source, where to capture it from and how to encode
|
||||
* it. Where the device and the settings are left out, the browser's and
|
||||
* LiveKit's defaults apply.
|
||||
*/
|
||||
export type PublishRequest =
|
||||
| { source: "microphone"; deviceId?: string; capture?: AudioCaptureSettings }
|
||||
| {
|
||||
source: "camera";
|
||||
deviceId?: string;
|
||||
/** Background blur and the like. */
|
||||
processor?: VideoProcessor;
|
||||
capture?: VideoCaptureSettings;
|
||||
}
|
||||
| {
|
||||
source: "screenShare";
|
||||
/** Whether to capture the screen's audio too. Default true. */
|
||||
audio?: boolean;
|
||||
capture?: VideoCaptureSettings;
|
||||
};
|
||||
```
|
||||
|
||||
Device enumeration, permission prompts and the lobby preview stay with the
|
||||
host: it hands over publish requests naming the devices it chose.
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Takes the whole MatrixClient, not a narrow pick, and finds the MatrixRTC
|
||||
* session itself. The js-sdk is the MatrixRTC implementation today; when the
|
||||
* rust-rtc crate replaces it, the SDK has to bridge the crate to the js-sdk
|
||||
* client (events, state, to-device, delayed events, OpenID), and only the SDK
|
||||
* knows what that bridge needs. Holding the client keeps that change inside
|
||||
* the SDK. The js-sdk session is not part of the public API.
|
||||
*
|
||||
* The room has to be the one the client's sync loop maintains; see "What a
|
||||
* host must provide".
|
||||
*/
|
||||
export function createMatrixRTCClient(
|
||||
scope: ObservableScope,
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
options: MatrixRTCClientOptions,
|
||||
): MatrixRTCClient;
|
||||
```
|
||||
|
||||
### The client
|
||||
|
||||
```ts
|
||||
/** The homeserver link that is down, in the order they depend on each other. */
|
||||
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||
|
||||
/** Why the participation is not connected: the first failing link, in dependency order. */
|
||||
export type DisconnectReason = MatrixDisconnectReason | "media";
|
||||
|
||||
export type ParticipationState =
|
||||
| { kind: "waitingForTransport" }
|
||||
| { kind: "connecting" }
|
||||
| { kind: "connected" }
|
||||
| { kind: "reconnecting"; reason: DisconnectReason }
|
||||
/** A failed publication is not fatal: the member can still receive, so it stays on the local member's media. */
|
||||
| { kind: "failed"; error: MatrixRTCError }
|
||||
/** `leave()` was called. Terminal. */
|
||||
| { kind: "left" };
|
||||
|
||||
/**
|
||||
* An error raised by the client. `code` and `category` say what went wrong in
|
||||
* a form a host can translate; `cause` holds the backend's error.
|
||||
*/
|
||||
export class MatrixRTCError extends Error {
|
||||
code: ErrorCode;
|
||||
category: ErrorCategory;
|
||||
}
|
||||
|
||||
export interface MatrixRTCClient {
|
||||
join(): void;
|
||||
leave(): void;
|
||||
/**
|
||||
* The Matrix membership combined with the local media connection. One
|
||||
* value: a reason exists only while `reconnecting`, an error only once
|
||||
* `failed`.
|
||||
*/
|
||||
state$: Behavior<ParticipationState>;
|
||||
|
||||
/** Null until our own membership has been seen in the room. */
|
||||
localMember$: Behavior<LocalRTCMember | null>;
|
||||
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||
/** `remoteMembers.length`, plus one for the local member once it exists. One per membership, so one user may count several times. */
|
||||
memberCount$: Behavior<number>;
|
||||
|
||||
/**
|
||||
* Whether the session has grown large enough that MatrixRTC has stopped
|
||||
* rotating the media encryption key.
|
||||
*/
|
||||
keyRotationSuppressed$: Behavior<boolean>;
|
||||
|
||||
/**
|
||||
* Transports the client currently holds a live connection to. The
|
||||
* connections themselves stay internal; this is what a developer panel
|
||||
* needs.
|
||||
*/
|
||||
connectedTransports$: Behavior<TransportMetadata[]>;
|
||||
|
||||
/**
|
||||
* Plays the members' audio on this device from now on. Rejects where the
|
||||
* browser cannot switch to it. A host that routes audio itself, or leaves
|
||||
* the browser's choice, never calls it.
|
||||
*/
|
||||
setAudioOutputDeviceId(deviceId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sends a short text to every member on the local transport, over a
|
||||
* reliable data channel beside the media. A packet holds about 15 KiB;
|
||||
* anything larger belongs in a room event. Rejects while the local
|
||||
* transport is not connected. Encrypted on the wire like the media, but
|
||||
* not end to end with the media key.
|
||||
*/
|
||||
sendData(topic: string, text: string): Promise<void>;
|
||||
/**
|
||||
* What remote members sent with `sendData`, on every transport the client
|
||||
* is connected to. A message from an identity that is not a member is
|
||||
* dropped, so a host only ever hears from attested members.
|
||||
*/
|
||||
data$: Observable<DataMessage>;
|
||||
}
|
||||
|
||||
/** One `sendData` call as it arrives at a remote member. */
|
||||
export interface DataMessage {
|
||||
member: RemoteRTCMember;
|
||||
topic: string;
|
||||
text: string;
|
||||
}
|
||||
```
|
||||
|
||||
`state$` is two state machines, one per box in the diagram, combined by one pure
|
||||
function. `LocalMembership` knows only the Matrix side: `waitingForTransport`,
|
||||
`joining`, `joined`, `reconnecting` with a `MatrixDisconnectReason`, `failed`,
|
||||
`left`. The client folds the local media connection in: `joined` with the media
|
||||
up is `connected`, a media drop after that is `reconnecting: "media"`, a
|
||||
connection error is `failed`. Each step is computed in priority order from the
|
||||
previous state, so `left` and `failed` absorb everything after them, and
|
||||
`reconnecting` is only ever reached from `connected`; a membership that drops
|
||||
before the media ever came up stays `connecting`. One reason, not a set, because
|
||||
the links are a chain: without sync the membership is unknowable, and the media
|
||||
is worth little while the Matrix side is down, so the first broken link in that
|
||||
order is what a host shows and what Element Call's analytics count. A failed
|
||||
publication is not in here at all: the member can still receive, so it stays on
|
||||
the local member's media.
|
||||
|
||||
### Members
|
||||
|
||||
```ts
|
||||
/**
|
||||
* What a membership says, as hosts read it. The js-sdk `CallMembership` is
|
||||
* behind it; anything beyond this is the js-sdk's API, not the SDK's.
|
||||
*/
|
||||
export type RTCMembership = Pick<
|
||||
CallMembership,
|
||||
| "userId"
|
||||
| "deviceId"
|
||||
| "memberId"
|
||||
| "rtcBackendIdentity"
|
||||
| "application"
|
||||
| "applicationData"
|
||||
| "getTransport"
|
||||
| "transports"
|
||||
| "createdTs"
|
||||
| "getAbsoluteExpiry"
|
||||
>;
|
||||
|
||||
export interface RTCMember {
|
||||
local: boolean;
|
||||
/** The membership's `rtcBackendIdentity`, `${userId}:${deviceId}`: a key and a debug label. */
|
||||
rtcBackendIdentity: string;
|
||||
/**
|
||||
* Every identity the member may publish under on its transport, one per
|
||||
* authentication flow the transport allows: the hashed identity the
|
||||
* MSC4195 client-server API assigns, the legacy one the JWT service's
|
||||
* `/sfu/get` assigns, or both for a transport carrying both a `url` and a
|
||||
* `livekit_service_url`. The backend matches whichever shows up.
|
||||
*/
|
||||
backendIdentities: readonly string[];
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
membership$: Behavior<RTCMembership>;
|
||||
/**
|
||||
* Matrix room state rather than MatrixRTC, but the SDK already holds the
|
||||
* room and every consumer wants them next to the membership. Disambiguated
|
||||
* among the members of the session.
|
||||
*/
|
||||
displayName$: Behavior<string>;
|
||||
avatarUrl$: Behavior<string | undefined>;
|
||||
/** Which transport this member is on; undefined when the membership names none. */
|
||||
transport$: Behavior<TransportMetadata | undefined>;
|
||||
/**
|
||||
* The member's tracks, in publication order, once it has shown up on its
|
||||
* transport; null until then ("waiting for media"). An entry stays the same
|
||||
* object for as long as the same publication is behind it. Which track is
|
||||
* which is in its `source`; `trackBySource$` picks one out. Hand raise and
|
||||
* reactions are room events keyed by member and stay with the host for now;
|
||||
* they are the obvious next fields here.
|
||||
*/
|
||||
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
|
||||
/** Emits when the SFU reports a key problem for this member. */
|
||||
encryptionError$: Observable<EncryptionError>;
|
||||
}
|
||||
|
||||
export interface RemoteRTCMember extends RTCMember {
|
||||
local: false;
|
||||
}
|
||||
|
||||
export interface LocalRTCMember extends RTCMember {
|
||||
local: true;
|
||||
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
|
||||
/**
|
||||
* Publishes a source and resolves with its track once it is in `tracks$`.
|
||||
* Before the transport is connected the request is remembered and applied
|
||||
* once it is. Rejects where the device could not be used, including the
|
||||
* user closing the picker. One publication per source: publishing a source
|
||||
* again unmutes it.
|
||||
*/
|
||||
publish(
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||
/** Removes one of our tracks; a screen share takes its audio with it. */
|
||||
unpublish(id: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Transports
|
||||
|
||||
```ts
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
/**
|
||||
* One transport advertised in a membership. Transport independent: `type` and
|
||||
* `id` are all the SDK needs; `raw` and `resolved$` are there for a
|
||||
* backend-specific developer panel and for connection diagnostics. One object
|
||||
* per transport, however many memberships name it.
|
||||
*/
|
||||
export interface TransportMetadata {
|
||||
/** `"livekit"` today. */
|
||||
type: string;
|
||||
/** Stable key, unique per transport in the session: the raw transport, serialised with sorted keys. */
|
||||
id: string;
|
||||
/** The transport object as it appears in the membership, e.g. `{ type, livekit_service_url }`. */
|
||||
raw: Transport;
|
||||
/**
|
||||
* What the backend had to fetch before it could connect. Undefined until
|
||||
* the connection has resolved it, and again after the connection stops or
|
||||
* fails. The local transport resolves before the join, since the membership
|
||||
* needs it; remote ones when their connection starts.
|
||||
*/
|
||||
resolved$: Behavior<ResolvedTransport | undefined>;
|
||||
}
|
||||
|
||||
export type ResolvedTransport =
|
||||
| {
|
||||
type: "livekit";
|
||||
/** The SFU websocket url, as opposed to the JWT service url in `raw`. */
|
||||
url: string;
|
||||
/** A secret: fit for a developer panel, never for a log line. */
|
||||
token: string;
|
||||
roomAlias: string;
|
||||
identity: string;
|
||||
}
|
||||
| { type: string; [key: string]: unknown };
|
||||
```
|
||||
|
||||
### Call-specific things kept out of the client
|
||||
|
||||
Checked against `CallViewModel` and its options. These stay in Element Call because
|
||||
they only make sense for a call:
|
||||
|
||||
| Today | Why it is call-specific | Where it lives instead |
|
||||
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `autoLeave$` / `AutoLeaveReason` (`"allOthersLeft" \| "timeout" \| "decline"`) | hanging up because the other side declined or everyone left is call etiquette, not session semantics | `CallViewModel.leave$`, built from `remoteMembers$` and the room timeline |
|
||||
| `ringAttempts$`, `createCallNotificationLifecycle$`, `createSentCallNotification$`, `createReceivedDecline$` | ringing, pickup timeouts and decline events are MSC4075 call-notify UX | `src/state/CallViewModel/CallNotificationLifecycle.ts`, fed by `remoteMembers$` and the Matrix room |
|
||||
| `waitForCallPickup`, `autoLeaveWhenOthersLeft` options | inputs to the above | `CallViewModelOptions` |
|
||||
| `hideScreensharing` option | a UI policy; the session can always share a screen if the platform can | `CallViewModel` offers no screen share control |
|
||||
| `ringingVm$`, `ringingStatusLocation` | view models | `CallViewModel` |
|
||||
| `hostBridge.hangUp$`, `hangup()`, `userHangup$` | widget / host integration | `CallViewModel` |
|
||||
| `ElementCallError` | carries Element Call translation keys | the view model wraps `MatrixRTCError`, reading `cause` |
|
||||
| `participantCount$` sound limits, join/leave sounds | call UX | `CallViewModel` |
|
||||
| `callIntent` | what a membership says about the call | `applicationData["m.call.intent"]`, set by the host |
|
||||
|
||||
Kept despite the name: `sendNotificationType`, because the js-sdk join sends the
|
||||
notification as part of entering the session. Only _reacting_ to it is
|
||||
call-specific.
|
||||
|
||||
## Media backend: `MediaBackend`
|
||||
|
||||
The client speaks MatrixRTC: memberships, the transports named in them, the
|
||||
delayed leave, the keys. What carries the media is a backend behind one
|
||||
interface, `MediaBackend` in `sdk/src/media-backend/api.ts`, created once per client.
|
||||
LiveKit under `sdk/src/media-backend/livekit/` is the only backend today, and the only
|
||||
place in the SDK that imports `livekit-client`; the import boundary lint rule
|
||||
enforces that outside `sdk/src/media-backend/` the LiveKit packages appear in type
|
||||
imports only.
|
||||
|
||||
The seam sits above the connection manager on purpose. One connection per
|
||||
transport url, with a subscribe-only connection to every remote SFU, is the
|
||||
LiveKit multi-SFU strategy, not a MatrixRTC concept: a cascading SFU keeps the
|
||||
membership transport as it is and only changes which SFUs the client connects to,
|
||||
and full mesh has one peer connection per member and no token service. So a
|
||||
backend is handed the whole session's view and gives back media:
|
||||
|
||||
```ts
|
||||
/** The fields of a member the backend supplies, as the member exposes them. */
|
||||
type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
|
||||
|
||||
export interface MediaBackend {
|
||||
readonly transportType: string; // "livekit"
|
||||
/** Authenticate with the transport the client found; the probe for delegation runs here. */
|
||||
prepareLocalTransport(
|
||||
transport: Transport,
|
||||
): Promise<{ canDelegateDelayedLeave: boolean }>;
|
||||
/** Hand over the delayed leave, each time the membership manager has a new delay id. */
|
||||
delegateDelayedLeave(delayId: string): Promise<void>;
|
||||
readonly local: LocalMediaBackend; // tracks$ encryptionError$ connectionState$ setPublishing publishError$ publish unpublish
|
||||
/** A remote member's media; also how the backend learns which transports to connect to. */
|
||||
mediaFor$(
|
||||
scope: ObservableScope,
|
||||
membership$: Behavior<RTCMembership>,
|
||||
): MemberMediaFields;
|
||||
readonly connections$: Behavior<BackendConnection[]>; // transport, state, resolved; diagnostics
|
||||
sendData(topic: string, text: string): Promise<void>;
|
||||
readonly data$: Observable<DataPacket>;
|
||||
}
|
||||
```
|
||||
|
||||
What stays on the client's side: discovery of the local transport from the
|
||||
homeserver's list (a homeserver endpoint, so not a backend's), the membership
|
||||
state machine in `LocalMembership.ts` and its combination with
|
||||
`local.connectionState$` into `state$`, the choice of delayed leave timings,
|
||||
mapping a data packet's sender to a member, and the transport registry, which keys on the raw
|
||||
transport serialised with sorted keys and derives `connectedTransports$` and each
|
||||
`TransportMetadata.resolved$` from `connections$`.
|
||||
|
||||
The backend's context carries no Matrix client and no membership list. A backend
|
||||
that needs the client takes a `Pick` of exactly what it calls in its own options
|
||||
(`createLivekitBackend(scope, context, { client, mediaQuality })`),
|
||||
and learns of remote members only through `mediaFor$`, registering each
|
||||
membership's transport for as long as the member's scope lives. Media keys reach
|
||||
it as a stream, `mediaKeys$`, replayed on subscribe; the js-sdk session never
|
||||
does. A host supplies its own backend through `MatrixRTCClientOptions.backend`;
|
||||
without one, LiveKit is built from the LiveKit-flavoured client options.
|
||||
|
||||
### Authenticating with a LiveKit transport
|
||||
|
||||
A LiveKit transport comes in three shapes while MSC4195 settles: an SFU `url`,
|
||||
a JWT service `livekit_service_url`, or both. `media-backend/livekit/auth/`
|
||||
fronts the two flows and chooses between them per connection, from the shape
|
||||
and from the role we take on the transport:
|
||||
|
||||
| Transport | Publisher | Subscriber |
|
||||
| -------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `url` only | client-server API `get_token`, no fallback | client-server API `get_token`, no fallback |
|
||||
| `livekit_service_url` only | JWT service `/sfu/get` (legacy identity) | JWT service `/get_token` (hashed identity) |
|
||||
| both | JWT service `/sfu/get` (legacy identity) | client-server API, falling back to the JWT service `/get_token` where the homeserver, or a widget host, has no MSC4195 |
|
||||
|
||||
A publisher on a transport with a service URL keeps the legacy
|
||||
`<user_id>:<device_id>` identity, because clients predating MSC4195 match a
|
||||
membership against that and nothing else; a subscriber is matched by nobody and
|
||||
takes the more private hashed identity. A publisher never falls back: a
|
||||
homeserver that advertises a `url` without implementing MSC4195 is misconfigured
|
||||
and fails with `NoMatrix2AuthorizationService`, so the admin hears about it.
|
||||
The MatrixRTC mode plays no part in the choice. The delayed leave is delegated
|
||||
through the same flow that issued the publisher's token, so that the identity
|
||||
the service watches for on the SFU is the one we publish under, and the
|
||||
delegation probe before the join asks only that service.
|
||||
|
||||
The client-server API needs the name of the homeserver the transport belongs to:
|
||||
our own homeserver asks that server for the token, over federation where it is
|
||||
not ours. The backend derives it from the member's user id, and from our own for
|
||||
the local transport, and keys its connections on the transport plus the server
|
||||
name where the transport has a `url`. A JWT service is reached directly, so
|
||||
members of several homeservers advertising the same one share a connection.
|
||||
|
||||
## Media: `tracks$` and `MediaTrack`
|
||||
|
||||
Every member has media. Today, in Element Call, that media _is_ a `livekit-client`
|
||||
participant, handed straight through to the view models. In the SDK the participant
|
||||
becomes the member's `tracks$`, a list of `MediaTrack`s, so that nothing outside
|
||||
the SDK imports `Participant`, `TrackPublication`, `Track` or `TrackReference`.
|
||||
The adapter in `sdk/src/media-backend/livekit/` is the only place that reads a
|
||||
participant. The backend supplies the same two fields through `mediaFor$`, so
|
||||
there is one shape from the LiveKit participant to the host and nobody has to
|
||||
subscribe through one Behavior to reach another.
|
||||
|
||||
```ts
|
||||
export type MediaSource =
|
||||
| "microphone"
|
||||
| "camera"
|
||||
| "screenShare"
|
||||
| "screenShareAudio"
|
||||
/** Published without a source; the application knows what it is. */
|
||||
| "unknown";
|
||||
|
||||
export type MediaStreamStats =
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined;
|
||||
|
||||
/** One published track of a member. */
|
||||
export interface MediaTrack {
|
||||
source: MediaSource;
|
||||
kind: "audio" | "video";
|
||||
/** Stable for the life of the track. */
|
||||
id: string;
|
||||
muted$: Behavior<boolean>;
|
||||
/** False when the SFU reports the track as unencrypted. Fixed for the life of the track. */
|
||||
encrypted: boolean;
|
||||
/**
|
||||
* Inbound statistics for a remote track, outbound for a local one, polled
|
||||
* every `intervalMs` while the observable is subscribed and not at all
|
||||
* otherwise, so a statistics panel costs nothing while closed.
|
||||
*/
|
||||
stats$(intervalMs: number): Observable<MediaStreamStats>;
|
||||
/**
|
||||
* Rendering. The view hands its <video> or <audio> element over; the SDK
|
||||
* sets its stream and, for video, registers the size and on-screen
|
||||
* observers that pick a simulcast layer and pause the subscription while
|
||||
* the element is hidden. attach is idempotent per element; detach has to be
|
||||
* called before the element leaves the DOM so those observers are released.
|
||||
*/
|
||||
attach(element: HTMLMediaElement): void;
|
||||
detach(element: HTMLMediaElement): void;
|
||||
}
|
||||
|
||||
export interface AudioMediaTrack extends MediaTrack {
|
||||
kind: "audio";
|
||||
/**
|
||||
* Whether the track carries sound right now, as the backend measures it.
|
||||
* False while muted. A call reads this on the microphone track and calls it
|
||||
* "speaking". LiveKit measures it per member, so every audio track of a
|
||||
* member reports the same value.
|
||||
*/
|
||||
isActive$: Behavior<boolean>;
|
||||
/**
|
||||
* How loud the track is right now, 0 to 1, as the backend measures it. 0
|
||||
* while muted. Not what decides `isActive$`: the backend applies its own
|
||||
* threshold and hysteresis to that.
|
||||
*/
|
||||
audioLevel$: Behavior<number>;
|
||||
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
|
||||
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
|
||||
/** No-op for our own audio, which is never played back. */
|
||||
setVolume(volume: number): void;
|
||||
}
|
||||
|
||||
export interface VideoMediaTrack extends MediaTrack {
|
||||
kind: "video";
|
||||
}
|
||||
|
||||
/** The controls a member has over a track it publishes itself. */
|
||||
export interface LocalMediaTrack {
|
||||
/** Mutes or unmutes. Resolves with the state that resulted. */
|
||||
setEnabled(enabled: boolean): Promise<boolean>;
|
||||
/** Captures from another device. Rejects for a screen share, which has none. */
|
||||
setDevice(deviceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalAudioMediaTrack
|
||||
extends AudioMediaTrack, LocalMediaTrack {}
|
||||
|
||||
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
|
||||
/** For mirroring; undefined where the camera does not say which way it faces. */
|
||||
facingMode$: Behavior<"user" | "environment" | undefined>;
|
||||
/** Restarts the camera facing the other way; resolves with the device now in use. */
|
||||
switchFacingMode(): Promise<string | undefined>;
|
||||
/** Background blur and the like; undefined removes the processor. */
|
||||
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
export type EncryptionError = "MissingKey" | "InvalidKey";
|
||||
|
||||
/** The member's first track of a source; undefined while there is none, or no media yet. */
|
||||
export function trackBySource$<S extends MediaSource>(
|
||||
scope: ObservableScope,
|
||||
tracks$: Behavior<MediaTrack[] | null>,
|
||||
source: S,
|
||||
): Behavior<TrackOfSource<S> | undefined>;
|
||||
```
|
||||
|
||||
### Rendering: `attach` and `detach`
|
||||
|
||||
The view passes its DOM element into the SDK and the SDK does the rest; there is no
|
||||
`MediaStreamTrack` on the interface. The reason is adaptive stream: Element Call
|
||||
runs with `adaptiveStream: true`, which means LiveKit decides the simulcast layer
|
||||
from the size of the attached video element and pauses the subscription at the
|
||||
SFU while no attached element is on screen. Those size and visibility observers are
|
||||
set up inside LiveKit's `attach`. Handing the element over is the only way to keep
|
||||
that without reimplementing the observers outside the SDK. The e2e tests check both
|
||||
effects: a larger tile receives a higher resolution, a hidden tile stops receiving
|
||||
frames.
|
||||
|
||||
What `attach` covers for free, so the host does not have to: `srcObject` and
|
||||
`autoplay` setup with the Safari and Firefox black-video workarounds; `play()` and
|
||||
the autoplay-blocked signal; following the track through mute, reconnect and
|
||||
re-subscription into every attached element; the Web Audio graph for earpiece pan
|
||||
and gain; the adaptive stream observers.
|
||||
|
||||
The interface is web-only. A non-DOM host would need a different media surface; if
|
||||
one shows up, a `MediaStreamTrack` accessor is added next to `attach`, not instead
|
||||
of it.
|
||||
|
||||
## Packaging
|
||||
|
||||
`sdk/` is packaged exactly as `component/` is: a `package.json` with peer
|
||||
dependencies (`matrix-js-sdk`, `livekit-client`, `rxjs`), `exports` with types,
|
||||
type emission through `sdk/tsconfig.build.json`, an externals list in
|
||||
`vite-sdk.config.ts` that `pnpm lint:externals` enforces, and installation from the
|
||||
repository as a git dependency with a `path:` suffix. `pnpm build:sdk` produces
|
||||
`sdk/dist`. It is not a pnpm workspace package yet; the root is not a workspace, and
|
||||
moving to one later carries the package name, entry point and externals over
|
||||
unchanged.
|
||||
|
||||
Element Call consumes the SDK by its package name from day one, so the swap to a
|
||||
published dependency is one line: `tsconfig.json` `paths` and the `resolve.alias`
|
||||
in `vitePluginsConfig` both map `@element-hq/matrixrtc-sdk` to `sdk/index.ts`. The
|
||||
alias has to live in `vitePluginsConfig`, which every config is built on. Put only
|
||||
into the app's own config, the harness resolves the name through `sdk/package.json`
|
||||
to whatever stale `sdk/dist` is lying around.
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
sdk/
|
||||
index.ts the entry point: re-exports the API and the primitives
|
||||
src/api.ts the public types above: client, members, transports
|
||||
src/media-api.ts the public media types: publish requests, tracks
|
||||
src/errors.ts MatrixRTCError and its codes
|
||||
src/config.ts MatrixRTCMode, session timings, media quality
|
||||
src/encryption.ts E2eeType, EncryptionSystem
|
||||
src/MatrixRTCClient.ts createMatrixRTCClient: wires the two folders below
|
||||
src/participationState.ts the membership state combined with the media connection
|
||||
src/reactive/ Behavior, ObservableScope, the observable operators
|
||||
src/matrixrtc/ the MatrixRTC side: the local membership, memberships, member
|
||||
metadata, transports, discovery, the js-sdk join
|
||||
src/media-backend/ the MediaBackend interface in api.ts; livekit/ is the one
|
||||
backend: connections, publisher, JWT, keys, the tracks
|
||||
src/utils/ mapScoped, network retry, display names, test helpers
|
||||
dev/ the harness (below)
|
||||
SdkArchitecture.md this document
|
||||
SdkMigration.md what moved from Element Call's src/ into the SDK
|
||||
```
|
||||
|
||||
The lint rule `element-call/sdk-import-boundary` keeps it that way: nothing under
|
||||
`sdk/` may import a file outside `sdk/` (that is `src/`, `component/`,
|
||||
`playwright/`) or React, i18n or Compound. The harness under `sdk/dev/` is a host
|
||||
and exempt.
|
||||
|
||||
## Dev harness: `sdk/dev/`
|
||||
|
||||
A web app that uses the SDK and nothing else: plain DOM, no React, no Compound, no
|
||||
i18n, imports only `@element-hq/matrixrtc-sdk`, `matrix-js-sdk` and `rxjs`. It is
|
||||
where the SDK is run during development and what the e2e tests in `playwright/sdk`
|
||||
drive. If something cannot be done in the harness without reaching into `src/`, the
|
||||
SDK API is missing something.
|
||||
|
||||
What it does: log in with a password, or with an access token the URL hands it (the
|
||||
tests register users through the admin API and skip `/login`, which the dev
|
||||
homeserver rate-limits); join the room and wait for the sync to hold it; create the
|
||||
client with microphone and camera enabled; show one tile per member with the display
|
||||
name, a `<video>` attached from the member's `"camera"` track and, for remote
|
||||
members, an `<audio>` from the `"microphone"` track, both picked out of
|
||||
`tracks$` with `trackBySource$`; toggle the microphone and camera; leave.
|
||||
|
||||
Every media element carries the track's state as `data-*` attributes (`muted`,
|
||||
`encrypted`, `active`, `frameWidth`, `frames`), which is what the tests read to
|
||||
check mute propagation, encryption, adaptive resolution and paused subscriptions
|
||||
(the speaker detection check is a `fixme`: the fake microphones do not reliably
|
||||
register at the SFU). That is more
|
||||
than the smallest consumer needs, and the price of testing the SDK through its
|
||||
public API only.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- **Publishing before `join()`.** The initial requests are published at the
|
||||
join; creating the tracks earlier, for a lobby preview, is still missing.
|
||||
- **`applicationData` after the join.** The option is read once; there is no
|
||||
way to update it on a live membership yet.
|
||||
- **Raw membership state.** The Matrix side's own state machine
|
||||
(`MembershipState` in `LocalMembership.ts`) stays internal; `state$` on the
|
||||
participation is its public view combined with the media connection. A
|
||||
developer panel may want it as `LocalRTCMember.membershipState$`.
|
||||
- **Developer panel.** `resolved$` on each connected transport covers what the
|
||||
panel shows today. Anything beyond that (LiveKit room state, connection
|
||||
quality) needs an opaque `debug$` per transport.
|
||||
- **Data beside the media.** `sendData`/`data$` ride on LiveKit's reliable data
|
||||
packets: topic-agnostic, matched to members by identity, received on every
|
||||
connection but sent only on the local transport, so a member on another
|
||||
transport does not hear us. They are transport-encrypted, not end to end:
|
||||
LiveKit can encrypt data packets with the media key, but that is not wired
|
||||
through the key provider yet. A host that needs either reach or end-to-end
|
||||
encryption uses room events.
|
||||
- **Unencrypted rooms with per-participant keys.** Keys only reach devices the
|
||||
crypto tracks, which are the members of encrypted rooms. If the SDK is to work
|
||||
in an unencrypted room, tracking the members' devices becomes its job.
|
||||
@@ -0,0 +1,53 @@
|
||||
# What moved from `src/` into the SDK
|
||||
|
||||
Element Call's `CallViewModel` used to do everything; now it consumes the SDK and
|
||||
keeps only what makes a session a call. This is the inventory of what moved out
|
||||
of `src/` on the way, so that a reader of either side knows where to look. It
|
||||
names source files, so it ages with the code; the shape they make is in
|
||||
[`SdkArchitecture.md`](./SdkArchitecture.md).
|
||||
|
||||
Everything in the table is gone from `src/`. Each module came with its tests;
|
||||
`sdk/src/utils/test.ts` and `test-fixtures.ts` are the subset of Element Call's
|
||||
test helpers they need. `Behavior`, `ObservableScope`, the observable operators,
|
||||
`E2eeType`, `EncryptionSystem` and `MatrixRTCMode` are re-exported from the SDK
|
||||
by their old `src/` paths, so the rest of Element Call did not have to change.
|
||||
|
||||
| Was in `src/` | Is in `sdk/src/` | What changed on the way |
|
||||
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `state/Behavior.ts`, `state/ObservableScope.ts`, `utils/observable.ts` | `reactive/` | as is |
|
||||
| `state/SessionBehaviors.ts` | `matrixrtc/JsRtcSessionBehaviors.ts` | the memberships and key rotation behaviors only |
|
||||
| `state/CallViewModel/localMember/HomeserverConnected.ts` | `matrixrtc/HomeserverConnected.ts` | the grace period defaults to `defaultSessionTimings` instead of `Config.get()` |
|
||||
| `state/CallViewModel/localMember/LocalMember.ts` | `matrixrtc/LocalMembership.ts`, `media-backend/livekit/LocalMedia.ts`, `participationState.ts` | split into the Matrix side, the media side and the state machine that combines them |
|
||||
| `state/CallViewModel/localMember/LocalTransport.ts`, `RtcTransportAutoDiscovery.ts` | `matrixrtc/LocalTransport.ts`, `TransportAutoDiscovery.ts` | the override and the fallback are transports from the options, not a setting and a resolved config |
|
||||
| `state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts` | `media-backend/livekit/LivekitBackend.ts` (`mediaFor`) | matches participants to members inside the backend; the SDK hands out `RTCMember`s |
|
||||
| `state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts`, `utils/displayname.ts` | `matrixrtc/MatrixMemberMetadata.ts`, `utils/displayname.ts` | as is |
|
||||
| `state/CallViewModel/remoteMembers/Connection.ts` | `media-backend/livekit/Connection.ts` | throws `MatrixRTCError`s; `ConnectionState` is a discriminated union; fetches its own token for its role |
|
||||
| `state/CallViewModel/remoteMembers/ConnectionFactory.ts` | `media-backend/livekit/ConnectionFactory.ts` | `LivekitConnectionFactory` builds the room options from the publish requests and `mediaQuality`, not from settings |
|
||||
| `state/CallViewModel/remoteMembers/ConnectionManager.ts` | `media-backend/livekit/ConnectionMap.ts` | keyed by `TransportLocator`; the publisher role is the local transport's |
|
||||
| `state/CallViewModel/localMember/Publisher.ts` | `media-backend/livekit/Publisher.ts` | rewritten on publish requests instead of `MuteStates` |
|
||||
| `livekit/openIDSFU.ts`, later `livekit/auth/` | `media-backend/livekit/auth/` | the flow is chosen by the transport's shape and our role; the delegated leave timeout is a parameter |
|
||||
| `livekit/options.ts` | `media-backend/livekit/livekitOptions.ts` | `buildLiveKitOptions(mediaQuality?)` only; the `Config` singleton is gone |
|
||||
| `e2ee/matrixKeyProvider.ts`, the key provider selection | `media-backend/livekit/MatrixKeyProvider.ts`, `KeyProvider.ts` | as is; selection keyed off `encryptionSystem` |
|
||||
| `e2ee/e2eeType.ts`, `EncryptionSystem` from `e2ee/sharedKeyManagement.ts` | `encryption.ts` | the React hooks stayed behind |
|
||||
| `MatrixRTCMode`, the timings and media quality types from `config/ConfigOptions.ts` | `config.ts` | `defaultSessionTimings` and `defaultMediaQuality` replace `DEFAULT_CONFIG` |
|
||||
| `utils/errors.ts` | `errors.ts` | `MatrixRTCError` with `code` and `category` replaces `ElementCallError`; messages are plain English, the host translates by code |
|
||||
| `doNetworkOperationWithRetry` from `utils/matrix.ts` | `utils/network.ts` | takes a `shouldRetry` predicate |
|
||||
| the join in `CallViewModel.ts` (`enterRTCSession`) | `matrixrtc/joinJsSdkSession.ts` | the timings and the delayed leave variant are parameters |
|
||||
| `livekit/MatrixAudioRenderer.tsx`, the participant reads in `state/media/` | `media-backend/livekit/LivekitMediaTrack.ts`, `LivekitMemberMedia.ts` | the view models get `MediaTrack`s with `attach`; nothing in `src/` imports a LiveKit participant any more |
|
||||
|
||||
## What still binds Element Call to LiveKit
|
||||
|
||||
Each is a gap in the SDK's API rather than something to rename; the import goes
|
||||
away when the SDK grows the piece. The names Element Call owes to the outside
|
||||
stay as they are: the `livekit.livekit_service_url` key in `config.json`, the
|
||||
persisted setting keys `extended-livekit-logs` and `custom-transport`, the
|
||||
Posthog fields `callBackend` and `callReconnectingCountLivekit`, the rageshake
|
||||
field `call_backend`, and the translated error strings.
|
||||
|
||||
| Gap | Where it shows |
|
||||
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| Preview before `join()`: local tracks for the lobby, handed out as `MediaTrack`s | `LobbyView` (`usePreviewTracks`), `VideoPreview` (`LocalVideoTrack`, `facingModeFromLocalTrack`) |
|
||||
| Device enumeration without LiveKit's helpers | `MediaDevices` (`createMediaDeviceObserver`), `CallView` (`Room.getLocalDevices`) |
|
||||
| A `logging` option: log level and a sink for the transport's logs | `initializer.tsx`, the `extended-livekit-logs` setting |
|
||||
| A `capabilities` field: output selection and background processors, asked of the SDK rather than of LiveKit | `CallFooterViewModel` (`supportsAudioOutputSelection`, `supportsBackgroundProcessors`) |
|
||||
| Element Call's error and analytics types on the SDK's | `PosthogEvents` (`DisconnectReason`), `errors.ts` (`ConnectionError`, `LivekitConnectionError`) |
|
||||
@@ -0,0 +1,78 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MatrixRTC SDK harness</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 1rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#members {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#members section {
|
||||
width: 240px;
|
||||
}
|
||||
#members video {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #222;
|
||||
}
|
||||
#members h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form>
|
||||
<input
|
||||
name="homeserver"
|
||||
placeholder="homeserver"
|
||||
value="https://synapse.m.localhost"
|
||||
/>
|
||||
<input name="username" placeholder="username" />
|
||||
<input name="password" type="password" placeholder="password" />
|
||||
<input name="room" placeholder="room id or alias" />
|
||||
<!-- A host that already holds a session passes it instead of a password -->
|
||||
<input name="accessToken" type="hidden" />
|
||||
<input name="userId" type="hidden" />
|
||||
<input name="deviceId" type="hidden" />
|
||||
<button type="submit">Start</button>
|
||||
<button type="button" id="microphone" aria-pressed="true" hidden>
|
||||
Microphone
|
||||
</button>
|
||||
<button type="button" id="camera" aria-pressed="true" hidden>
|
||||
Camera
|
||||
</button>
|
||||
<button type="button" id="screenShare" aria-pressed="false" hidden>
|
||||
Share screen
|
||||
</button>
|
||||
<button type="button" id="leave" hidden>Leave</button>
|
||||
</form>
|
||||
<p id="status" data-testid="status"></p>
|
||||
<!-- A line of text to the other members, beside the media -->
|
||||
<form id="data" hidden>
|
||||
<input name="text" placeholder="message" />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
<ul id="messages"></ul>
|
||||
<main id="members"></main>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
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 smallest consumer of the SDK: log in, join a room, show every member's
|
||||
* camera and play every remote member's microphone, mute, leave. What a host
|
||||
* writes, and nothing a host would not.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, type Observable, of, switchMap } from "rxjs";
|
||||
import {
|
||||
type AudioMediaTrack,
|
||||
createRTCSlot,
|
||||
E2eeType,
|
||||
type LocalRTCMember,
|
||||
MatrixRTCMode,
|
||||
ObservableScope,
|
||||
type RemoteRTCMember,
|
||||
type RTCParticipation,
|
||||
trackBySource$,
|
||||
type VideoMediaTrack,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { createSession, joinRoom, type Login } from "./session";
|
||||
|
||||
const form = document.querySelector("form")!;
|
||||
const status = document.getElementById("status")!;
|
||||
const members = document.getElementById("members")!;
|
||||
const dataForm = document.getElementById("data") as HTMLFormElement;
|
||||
const messages = document.getElementById("messages")!;
|
||||
const buttons = {
|
||||
microphone: document.getElementById("microphone") as HTMLButtonElement,
|
||||
camera: document.getElementById("camera") as HTMLButtonElement,
|
||||
screenShare: document.getElementById("screenShare") as HTMLButtonElement,
|
||||
leave: document.getElementById("leave") as HTMLButtonElement,
|
||||
};
|
||||
|
||||
// So that a test, or a bookmark, can fill the form from the URL
|
||||
const params = new URLSearchParams(location.search);
|
||||
for (const input of form.querySelectorAll("input"))
|
||||
input.value = params.get(input.name) ?? input.value;
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const fields = new FormData(form);
|
||||
const field = (name: string): string => fields.get(name) as string;
|
||||
const login: Login = field("accessToken")
|
||||
? {
|
||||
accessToken: field("accessToken"),
|
||||
userId: field("userId"),
|
||||
deviceId: field("deviceId"),
|
||||
}
|
||||
: { username: field("username"), password: field("password") };
|
||||
void start(field("homeserver"), login, field("room"));
|
||||
});
|
||||
|
||||
async function start(
|
||||
homeserver: string,
|
||||
login: Login,
|
||||
roomIdOrAlias: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
status.textContent = "Logging in";
|
||||
const client = await createSession(homeserver, login);
|
||||
const room = await joinRoom(client, roomIdOrAlias);
|
||||
|
||||
const scope = new ObservableScope();
|
||||
const slot = createRTCSlot(scope, client, room, {
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
});
|
||||
const rtcParticipation = slot.join({
|
||||
publish: [{ source: "microphone" }, { source: "camera" }],
|
||||
});
|
||||
rtcParticipation.state$.pipe(scope.bind()).subscribe((state) => {
|
||||
status.textContent =
|
||||
state.kind === "failed" ? `Error: ${state.error.message}` : state.kind;
|
||||
});
|
||||
showMembers(scope, rtcParticipation);
|
||||
showMessages(scope, rtcParticipation);
|
||||
|
||||
toggle(buttons.microphone, async (enabled) =>
|
||||
setPublished(rtcParticipation, "microphone", enabled),
|
||||
);
|
||||
toggle(buttons.camera, async (enabled) =>
|
||||
setPublished(rtcParticipation, "camera", enabled),
|
||||
);
|
||||
toggle(buttons.screenShare, async (enabled) =>
|
||||
setPublished(rtcParticipation, "screenShare", enabled),
|
||||
);
|
||||
buttons.leave.hidden = false;
|
||||
buttons.leave.onclick = (): void => {
|
||||
rtcParticipation.leave();
|
||||
scope.end();
|
||||
members.replaceChildren();
|
||||
messages.replaceChildren();
|
||||
dataForm.hidden = true;
|
||||
for (const button of Object.values(buttons)) button.hidden = true;
|
||||
status.textContent = "Left";
|
||||
};
|
||||
} catch (e) {
|
||||
status.textContent = `Error: ${e}`;
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns one of our sources on or off: a microphone or camera is muted and
|
||||
* unmuted while it exists and published when it does not; a screen share is
|
||||
* published and unpublished.
|
||||
*/
|
||||
async function setPublished(
|
||||
rtcParticipation: RTCParticipation,
|
||||
source: "microphone" | "camera" | "screenShare",
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
const track = rtcParticipation.localMember$.value?.tracks$.value?.find(
|
||||
(t) => t.source === source,
|
||||
);
|
||||
if (track && source === "screenShare") {
|
||||
if (!enabled) await rtcParticipation.unpublish(source);
|
||||
return enabled;
|
||||
}
|
||||
if (track) return track.setEnabled(enabled);
|
||||
if (!enabled) return false;
|
||||
await rtcParticipation.publish({ source });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A pressed button means the source is on; the SDK says what it could do. */
|
||||
function toggle(
|
||||
button: HTMLButtonElement,
|
||||
set: (enabled: boolean) => Promise<boolean>,
|
||||
): void {
|
||||
button.hidden = false;
|
||||
button.onclick = (): void => {
|
||||
void set(button.ariaPressed !== "true").then((enabled) => {
|
||||
button.ariaPressed = String(enabled);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Sends what the form holds on the "chat" topic, and lists what arrives. */
|
||||
function showMessages(
|
||||
scope: ObservableScope,
|
||||
rtcParticipation: RTCParticipation,
|
||||
): void {
|
||||
dataForm.hidden = false;
|
||||
dataForm.onsubmit = (event): void => {
|
||||
event.preventDefault();
|
||||
const text = new FormData(dataForm).get("text") as string;
|
||||
rtcParticipation.sendData("chat", text).then(
|
||||
() => dataForm.reset(),
|
||||
(e: unknown) => {
|
||||
status.textContent = `Error: ${e}`;
|
||||
},
|
||||
);
|
||||
};
|
||||
rtcParticipation.data$
|
||||
.pipe(scope.bind())
|
||||
.subscribe(({ member, topic, text }) => {
|
||||
const line = messages.appendChild(document.createElement("li"));
|
||||
line.dataset.testid = "message";
|
||||
line.dataset.topic = topic;
|
||||
line.dataset.userId = member.userId;
|
||||
line.textContent = `${member.displayName$.value}: ${text}`;
|
||||
});
|
||||
}
|
||||
|
||||
function showMembers(
|
||||
scope: ObservableScope,
|
||||
rtcParticipation: RTCParticipation,
|
||||
): void {
|
||||
const tiles = new Map<string, HTMLElement>();
|
||||
combineLatest([
|
||||
rtcParticipation.localMember$,
|
||||
rtcParticipation.remoteMembers$,
|
||||
])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([local, remote]) => {
|
||||
const current = local === null ? remote : [local, ...remote];
|
||||
for (const [id, tile] of tiles)
|
||||
if (!current.some((m) => m.memberId === id)) {
|
||||
tile.remove();
|
||||
tiles.delete(id);
|
||||
}
|
||||
for (const member of current)
|
||||
if (!tiles.has(member.memberId)) {
|
||||
const tile = memberTile(scope, member);
|
||||
tiles.set(member.memberId, tile);
|
||||
members.append(tile);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function memberTile(
|
||||
scope: ObservableScope,
|
||||
member: LocalRTCMember | RemoteRTCMember,
|
||||
): HTMLElement {
|
||||
const tile = document.createElement("section");
|
||||
tile.dataset.testid = "member";
|
||||
tile.dataset.userId = member.userId;
|
||||
tile.dataset.local = String(member.local);
|
||||
|
||||
const name = tile.appendChild(document.createElement("h2"));
|
||||
member.displayName$.pipe(scope.bind()).subscribe((n) => {
|
||||
name.textContent = n;
|
||||
});
|
||||
|
||||
render(
|
||||
scope,
|
||||
trackBySource$(scope, member.tracks$, "camera"),
|
||||
tile.appendChild(document.createElement("video")),
|
||||
);
|
||||
// Our own microphone would only echo
|
||||
if (!member.local)
|
||||
render(
|
||||
scope,
|
||||
trackBySource$(scope, member.tracks$, "microphone"),
|
||||
tile.appendChild(document.createElement("audio")),
|
||||
);
|
||||
return tile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a track on an element and labels the element with the track's state,
|
||||
* so that the page shows what the member is sending and how well it arrives.
|
||||
*/
|
||||
function render(
|
||||
scope: ObservableScope,
|
||||
track$: Observable<AudioMediaTrack | VideoMediaTrack | undefined>,
|
||||
element: HTMLMediaElement,
|
||||
): void {
|
||||
let attached: AudioMediaTrack | VideoMediaTrack | undefined;
|
||||
track$.pipe(scope.bind()).subscribe((track) => {
|
||||
attached?.detach(element);
|
||||
attached = track;
|
||||
track?.attach(element);
|
||||
});
|
||||
scope.onEnd(() => attached?.detach(element));
|
||||
|
||||
const label = (
|
||||
key: string,
|
||||
value$: Observable<string | number | boolean | undefined>,
|
||||
): void => {
|
||||
value$.pipe(scope.bind()).subscribe((value) => {
|
||||
if (value === undefined) delete element.dataset[key];
|
||||
else element.dataset[key] = String(value);
|
||||
});
|
||||
};
|
||||
const of$ = <T>(
|
||||
pick: (track: AudioMediaTrack | VideoMediaTrack) => Observable<T>,
|
||||
): Observable<T | undefined> =>
|
||||
track$.pipe(switchMap((track) => (track ? pick(track) : of(undefined))));
|
||||
label(
|
||||
"muted",
|
||||
of$((t) => t.muted$),
|
||||
);
|
||||
label(
|
||||
"encrypted",
|
||||
of$((t) => of(t.encrypted)),
|
||||
);
|
||||
label(
|
||||
"active",
|
||||
of$((t) => (t.kind === "audio" ? t.isActive$ : of(undefined))),
|
||||
);
|
||||
const stats$ = of$((t) => t.stats$(1000));
|
||||
label("frameWidth", stats$.pipe(switchMap((s) => of(frames(s)?.frameWidth))));
|
||||
label("frames", stats$.pipe(switchMap((s) => of(frames(s)?.count))));
|
||||
}
|
||||
|
||||
/** The frame counters an RTP stream reports, from either end of it. */
|
||||
function frames(
|
||||
stats: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined,
|
||||
): { frameWidth: number | undefined; count: number | undefined } | undefined {
|
||||
if (stats === undefined) return undefined;
|
||||
return {
|
||||
frameWidth: stats.frameWidth,
|
||||
count:
|
||||
stats.type === "inbound-rtp"
|
||||
? (stats as RTCInboundRtpStreamStats).framesDecoded
|
||||
: (stats as RTCOutboundRtpStreamStats).framesEncoded,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type MatrixClient,
|
||||
MemoryStore,
|
||||
type Room,
|
||||
SyncState,
|
||||
} from "matrix-js-sdk";
|
||||
import { KnownMembership } from "matrix-js-sdk/lib/types";
|
||||
|
||||
export type Login =
|
||||
| { username: string; password: string }
|
||||
| { accessToken: string; userId: string; deviceId: string };
|
||||
|
||||
/**
|
||||
* Returns a client that has finished its first sync, logging in with a
|
||||
* password unless the caller already holds a token.
|
||||
*/
|
||||
export async function createSession(
|
||||
homeserver: string,
|
||||
login: Login,
|
||||
): Promise<MatrixClient> {
|
||||
const credentials =
|
||||
"accessToken" in login ? login : await logIn(homeserver, login);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: homeserver,
|
||||
...credentials,
|
||||
store: new MemoryStore(),
|
||||
useAuthorizationHeader: true,
|
||||
fallbackICEServerAllowed: true,
|
||||
});
|
||||
|
||||
await client.initRustCrypto({ useIndexedDB: false });
|
||||
await client.startClient();
|
||||
await new Promise<void>((resolve) => {
|
||||
const onSync = (state: SyncState): void => {
|
||||
if (state !== SyncState.Prepared && state !== SyncState.Syncing) return;
|
||||
client.off(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a room and returns it as the sync loop maintains it. The room object
|
||||
* `joinRoom` itself returns for a room joined just now is a detached copy
|
||||
* that never receives the state the sync delivers, and a session built on
|
||||
* it would never see a member.
|
||||
*/
|
||||
export async function joinRoom(
|
||||
client: MatrixClient,
|
||||
roomIdOrAlias: string,
|
||||
): Promise<Room> {
|
||||
const { roomId } = await client.joinRoom(roomIdOrAlias);
|
||||
const joinedRoom = (): Room | undefined => {
|
||||
const room = client.getRoom(roomId);
|
||||
return room?.hasMembershipState(client.getUserId()!, KnownMembership.Join)
|
||||
? room
|
||||
: undefined;
|
||||
};
|
||||
return (
|
||||
joinedRoom() ??
|
||||
new Promise<Room>((resolve) => {
|
||||
const onSync = (): void => {
|
||||
const room = joinedRoom();
|
||||
if (room === undefined) return;
|
||||
client.off(ClientEvent.Sync, onSync);
|
||||
resolve(room);
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function logIn(
|
||||
homeserver: string,
|
||||
{ username, password }: { username: string; password: string },
|
||||
): Promise<{ accessToken: string; userId: string; deviceId: string }> {
|
||||
const login = await createClient({ baseUrl: homeserver }).login(
|
||||
"m.login.password",
|
||||
{ identifier: { type: "m.id.user", user: username }, password },
|
||||
);
|
||||
return {
|
||||
accessToken: login.access_token,
|
||||
userId: login.user_id,
|
||||
deviceId: login.device_id,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* MatrixRTC sessions with LiveKit media, without Element Call's UI: the call
|
||||
* model Element Call's own view model is built on, for hosts that want to
|
||||
* build a different one. The design is in `SdkArchitecture.md` next to this
|
||||
* file.
|
||||
*/
|
||||
|
||||
export { type Behavior, constant } from "./src/reactive/Behavior";
|
||||
export {
|
||||
Epoch,
|
||||
mapEpoch,
|
||||
ObservableScope,
|
||||
trackEpoch,
|
||||
} from "./src/reactive/ObservableScope";
|
||||
export {
|
||||
filterBehavior,
|
||||
finalizeValue,
|
||||
generateItems,
|
||||
generateItemsWithEpoch,
|
||||
pauseWhen,
|
||||
} from "./src/reactive/observable";
|
||||
export { E2eeType, type EncryptionSystem } from "./src/encryption";
|
||||
export {
|
||||
defaultMediaQuality,
|
||||
defaultSessionTimings,
|
||||
type DelayedLeaveTimings,
|
||||
MatrixRTCMode,
|
||||
type MediaQuality,
|
||||
type SessionTimings,
|
||||
} from "./src/config";
|
||||
export {
|
||||
ErrorCategory,
|
||||
ErrorCode,
|
||||
FailToGetOpenIdToken,
|
||||
FailToStartLivekitConnection,
|
||||
InsufficientCapacityError,
|
||||
LivekitConnectionError,
|
||||
MatrixRTCTransportMissingError,
|
||||
MembershipManagerError,
|
||||
NoMatrix2AuthorizationService,
|
||||
PeerConnectionTimeoutError,
|
||||
SFURoomCreationRestrictedError,
|
||||
UnknownRTCError,
|
||||
} from "./src/errors";
|
||||
|
||||
export * from "./src/api";
|
||||
export * from "./src/media-api";
|
||||
export * from "./src/media-backend/api";
|
||||
export { trackBySource$, type TrackOfSource } from "./src/utils/tracks";
|
||||
export { createRTCSlot } from "./src/RTCSlot";
|
||||
export {
|
||||
createLivekitBackend,
|
||||
type LivekitBackendOptions,
|
||||
} from "./src/media-backend/livekit/LivekitBackend";
|
||||
// For a host that creates LiveKit tracks itself, such as a preview before joining
|
||||
export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor";
|
||||
// For a developer panel to check a transport before using it
|
||||
export {
|
||||
getSFUConfig as authenticateWithTransport,
|
||||
type GetSFUConfigParams as AuthenticateWithTransportParams,
|
||||
} from "./src/media-backend/livekit/auth";
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@element-hq/matrixrtc-sdk",
|
||||
"version": "0.0.0",
|
||||
"description": "MatrixRTC sessions with LiveKit media, without Element Call's UI. Consumed straight from the repository as a git dependency (github:element-hq/element-call#<ref>&path:/sdk): the host's package manager runs `prepare`, which builds `dist/`.",
|
||||
"license": "SEE LICENSE IN ../README.md",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/element-hq/element-call",
|
||||
"directory": "sdk"
|
||||
},
|
||||
"type": "module",
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/matrixrtc-sdk.js",
|
||||
"module": "./dist/matrixrtc-sdk.js",
|
||||
"types": "./dist/types/sdk/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/sdk/index.d.ts",
|
||||
"default": "./dist/matrixrtc-sdk.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:sdk"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"livekit-client": "^2.18.1",
|
||||
"matrix-js-sdk": "*",
|
||||
"rxjs": "^7.8.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Makes `sdk/` a pnpm project of its own rather than a directory inside the
|
||||
# repository's workspace. That matters when a host installs the SDK straight
|
||||
# from this repository (`github:element-hq/element-call#…&path:/sdk`): pnpm
|
||||
# prepares such a dependency by running `pnpm install` in this directory, and
|
||||
# only a project root gets its `prepare` script (see package.json) run, which is
|
||||
# what builds `dist/`. Inside the repository's own workspace the install would
|
||||
# silently target the repository instead and build nothing.
|
||||
#
|
||||
# Consequence for development: pnpm commands run from within this directory see
|
||||
# this project, not the repository; run them from the repository root.
|
||||
|
||||
# Nothing to install here: the peers in package.json are the host's, and the
|
||||
# build runs against the repository's own node_modules (see `prepare`).
|
||||
autoInstallPeers: false
|
||||
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixClient, type Room, SyncState } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
MatrixRTCSessionEvent,
|
||||
MembershipManagerEvent,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, EMPTY, Subject } from "rxjs";
|
||||
import { describe, expect, it, type Mock, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type DataMessage,
|
||||
type ParticipationState,
|
||||
type RTCMember,
|
||||
type RTCParticipationOptions,
|
||||
type RTCSlot,
|
||||
type TransportMetadata,
|
||||
} from "./api";
|
||||
import { defaultSessionTimings, MatrixRTCMode } from "./config";
|
||||
import { E2eeType } from "./encryption";
|
||||
import { MatrixRTCError } from "./errors";
|
||||
import {
|
||||
type DataPacket,
|
||||
type MediaBackend,
|
||||
type MediaBackendContext,
|
||||
MediaConnectionState,
|
||||
type MediaKey,
|
||||
} from "./media-backend/api";
|
||||
import { type MemberMedia } from "./media-api";
|
||||
import { constant } from "./reactive/Behavior";
|
||||
import { ObservableScope } from "./reactive/ObservableScope";
|
||||
import { createRTCParticipation } from "./RTCParticipation";
|
||||
import { transportId } from "./matrixrtc/Transports";
|
||||
import { flushPromises, mockEmitter, MockRTCSession } from "./utils/test";
|
||||
|
||||
const transport: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
};
|
||||
const userId = "@alice:example.org";
|
||||
const deviceId = "AAAA";
|
||||
const legacyMemberId = `${userId}:${deviceId}`;
|
||||
|
||||
function metadata(raw: Transport): TransportMetadata {
|
||||
return {
|
||||
type: raw.type,
|
||||
id: transportId(raw),
|
||||
raw,
|
||||
resolved$: constant(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function member(
|
||||
overrides: Partial<RTCMember> & Pick<RTCMember, "userId" | "memberId">,
|
||||
): RTCMember {
|
||||
return {
|
||||
local: false,
|
||||
rtcBackendIdentity: overrides.memberId,
|
||||
backendIdentities: [overrides.memberId],
|
||||
deviceId: "DEV",
|
||||
transport$: constant(metadata(transport)),
|
||||
applicationData$: constant({}),
|
||||
displayName$: constant(overrides.userId),
|
||||
avatarUrl$: constant(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const bob = member({ userId: "@bob:example.org", memberId: "bob-member" });
|
||||
const own = member({ userId, deviceId, memberId: legacyMemberId, local: true });
|
||||
|
||||
/** A backend the test drives: it prepares when told, and reports what the participation asked of it. */
|
||||
interface FakeBackend {
|
||||
backend: MediaBackend;
|
||||
context: MediaBackendContext;
|
||||
prepared: PromiseWithResolvers<{ canDelegateDelayedLeave: boolean }>;
|
||||
connectionState$: BehaviorSubject<MediaConnectionState | Error>;
|
||||
data$: Subject<DataPacket>;
|
||||
setPublishing: Mock<(publish: boolean) => void>;
|
||||
delegateDelayedLeave: Mock<(delayId: string) => Promise<void>>;
|
||||
mediaFor: Mock<(scope: ObservableScope, member: RTCMember) => MemberMedia>;
|
||||
}
|
||||
|
||||
function setup(
|
||||
options: Partial<RTCParticipationOptions> = {},
|
||||
slotOptions: { matrixRTCMode?: MatrixRTCMode } = {},
|
||||
): {
|
||||
scope: ObservableScope;
|
||||
participation: ReturnType<typeof createRTCParticipation>["participation"];
|
||||
session: MockRTCSession;
|
||||
mockedSession: ReturnType<MockRTCSession["asMockedSession"]>;
|
||||
join: ReturnType<typeof vi.spyOn>;
|
||||
members$: BehaviorSubject<RTCMember[]>;
|
||||
fake: FakeBackend;
|
||||
states: ParticipationState[];
|
||||
} {
|
||||
const scope = new ObservableScope();
|
||||
const room = { roomId: "!room:example.org" } as Room;
|
||||
const session = new MockRTCSession(room);
|
||||
const mockedSession = session.asMockedSession();
|
||||
const join = vi.spyOn(session, "join");
|
||||
const client = {
|
||||
...mockEmitter(),
|
||||
getUserId: () => userId,
|
||||
getDeviceId: () => deviceId,
|
||||
getDomain: () => "example.org",
|
||||
getSyncState: () => SyncState.Syncing,
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue([transport]),
|
||||
} as unknown as MatrixClient;
|
||||
const members$ = new BehaviorSubject<RTCMember[]>([]);
|
||||
|
||||
const fake: Omit<FakeBackend, "backend" | "context"> &
|
||||
Partial<Pick<FakeBackend, "backend" | "context">> = {
|
||||
prepared: Promise.withResolvers(),
|
||||
connectionState$: new BehaviorSubject<MediaConnectionState | Error>(
|
||||
MediaConnectionState.Initialized,
|
||||
),
|
||||
data$: new Subject<DataPacket>(),
|
||||
setPublishing: vi.fn(),
|
||||
delegateDelayedLeave: vi.fn(async () => Promise.resolve()),
|
||||
mediaFor: vi.fn(() => ({
|
||||
tracks$: constant(null),
|
||||
encryptionError$: EMPTY,
|
||||
})),
|
||||
};
|
||||
const backendFactory = (
|
||||
backendScope: ObservableScope,
|
||||
context: MediaBackendContext,
|
||||
): MediaBackend => {
|
||||
const backend: MediaBackend = {
|
||||
transportType: "livekit",
|
||||
prepareLocalTransport: async () => fake.prepared.promise,
|
||||
delegateDelayedLeave: fake.delegateDelayedLeave,
|
||||
local: {
|
||||
connectionState$: backendScope.behavior(fake.connectionState$),
|
||||
setPublishing: fake.setPublishing,
|
||||
publishError$: constant(null),
|
||||
publish: vi.fn(),
|
||||
unpublish: vi.fn(),
|
||||
tracks$: constant(null),
|
||||
encryptionError$: EMPTY,
|
||||
},
|
||||
mediaFor: fake.mediaFor,
|
||||
connections$: constant([]),
|
||||
setAudioOutputDeviceId: vi.fn(),
|
||||
sendData: vi.fn(),
|
||||
data$: fake.data$,
|
||||
};
|
||||
fake.context = context;
|
||||
fake.backend = backend;
|
||||
return backend;
|
||||
};
|
||||
|
||||
const { participation } = createRTCParticipation(
|
||||
scope,
|
||||
{
|
||||
client,
|
||||
room,
|
||||
slot: {} as RTCSlot,
|
||||
jsSdkSession: mockedSession,
|
||||
members$: scope.behavior(members$),
|
||||
transports: { get: metadata, connected$: constant([]) },
|
||||
slotOptions: {
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
...slotOptions,
|
||||
},
|
||||
logger,
|
||||
},
|
||||
{ publish: [], backend: backendFactory, ...options },
|
||||
);
|
||||
const states: ParticipationState[] = [];
|
||||
participation.state$.subscribe((state) => states.push(state));
|
||||
return {
|
||||
scope,
|
||||
participation,
|
||||
session,
|
||||
mockedSession,
|
||||
join,
|
||||
members$,
|
||||
fake: fake as FakeBackend,
|
||||
states,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createRTCParticipation", () => {
|
||||
it("joins the session once the transport is prepared, and is connected once the media is", async () => {
|
||||
const { participation, join, fake, states } = setup();
|
||||
expect(states).toEqual([{ kind: "waitingForTransport" }]);
|
||||
await flushPromises();
|
||||
expect(join).not.toHaveBeenCalled();
|
||||
|
||||
fake.prepared.resolve({ canDelegateDelayedLeave: false });
|
||||
await flushPromises();
|
||||
expect(join).toHaveBeenCalledWith(
|
||||
{ userId, deviceId, memberId: legacyMemberId },
|
||||
[transport],
|
||||
expect.objectContaining({
|
||||
manageMediaKeys: true,
|
||||
unstableSendStickyEvents: false,
|
||||
delayedLeaveEventDelayMs: defaultSessionTimings.delayedLeave.delay_ms,
|
||||
}),
|
||||
);
|
||||
expect(participation.state$.value).toEqual({ kind: "connecting" });
|
||||
|
||||
fake.connectionState$.next(MediaConnectionState.Connected);
|
||||
expect(participation.state$.value).toEqual({ kind: "connected" });
|
||||
|
||||
fake.connectionState$.next(MediaConnectionState.Reconnecting);
|
||||
expect(participation.state$.value).toEqual({
|
||||
kind: "reconnecting",
|
||||
reason: "media",
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes only while joined", async () => {
|
||||
const { participation, session, fake, scope } = setup();
|
||||
const ended = vi.fn();
|
||||
scope.onEnd(ended);
|
||||
await flushPromises();
|
||||
expect(fake.setPublishing).toHaveBeenLastCalledWith(false);
|
||||
|
||||
fake.prepared.resolve({ canDelegateDelayedLeave: false });
|
||||
await flushPromises();
|
||||
expect(fake.setPublishing).toHaveBeenLastCalledWith(true);
|
||||
|
||||
participation.leave();
|
||||
expect(fake.setPublishing).toHaveBeenLastCalledWith(false);
|
||||
expect(participation.state$.value).toEqual({ kind: "left" });
|
||||
await flushPromises();
|
||||
expect(session.leave).toHaveBeenCalled();
|
||||
expect(ended).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails where the transport cannot be prepared", async () => {
|
||||
const { participation, join, fake } = setup();
|
||||
const error = new MatrixRTCError("no token");
|
||||
|
||||
fake.prepared.reject(error);
|
||||
await flushPromises();
|
||||
|
||||
expect(participation.state$.value).toEqual({ kind: "failed", error });
|
||||
expect(join).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails where the media connection fails, and stays failed", async () => {
|
||||
const { participation, fake } = setup();
|
||||
fake.prepared.resolve({ canDelegateDelayedLeave: false });
|
||||
await flushPromises();
|
||||
const error = new MatrixRTCError("SFU gone");
|
||||
|
||||
fake.connectionState$.next(error);
|
||||
expect(participation.state$.value).toEqual({ kind: "failed", error });
|
||||
|
||||
fake.connectionState$.next(MediaConnectionState.Connected);
|
||||
expect(participation.state$.value).toEqual({ kind: "failed", error });
|
||||
});
|
||||
|
||||
it("joins with the delegated timings where the backend delegates, and hands it each delay id", async () => {
|
||||
const { session, join, fake } = setup({
|
||||
timings: { delegatedDelayedLeave: { delay_ms: 7_200_000 } },
|
||||
});
|
||||
fake.prepared.resolve({ canDelegateDelayedLeave: true });
|
||||
await flushPromises();
|
||||
expect(join).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
[transport],
|
||||
expect.objectContaining({ delayedLeaveEventDelayMs: 7_200_000 }),
|
||||
);
|
||||
expect(fake.delegateDelayedLeave).not.toHaveBeenCalled();
|
||||
|
||||
// Re-emitted by the js-sdk session with the membership manager appended
|
||||
(
|
||||
session as unknown as { emit(event: string, ...args: unknown[]): void }
|
||||
).emit(MembershipManagerEvent.DelayIdChanged, "delay-1", {});
|
||||
await flushPromises();
|
||||
|
||||
expect(fake.delegateDelayedLeave).toHaveBeenCalledWith("delay-1");
|
||||
});
|
||||
|
||||
it("names the membership `${userId}:${deviceId}` in compatibility mode and by a uuid in Matrix 2.0", () => {
|
||||
const compatibility = setup();
|
||||
expect(compatibility.fake.context.ownMembershipIdentity).toEqual({
|
||||
userId,
|
||||
deviceId,
|
||||
memberId: legacyMemberId,
|
||||
});
|
||||
compatibility.scope.end();
|
||||
|
||||
const matrix2 = setup({}, { matrixRTCMode: MatrixRTCMode.Matrix_2_0 });
|
||||
const { memberId } = matrix2.fake.context.ownMembershipIdentity;
|
||||
expect(memberId).not.toBe(legacyMemberId);
|
||||
expect(memberId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
matrix2.scope.end();
|
||||
});
|
||||
|
||||
it("passes the slot and the join options on to the backend", () => {
|
||||
const { fake, scope } = setup({
|
||||
publish: [{ source: "camera" }],
|
||||
timings: { networkErrorRetryMs: 250 },
|
||||
});
|
||||
|
||||
expect(fake.context).toMatchObject({
|
||||
roomId: "!room:example.org",
|
||||
slotId: "m.call#ROOM",
|
||||
publish: [{ source: "camera" }],
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
timings: { ...defaultSessionTimings, networkErrorRetryMs: 250 },
|
||||
});
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("gives the backend the keys the session holds and the ones that follow", () => {
|
||||
const { fake, session, mockedSession, scope } = setup();
|
||||
const keys: MediaKey[] = [];
|
||||
fake.context.mediaKeys$.subscribe((key) => keys.push(key));
|
||||
expect(mockedSession.reemitEncryptionKeys).toHaveBeenCalledOnce();
|
||||
|
||||
const key = new Uint8Array([1, 2, 3]);
|
||||
session.emit(
|
||||
MatrixRTCSessionEvent.EncryptionKeyChanged,
|
||||
key,
|
||||
2,
|
||||
{ userId: "@bob:example.org", deviceId: "DEV", memberId: "bob-member" },
|
||||
"bob-member",
|
||||
);
|
||||
|
||||
expect(keys).toEqual([{ participantId: "bob-member", index: 2, key }]);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("builds the remote members on the backend's media and the local member on our own membership", () => {
|
||||
const { participation, members$, fake, scope } = setup();
|
||||
const stale = member({
|
||||
userId,
|
||||
deviceId,
|
||||
memberId: "old-uuid",
|
||||
local: true,
|
||||
});
|
||||
|
||||
members$.next([own, bob, stale]);
|
||||
|
||||
expect(fake.mediaFor).toHaveBeenCalledTimes(1);
|
||||
expect(fake.mediaFor).toHaveBeenCalledWith(expect.anything(), bob);
|
||||
expect(participation.remoteMembers$.value.map((m) => m.memberId)).toEqual([
|
||||
"bob-member",
|
||||
]);
|
||||
expect(participation.localMember$.value).toMatchObject({
|
||||
local: true,
|
||||
memberId: legacyMemberId,
|
||||
});
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("delivers data from a member with its member and drops the rest", () => {
|
||||
const { participation, members$, fake, scope } = setup();
|
||||
members$.next([
|
||||
member({
|
||||
userId: "@bob:example.org",
|
||||
memberId: "bob-member",
|
||||
backendIdentities: ["bob-hashed", "bob-member"],
|
||||
}),
|
||||
]);
|
||||
const messages: DataMessage[] = [];
|
||||
participation.data$.subscribe((message) => messages.push(message));
|
||||
|
||||
fake.data$.next({ senderId: "bob-hashed", topic: "chat", text: "hi" });
|
||||
fake.data$.next({ senderId: "stranger", topic: "chat", text: "boo" });
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
topic: "chat",
|
||||
text: "hi",
|
||||
member: { memberId: "bob-member" },
|
||||
});
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("leaves at most once", async () => {
|
||||
const { participation, session, fake } = setup();
|
||||
fake.prepared.resolve({ canDelegateDelayedLeave: false });
|
||||
await flushPromises();
|
||||
|
||||
participation.leave();
|
||||
participation.leave();
|
||||
await flushPromises();
|
||||
|
||||
expect(session.leave).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type MatrixRTCSession as JsSdkRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
MembershipManagerEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
from,
|
||||
fromEvent,
|
||||
map,
|
||||
Observable,
|
||||
scan,
|
||||
} from "rxjs";
|
||||
|
||||
import { defaultSessionTimings, MatrixRTCMode } from "./config";
|
||||
import { E2eeType } from "./encryption";
|
||||
import { type Behavior } from "./reactive/Behavior";
|
||||
import { type ObservableScope } from "./reactive/ObservableScope";
|
||||
import { generateItems } from "./reactive/observable";
|
||||
import { mapScoped } from "./utils/mapScoped";
|
||||
import {
|
||||
type DataMessage,
|
||||
type LocalRTCMember,
|
||||
type ParticipationState,
|
||||
type RemoteRTCMember,
|
||||
type RTCMember,
|
||||
type RTCParticipation,
|
||||
type RTCParticipationOptions,
|
||||
type RTCSlot,
|
||||
type RTCSlotOptions,
|
||||
} from "./api";
|
||||
import {
|
||||
type MediaBackend,
|
||||
type MediaBackendContext,
|
||||
type MediaKey,
|
||||
} from "./media-backend/api";
|
||||
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
|
||||
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
|
||||
import { joinJsSdkSession } from "./matrixrtc/joinJsSdkSession";
|
||||
import {
|
||||
createLocalMembership$,
|
||||
type PreparedTransport,
|
||||
} from "./matrixrtc/LocalMembership";
|
||||
import { discoverLocalTransport } from "./matrixrtc/LocalTransport";
|
||||
import {
|
||||
createLocalRTCMember,
|
||||
createRemoteRTCMember,
|
||||
} from "./matrixrtc/Members";
|
||||
import { createKeyRotationSuppressed$ } from "./matrixrtc/JsRtcSessionBehaviors";
|
||||
import { type TransportRegistry } from "./matrixrtc/Transports";
|
||||
import { participationState } from "./participationState";
|
||||
|
||||
/** What the slot lends a participation. */
|
||||
export interface ParticipationDeps {
|
||||
client: MatrixClient;
|
||||
room: Room;
|
||||
slot: RTCSlot;
|
||||
jsSdkSession: JsSdkRTCSession;
|
||||
members$: Behavior<RTCMember[]>;
|
||||
transports: TransportRegistry;
|
||||
slotOptions: Pick<RTCSlotOptions, "matrixRTCMode" | "encryptionSystem">;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/** The participation, plus what the slot reads from it without exposing it. */
|
||||
export interface ParticipationInternals {
|
||||
participation: RTCParticipation;
|
||||
connections$: MediaBackend["connections$"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Our span in a slot: the backend, the local transport, the join state
|
||||
* machine and the members with the media it carries for them. Starts joining
|
||||
* at once and ends with `leave()`, which also ends `scope`.
|
||||
*/
|
||||
export function createRTCParticipation(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
client,
|
||||
room,
|
||||
slot,
|
||||
jsSdkSession,
|
||||
members$,
|
||||
transports,
|
||||
slotOptions: { matrixRTCMode, encryptionSystem },
|
||||
logger: parentLogger,
|
||||
}: ParticipationDeps,
|
||||
options: RTCParticipationOptions,
|
||||
): ParticipationInternals {
|
||||
const logger = parentLogger.getChild("[RTCParticipation]");
|
||||
const userId = client.getUserId()!;
|
||||
const deviceId = client.getDeviceId()!;
|
||||
const timings = { ...defaultSessionTimings, ...options.timings };
|
||||
|
||||
const ownMembershipIdentity: CallMembershipIdentityParts = {
|
||||
userId,
|
||||
deviceId,
|
||||
// A pre-sticky membership names itself `${userId}:${deviceId}`, and the
|
||||
// key transport stamps this id into every key event, so a uuid there
|
||||
// would name a member no peer can resolve
|
||||
memberId:
|
||||
matrixRTCMode === MatrixRTCMode.Matrix_2_0
|
||||
? uuidv4()
|
||||
: `${userId}:${deviceId}`,
|
||||
};
|
||||
|
||||
const backend = createBackend(scope, client, options, {
|
||||
roomId: room.roomId,
|
||||
slotId: jsSdkSession.slotId,
|
||||
ownMembershipIdentity,
|
||||
publish: options.publish,
|
||||
encryptionSystem,
|
||||
mediaKeys$: mediaKeys$(jsSdkSession),
|
||||
timings,
|
||||
logger,
|
||||
});
|
||||
|
||||
const preparedTransport$ = from(
|
||||
discoverLocalTransport(client, backend.transportType, options, logger).then(
|
||||
async (transport): Promise<PreparedTransport> => ({
|
||||
transport,
|
||||
...(await backend.prepareLocalTransport(transport)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const localMembership = createLocalMembership$({
|
||||
scope,
|
||||
delegateDelayedLeave: async (delayId) =>
|
||||
backend.delegateDelayedLeave(delayId),
|
||||
preparedTransport$,
|
||||
homeserverConnected: createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
jsSdkSession,
|
||||
timings.syncDisconnectGracePeriodMs,
|
||||
),
|
||||
joinMatrixRTC: (transport, delayedLeave) =>
|
||||
joinJsSdkSession(jsSdkSession, ownMembershipIdentity, transport, {
|
||||
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
|
||||
matrixRTCMode,
|
||||
sendNotificationType: options.sendNotificationType,
|
||||
applicationData: options.applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
}),
|
||||
membershipManagerError$: fromEvent(
|
||||
jsSdkSession,
|
||||
MatrixRTCSessionEvent.MembershipManagerError,
|
||||
),
|
||||
matrixRTCSession: jsSdkSession,
|
||||
delayId$: scope.behavior(
|
||||
(
|
||||
fromEvent(
|
||||
jsSdkSession,
|
||||
MembershipManagerEvent.DelayIdChanged,
|
||||
// The re-emitted event carries the original emitter as the second argument
|
||||
) as Observable<[string | undefined, IMembershipManager]>
|
||||
).pipe(map(([delayId]) => delayId ?? null)),
|
||||
jsSdkSession.delayId ?? null,
|
||||
),
|
||||
timings,
|
||||
logger,
|
||||
});
|
||||
|
||||
const remoteMembers$ = scope.behavior<RemoteRTCMember[]>(
|
||||
members$.pipe(
|
||||
generateItems(
|
||||
"RTCParticipation remoteMembers",
|
||||
function* (members) {
|
||||
for (const member of members)
|
||||
if (!member.local) yield { keys: [member] as const, data: member };
|
||||
},
|
||||
(memberScope, member$) =>
|
||||
createRemoteRTCMember(
|
||||
member$.value,
|
||||
backend.mediaFor(memberScope, member$.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Matched by the identity we joined with: a stale membership of ours from
|
||||
// an earlier participation is `local` in the slot but is not this one
|
||||
const ownMember$ = scope.behavior(
|
||||
members$.pipe(
|
||||
map(
|
||||
(members) =>
|
||||
members.find(
|
||||
(member) =>
|
||||
member.local &&
|
||||
member.memberId === ownMembershipIdentity.memberId,
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
);
|
||||
const localMember$ = scope.behavior<LocalRTCMember | null>(
|
||||
mapScoped(scope, ownMember$, (_memberScope, member) =>
|
||||
createLocalRTCMember(member, backend.local),
|
||||
).pipe(map((member) => member ?? null)),
|
||||
);
|
||||
|
||||
// A packet from an identity that is not a member is dropped, so a host only
|
||||
// ever hears from attested members
|
||||
const data$ = backend.data$.pipe(
|
||||
map(({ senderId, topic, text }): DataMessage | null => {
|
||||
const member = remoteMembers$.value.find((m) =>
|
||||
m.backendIdentities.includes(senderId),
|
||||
);
|
||||
if (member === undefined) {
|
||||
logger.warn(`Dropping data from ${senderId}: not a member`);
|
||||
return null;
|
||||
}
|
||||
return { member, topic, text };
|
||||
}),
|
||||
filter((message) => message !== null),
|
||||
);
|
||||
|
||||
// Nothing leaves this device while it may already have been dropped from
|
||||
// the session: the member would show as away while still being heard
|
||||
localMembership.state$
|
||||
.pipe(
|
||||
map((membership) => membership.kind === "joined"),
|
||||
distinctUntilChanged(),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((publish) => backend.local.setPublishing(publish));
|
||||
|
||||
const state$ = scope.behavior<ParticipationState>(
|
||||
combineLatest([
|
||||
localMembership.state$,
|
||||
backend.local.connectionState$,
|
||||
]).pipe(
|
||||
scan(
|
||||
(previous, [membership, media]) =>
|
||||
participationState(previous, membership, media),
|
||||
{ kind: "waitingForTransport" } as ParticipationState,
|
||||
),
|
||||
distinctUntilChanged(deepCompare),
|
||||
),
|
||||
);
|
||||
|
||||
const leave = (): void => {
|
||||
if (state$.value.kind === "left") return;
|
||||
localMembership.leave();
|
||||
scope.end();
|
||||
};
|
||||
|
||||
return {
|
||||
connections$: backend.connections$,
|
||||
participation: {
|
||||
slot,
|
||||
leave,
|
||||
state$,
|
||||
localMember$,
|
||||
remoteMembers$,
|
||||
publish: async (request) => backend.local.publish(request),
|
||||
unpublish: async (source) => backend.local.unpublish(source),
|
||||
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
|
||||
connectedTransports$: transports.connected$,
|
||||
setAudioOutputDeviceId: async (deviceId) =>
|
||||
backend.setAudioOutputDeviceId(deviceId),
|
||||
sendData: async (topic, text) => backend.sendData(topic, text),
|
||||
data$,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The host's backend, or LiveKit configured from the participation options. */
|
||||
function createBackend(
|
||||
scope: ObservableScope,
|
||||
client: MatrixClient,
|
||||
{ backend, mediaQuality }: RTCParticipationOptions,
|
||||
context: MediaBackendContext,
|
||||
): MediaBackend {
|
||||
if (backend) return backend(scope, context);
|
||||
return createLivekitBackend(scope, context, { client, mediaQuality });
|
||||
}
|
||||
|
||||
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
|
||||
function mediaKeys$(session: JsSdkRTCSession): Observable<MediaKey> {
|
||||
return new Observable((subscriber) => {
|
||||
const onKey = (
|
||||
key: Uint8Array<ArrayBuffer>,
|
||||
index: number,
|
||||
_membership: CallMembershipIdentityParts,
|
||||
participantId: string,
|
||||
): void => subscriber.next({ participantId, index, key });
|
||||
session.on(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
|
||||
session.reemitEncryptionKeys();
|
||||
return () => session.off(MatrixRTCSessionEvent.EncryptionKeyChanged, onKey);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
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 { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||
import { EventType } from "matrix-js-sdk/lib/@types/event";
|
||||
import { type MatrixEvent } from "matrix-js-sdk/lib/models/event";
|
||||
import {
|
||||
type RoomState,
|
||||
RoomStateEvent,
|
||||
} from "matrix-js-sdk/lib/models/room-state";
|
||||
|
||||
import { createRTCSlot } from "./RTCSlot";
|
||||
import { type RTCSlotOptions } from "./api";
|
||||
import { MatrixRTCMode } from "./config";
|
||||
import { E2eeType } from "./encryption";
|
||||
import { mockEmitter, MockRTCSession, testScope } from "./utils/test";
|
||||
|
||||
const options: RTCSlotOptions = {
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
encryptionSystem: { kind: E2eeType.NONE },
|
||||
};
|
||||
|
||||
function setup(): {
|
||||
client: MatrixClient;
|
||||
room: Room;
|
||||
session: MockRTCSession;
|
||||
} {
|
||||
const room = {
|
||||
...mockEmitter(),
|
||||
roomId: "!room:example.org",
|
||||
getMembersWithMembership: (): [] => [],
|
||||
} as unknown as Room;
|
||||
const session = new MockRTCSession(room);
|
||||
const client = {
|
||||
getUserId: (): string => "@alice:example.org",
|
||||
getDeviceId: (): string => "AAAA",
|
||||
matrixRTC: { getRoomSession: (): MockRTCSession => session },
|
||||
} as unknown as MatrixClient;
|
||||
return { client, room, session };
|
||||
}
|
||||
|
||||
describe("createRTCSlot", () => {
|
||||
describe("status$", () => {
|
||||
it("is undefined while the room has no slot event", () => {
|
||||
const { client, room } = setup();
|
||||
const slot = createRTCSlot(testScope(), client, room, options);
|
||||
expect(slot.status$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows the slot event", () => {
|
||||
const { client, room, session } = setup();
|
||||
session.rtcSlot = { status: "open" };
|
||||
const slot = createRTCSlot(testScope(), client, room, options);
|
||||
expect(slot.status$.value).toBe("open");
|
||||
|
||||
session.rtcSlot = { status: "closed" };
|
||||
room.emit(
|
||||
RoomStateEvent.Events,
|
||||
{ getType: (): string => EventType.RTCSlot } as MatrixEvent,
|
||||
{} as RoomState,
|
||||
null,
|
||||
);
|
||||
expect(slot.status$.value).toBe("closed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk";
|
||||
import { EventType } from "matrix-js-sdk/lib/@types/event";
|
||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type MatrixRTCSession as JsSdkRTCSession,
|
||||
MatrixRTCSessionManager,
|
||||
type SlotDescription,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type MatrixEvent } from "matrix-js-sdk/lib/models/event";
|
||||
import { RoomStateEvent } from "matrix-js-sdk/lib/models/room-state";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
filter,
|
||||
fromEvent,
|
||||
map,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { ObservableScope } from "./reactive/ObservableScope";
|
||||
import { generateItems } from "./reactive/observable";
|
||||
import {
|
||||
type RTCMember,
|
||||
type RTCParticipation,
|
||||
type RTCParticipationOptions,
|
||||
type RTCSlot,
|
||||
type RTCSlotOptions,
|
||||
} from "./api";
|
||||
import { MatrixRTCError } from "./errors";
|
||||
import {
|
||||
createRTCParticipation,
|
||||
type ParticipationInternals,
|
||||
} from "./RTCParticipation";
|
||||
import { createMemberships$ } from "./matrixrtc/JsRtcSessionBehaviors";
|
||||
import {
|
||||
createMatrixMemberMetadata$,
|
||||
createRoomMembers$,
|
||||
} from "./matrixrtc/MatrixMemberMetadata";
|
||||
import {
|
||||
createRTCMember,
|
||||
type MemberContext,
|
||||
membershipKeys,
|
||||
} from "./matrixrtc/Members";
|
||||
import { createTransportRegistry } from "./matrixrtc/Transports";
|
||||
|
||||
/**
|
||||
* Takes the whole `MatrixClient` rather than a slice of it, and finds the
|
||||
* MatrixRTC session itself. The js-sdk is the MatrixRTC implementation today;
|
||||
* when the rust-rtc crate replaces it, the SDK has to bridge the crate to the
|
||||
* js-sdk client, and only the SDK knows what that bridge needs. Holding the
|
||||
* client keeps that change inside the SDK.
|
||||
*/
|
||||
export function createRTCSlot(
|
||||
scope: ObservableScope,
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
options: RTCSlotOptions,
|
||||
): RTCSlot {
|
||||
const logger = rootLogger.getChild("[RTCSlot]");
|
||||
const userId = client.getUserId();
|
||||
const deviceId = client.getDeviceId();
|
||||
if (!(userId && deviceId))
|
||||
throw new MatrixRTCError("The client has to be logged in");
|
||||
const { application = "m.call", id = "ROOM" } = options;
|
||||
|
||||
const jsSdkSession = sessionManager(
|
||||
scope,
|
||||
client,
|
||||
{ application, id },
|
||||
logger,
|
||||
).getRoomSession(room);
|
||||
const memberships$ = createMemberships$(scope, jsSdkSession);
|
||||
|
||||
const active$ = new BehaviorSubject<ParticipationInternals | null>(null);
|
||||
const participationScopes = new Set<ObservableScope>();
|
||||
scope.onEnd(() => {
|
||||
for (const participationScope of participationScopes)
|
||||
participationScope.end();
|
||||
});
|
||||
const transports = createTransportRegistry(
|
||||
scope,
|
||||
scope.behavior(
|
||||
active$.pipe(switchMap((active) => active?.connections$ ?? of([]))),
|
||||
),
|
||||
);
|
||||
const context: MemberContext = {
|
||||
metadata: createMatrixMemberMetadata$(
|
||||
scope,
|
||||
scope.behavior(memberships$.pipe(map(({ value }) => value))),
|
||||
createRoomMembers$(scope, room),
|
||||
),
|
||||
transports,
|
||||
own: { userId, deviceId },
|
||||
};
|
||||
|
||||
const members$ = scope.behavior<RTCMember[]>(
|
||||
memberships$.pipe(
|
||||
map(({ value }) => value),
|
||||
generateItems(
|
||||
"RTCSlot members",
|
||||
function* (memberships) {
|
||||
for (const membership of memberships)
|
||||
yield { keys: membershipKeys(membership), data: membership };
|
||||
},
|
||||
(memberScope, membership$) =>
|
||||
createRTCMember(memberScope, membership$, context),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// The status is undefined while the room has no slot event, and an explicit
|
||||
// undefined is not an initial value to `behavior`, so it goes in the stream
|
||||
const status$ = scope.behavior(
|
||||
fromEvent(room, RoomStateEvent.Events, (event: MatrixEvent) => event).pipe(
|
||||
filter((event) => event.getType() === EventType.RTCSlot),
|
||||
map(() => slotStatus(jsSdkSession)),
|
||||
startWith(slotStatus(jsSdkSession)),
|
||||
),
|
||||
);
|
||||
|
||||
const slot: RTCSlot = {
|
||||
roomId: room.roomId,
|
||||
application,
|
||||
id,
|
||||
status$,
|
||||
members$,
|
||||
participation$: scope.behavior(
|
||||
active$.pipe(map((active) => active?.participation ?? null)),
|
||||
),
|
||||
join: (participationOptions: RTCParticipationOptions): RTCParticipation => {
|
||||
if (active$.value !== null)
|
||||
throw new MatrixRTCError(
|
||||
"Already in this slot: leave the participation before joining again",
|
||||
);
|
||||
const participationScope = new ObservableScope();
|
||||
participationScopes.add(participationScope);
|
||||
const internals = createRTCParticipation(
|
||||
participationScope,
|
||||
{
|
||||
client,
|
||||
room,
|
||||
slot,
|
||||
jsSdkSession,
|
||||
members$,
|
||||
transports,
|
||||
slotOptions: options,
|
||||
logger,
|
||||
},
|
||||
participationOptions,
|
||||
);
|
||||
participationScope.onEnd(() => {
|
||||
participationScopes.delete(participationScope);
|
||||
if (active$.value === internals) active$.next(null);
|
||||
});
|
||||
active$.next(internals);
|
||||
return internals.participation;
|
||||
},
|
||||
};
|
||||
return slot;
|
||||
}
|
||||
|
||||
function slotStatus(
|
||||
session: Pick<JsSdkRTCSession, "getRtcSlot">,
|
||||
): "open" | "closed" | undefined {
|
||||
return session.getRtcSlot()?.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* The js-sdk keeps one session manager per slot. The client comes with the
|
||||
* manager for the default slot; any other slot gets a manager of its own,
|
||||
* which lives as long as the scope.
|
||||
*/
|
||||
function sessionManager(
|
||||
scope: ObservableScope,
|
||||
client: MatrixClient,
|
||||
{ application, id }: SlotDescription,
|
||||
logger: Logger,
|
||||
): MatrixRTCSessionManager {
|
||||
if (application === "m.call" && id === "ROOM") return client.matrixRTC;
|
||||
const manager = new MatrixRTCSessionManager(logger, client, {
|
||||
application,
|
||||
id,
|
||||
});
|
||||
manager.start();
|
||||
scope.onEnd(() => manager.stop());
|
||||
return manager;
|
||||
}
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
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 public types of the SDK: the slot, the participation, their members and
|
||||
* transports. What a member sends is in `media-api.ts`. `createRTCSlot` in
|
||||
* `RTCSlot.ts` is the only way to obtain an implementation of them.
|
||||
*/
|
||||
|
||||
import {
|
||||
type RTCNotificationType,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import { type Behavior } from "./reactive/Behavior";
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMemberMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type MemberMedia,
|
||||
type PublishRequest,
|
||||
} from "./media-api";
|
||||
import { type MediaBackendFactory } from "./media-backend/api";
|
||||
import { type EncryptionSystem } from "./encryption";
|
||||
import {
|
||||
type MatrixRTCMode,
|
||||
type MediaQuality,
|
||||
type SessionTimings,
|
||||
} from "./config";
|
||||
import { type MatrixRTCError } from "./errors";
|
||||
|
||||
export { MatrixRTCError } from "./errors";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slot
|
||||
|
||||
export interface RTCSlotOptions {
|
||||
/** The application the slot belongs to, as named in the membership. Default `m.call`. */
|
||||
application?: string;
|
||||
/** The application's slot in the room. Default `ROOM`. */
|
||||
id?: string;
|
||||
/** How memberships and keys in this slot are written. Resolved by the host. */
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
encryptionSystem: EncryptionSystem;
|
||||
}
|
||||
|
||||
/**
|
||||
* The namespace in a room that memberships join into. It exists while the
|
||||
* room does, with nobody in it too. The room has to be the one the client's
|
||||
* sync loop maintains (`client.getRoom(roomId)`, once the join has synced):
|
||||
* the detached copy `joinRoom` returns for a room joined just now never
|
||||
* receives the state the members are read from.
|
||||
*/
|
||||
export interface RTCSlot {
|
||||
readonly roomId: string;
|
||||
readonly application: string;
|
||||
readonly id: string;
|
||||
/** From the slot state event; undefined while the room has none. */
|
||||
status$: Behavior<"open" | "closed" | undefined>;
|
||||
/**
|
||||
* Everyone in the slot, one object per membership, kept for as long as the
|
||||
* membership is. Our own membership is in here too, with `local: true`,
|
||||
* once the room has seen it. No media: that is the participation's.
|
||||
*/
|
||||
members$: Behavior<RTCMember[]>;
|
||||
/** The participation we are in, null between `leave()` and the next `join()`. */
|
||||
participation$: Behavior<RTCParticipation | null>;
|
||||
/**
|
||||
* Joins the slot and returns the participation that lasts until its
|
||||
* `leave()`. Throws while `participation$` is not null: a slot has one local
|
||||
* member.
|
||||
*/
|
||||
join(options: RTCParticipationOptions): RTCParticipation; // Maybe MediaSession,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Participation
|
||||
|
||||
export interface RTCParticipationOptions {
|
||||
/**
|
||||
* Published at the join; `publish` on the participation adds to it from then
|
||||
* on. Kept as an option, rather than only the method, because the LiveKit
|
||||
* backend reads the initial requests to build the room's default capture
|
||||
* options before the first connection exists.
|
||||
*/
|
||||
publish: PublishRequest[];
|
||||
/**
|
||||
* LEGACY
|
||||
*
|
||||
* Conside already removing it. just skips code in js-sdk. But rust sdk will not have this feature.
|
||||
* MSC4075 notification sent with the join. A parameter of the MatrixRTC
|
||||
* join itself, so it is here even though it is named after calls; reacting
|
||||
* to a notification (ringing, timeouts, declines) is the application's job.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* Whatever the application wants to say about itself in the membership,
|
||||
* 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. */
|
||||
timings?: Partial<SessionTimings>;
|
||||
/** Limits on what is published; LiveKit's defaults otherwise. */
|
||||
mediaQuality?: MediaQuality;
|
||||
/**
|
||||
* Use this transport instead of asking the homeserver. Any shape the
|
||||
* backend serves: for LiveKit a `url`, a `livekit_service_url` or both.
|
||||
*/
|
||||
transport?: Transport;
|
||||
/** Use this transport when the homeserver advertises none of the backend's type. */
|
||||
fallbackTransport?: Transport;
|
||||
/**
|
||||
* What carries the media. LiveKit, configured from the options above, when
|
||||
* left out; see `createLivekitBackend` for building one with other options.
|
||||
*/
|
||||
backend?: MediaBackendFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* The homeserver link that is down, in the order they depend on each other:
|
||||
* without sync the membership is unknowable, and without a confirmed
|
||||
* membership the delayed leave cannot be refreshed.
|
||||
*/
|
||||
export type MatrixDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||
|
||||
/**
|
||||
* Why the participation is not connected: the first failing link, in the
|
||||
* order they depend on each other. The media connection comes last because
|
||||
* it is worth little while the Matrix side is down.
|
||||
*/
|
||||
export type DisconnectReason = MatrixDisconnectReason | "media";
|
||||
|
||||
export type ParticipationState =
|
||||
/** Asking the homeserver for a transport, or preparing it. */
|
||||
| { kind: "waitingForTransport" }
|
||||
/** Transport known; joining the session and connecting the media for the first time. */
|
||||
| { kind: "connecting" }
|
||||
/** Membership joined and the local media connection up. */
|
||||
| { kind: "connected" }
|
||||
/** Was connected and one link is down. Back to `connected` or on to `failed`. */
|
||||
| { kind: "reconnecting"; reason: DisconnectReason }
|
||||
/**
|
||||
* The membership or the media connection ended with an error. Terminal
|
||||
* unless `left`. A failed publication is not fatal: the member can still
|
||||
* receive, so it stays on the local member's media.
|
||||
*/
|
||||
| { kind: "failed"; error: MatrixRTCError }
|
||||
/** `leave()` was called. Terminal. */
|
||||
| { kind: "left" };
|
||||
|
||||
/** The span between our `join()` of a slot and our `leave()`. */
|
||||
export interface RTCParticipation {
|
||||
readonly slot: RTCSlot;
|
||||
/** Sends the leave, tears the connections down and ends the participation. */
|
||||
leave(): void;
|
||||
state$: Behavior<ParticipationState>;
|
||||
|
||||
/** Null until our own membership has been seen in the room. */
|
||||
localMember$: Behavior<LocalRTCMember | null>;
|
||||
/**
|
||||
* The slot's other members, each with the media this participation carries
|
||||
* for it. A call view needs nothing but this and `localMember$` to render.
|
||||
*/
|
||||
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||
|
||||
/**
|
||||
* Publishes a source and resolves with its track, which also shows up in
|
||||
* `localMember$.tracks$`. Works from the moment `join()` returns: before the
|
||||
* transport is connected.
|
||||
*/
|
||||
publish(
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||
/** Removes one of our publications; a screen share takes its audio with it. */
|
||||
unpublish(source: MediaSource): Promise<void>;
|
||||
|
||||
/**
|
||||
* Whether the slot has grown large enough that MatrixRTC has stopped
|
||||
* rotating the media encryption key.
|
||||
*/
|
||||
keyRotationSuppressed$: Behavior<boolean>;
|
||||
|
||||
/** Transports the participation currently holds a live connection to. */
|
||||
connectedTransports$: Behavior<TransportMetadata[]>;
|
||||
|
||||
/**
|
||||
* Plays the members' audio on this device from now on. Rejects where the
|
||||
* browser cannot switch to it. A host that routes audio itself, or leaves
|
||||
* the browser's choice, never calls it.
|
||||
*/
|
||||
setAudioOutputDeviceId(deviceId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sends a short text to every member on the local transport, over a
|
||||
* reliable data channel beside the media. A packet holds about 15 KiB;
|
||||
* anything larger belongs in a room event. Rejects while the local
|
||||
* transport is not connected. Encrypted on the wire like the media, but
|
||||
* not end to end with the media key.
|
||||
*/
|
||||
sendData(topic: string, text: string): Promise<void>;
|
||||
/**
|
||||
* What remote members sent with `sendData`, on every transport the
|
||||
* participation is connected to. A message from an identity that is not a
|
||||
* member is dropped, so a host only ever hears from attested members.
|
||||
*/
|
||||
data$: Observable<DataMessage>;
|
||||
}
|
||||
|
||||
/** One `sendData` call as it arrives at a remote member. */
|
||||
export interface DataMessage {
|
||||
member: RemoteRTCMember;
|
||||
topic: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transports
|
||||
|
||||
/**
|
||||
* One transport advertised in a membership. Transport independent: `type` and
|
||||
* `id` are all the SDK needs; `raw` and `resolved$` are there for a
|
||||
* backend-specific developer panel and for connection diagnostics.
|
||||
*/
|
||||
export interface TransportMetadata {
|
||||
/** `"livekit"` today. */
|
||||
type: string;
|
||||
/** Stable key, unique per transport in the slot: the raw transport, serialised with sorted keys. */
|
||||
id: string;
|
||||
/** The transport object as it appears in the membership. */
|
||||
raw: Transport;
|
||||
/**
|
||||
* What the backend had to fetch before it could connect. Undefined until the
|
||||
* connection has resolved it, and again after the connection stops.
|
||||
*/
|
||||
resolved$: Behavior<ResolvedTransport | undefined>;
|
||||
}
|
||||
|
||||
export type ResolvedTransport =
|
||||
| {
|
||||
type: "livekit";
|
||||
/** The SFU websocket url, as opposed to the JWT service url in `raw`. */
|
||||
url: string;
|
||||
/** A secret: fit for a developer panel, never for a log line. */
|
||||
token: string;
|
||||
roomAlias: string;
|
||||
identity: string;
|
||||
}
|
||||
| { type: string; [key: string]: unknown };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Members
|
||||
|
||||
/** A membership in the slot, with what the SDK derives from it. No media. */
|
||||
export interface RTCMember {
|
||||
local: boolean;
|
||||
/**
|
||||
* The identity this member is known by on its transport for matching
|
||||
* purposes, read off the membership: `${userId}:${deviceId}`. A stable key
|
||||
* per membership and a debug label for a host; the SDK matches media
|
||||
* against `backendIdentities`, not this.
|
||||
*/
|
||||
rtcBackendIdentity: string;
|
||||
/**
|
||||
* Every identity this member may publish under on its transport: the
|
||||
* participant identity the SFU sees, and the sender of its data packets.
|
||||
* One per authentication flow the member's transport allows, so a
|
||||
* transport with both a `url` and a `livekit_service_url` lists the hashed
|
||||
* and the legacy identity, and the backend matches whichever shows up.
|
||||
* Opaque to a host.
|
||||
*/
|
||||
backendIdentities: readonly string[];
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
/** The membership's own id; what the Matrix 2.0 identity is derived from. */
|
||||
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. `m.call.intent`. */
|
||||
applicationData$: Behavior<Record<string, unknown>>;
|
||||
|
||||
displayName$: Behavior<string>;
|
||||
avatarUrl$: Behavior<string | undefined>;
|
||||
}
|
||||
|
||||
export interface RemoteRTCMember extends RTCMember, MemberMedia {
|
||||
local: false;
|
||||
}
|
||||
|
||||
/** Our member: the same as a remote one, with tracks that carry the controls. */
|
||||
export interface LocalRTCMember extends RTCMember, LocalMemberMedia {
|
||||
local: true;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The MatrixRTC mode determines how the client interacts with the MatrixRTC
|
||||
* backend and other participants. Resolved by the host.
|
||||
*/
|
||||
export enum MatrixRTCMode {
|
||||
/** Multi-SFU transport, membership state events. */
|
||||
Compatibility = "compatibility",
|
||||
/**
|
||||
* Multi-SFU transport with sticky membership events and a uuid member id.
|
||||
* The mode decides how the membership is written, not how the media
|
||||
* transport is authenticated with: that follows the transport's shape and
|
||||
* our role on it.
|
||||
*/
|
||||
Matrix_2_0 = "matrix_2_0",
|
||||
}
|
||||
|
||||
export interface DelayedLeaveTimings {
|
||||
/** How long (in milliseconds) the homeserver waits before sending the delayed leave. */
|
||||
delay_ms: number;
|
||||
/** How often (in milliseconds) the client restarts the delayed leave. */
|
||||
restart_ms?: number;
|
||||
/** How long (in milliseconds) a restart may take before it counts as failed. */
|
||||
restart_timeout_ms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's timings. A host that has them in its configuration passes
|
||||
* them in; these are the defaults a MatrixRTC backend is expected to accept.
|
||||
*/
|
||||
export interface SessionTimings {
|
||||
/** How long the sync may be down before the client counts as disconnected. */
|
||||
syncDisconnectGracePeriodMs: number;
|
||||
/** How long to wait before retrying after a network error on any request. */
|
||||
networkErrorRetryMs: number;
|
||||
/** How long to wait before rotating the media key when someone leaves. */
|
||||
waitForKeyRotationMs?: number;
|
||||
/** When the membership expires if the client stops renewing it. */
|
||||
membershipEventExpiryMs?: number;
|
||||
/** The session size at which the media key stops being rotated. */
|
||||
keyRotationParticipantLimit?: number;
|
||||
delayedLeave: DelayedLeaveTimings;
|
||||
/**
|
||||
* The timings used where the SFU restarts the delayed leave on the
|
||||
* client's behalf, which lets the client stop renewing it itself.
|
||||
*/
|
||||
delegatedDelayedLeave: DelayedLeaveTimings;
|
||||
}
|
||||
|
||||
export const defaultSessionTimings: SessionTimings = {
|
||||
syncDisconnectGracePeriodMs: 10_000,
|
||||
networkErrorRetryMs: 1_000,
|
||||
delayedLeave: { delay_ms: 18_000, restart_ms: 4_000 },
|
||||
delegatedDelayedLeave: { delay_ms: 3_600_000, restart_ms: 300_000 },
|
||||
};
|
||||
|
||||
/** Media quality settings a host may pass; everything is optional. */
|
||||
export interface MediaQuality {
|
||||
/** Video codec preference. The server must also have the codec enabled. Default `vp8`. */
|
||||
video_codec?: "vp8" | "vp9" | "h264" | "av1";
|
||||
video?: {
|
||||
/** Max resolution height in pixels. Default 720. */
|
||||
max_resolution?: number;
|
||||
/** Max bitrate in bits per second. Default 1700000. */
|
||||
max_bitrate?: number;
|
||||
/** Max framerate. Default 30. */
|
||||
max_framerate?: number;
|
||||
/** Simulcast layers, lowest quality first. Default 180p and 360p. */
|
||||
simulcast_layers?: Array<{ height: number; bitrate: number }>;
|
||||
};
|
||||
screen_share?: {
|
||||
/** Max resolution height in pixels. Default 1080. */
|
||||
max_resolution?: number;
|
||||
/** Max bitrate in bits per second. Default 5000000. */
|
||||
max_bitrate?: number;
|
||||
/** Max framerate. Default 30. */
|
||||
max_framerate?: number;
|
||||
/** Simulcast layers, lowest quality first. Without them LiveKit adds one layer at half resolution. */
|
||||
simulcast_layers?: Array<{
|
||||
height: number;
|
||||
bitrate: number;
|
||||
framerate?: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultMediaQuality = {
|
||||
video_codec: "vp8",
|
||||
video: { max_resolution: 720, max_bitrate: 1_700_000, max_framerate: 30 },
|
||||
screen_share: {
|
||||
max_resolution: 1080,
|
||||
max_bitrate: 5_000_000,
|
||||
max_framerate: 30,
|
||||
},
|
||||
} as const satisfies MediaQuality;
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
export enum E2eeType {
|
||||
NONE = 0,
|
||||
PER_PARTICIPANT = 1,
|
||||
SHARED_KEY = 2,
|
||||
}
|
||||
|
||||
export interface Unencrypted {
|
||||
kind: E2eeType.NONE;
|
||||
}
|
||||
|
||||
export interface SharedSecret {
|
||||
kind: E2eeType.SHARED_KEY;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export interface PerParticipantE2EE {
|
||||
kind: E2eeType.PER_PARTICIPANT;
|
||||
}
|
||||
|
||||
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
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 ConnectionError } from "livekit-client";
|
||||
|
||||
export enum ErrorCode {
|
||||
/** No MatrixRTC transport provided by the homeserver and no fallback configured. */
|
||||
MISSING_MATRIX_RTC_TRANSPORT = "MISSING_MATRIX_RTC_TRANSPORT",
|
||||
CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR",
|
||||
INTERNAL_MEMBERSHIP_MANAGER = "INTERNAL_MEMBERSHIP_MANAGER",
|
||||
FAILED_TO_START_LIVEKIT = "FAILED_TO_START_LIVEKIT",
|
||||
/** LiveKit indicates that the server has hit its track limits. */
|
||||
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
|
||||
OPEN_ID_ERROR = "OPEN_ID_ERROR",
|
||||
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
|
||||
SFU_ERROR = "SFU_ERROR",
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||
}
|
||||
|
||||
export enum ErrorCategory {
|
||||
/** Calling is not supported, the server is misconfigured (JWT service missing, no MSC support). */
|
||||
CONFIGURATION_ISSUE = "CONFIGURATION_ISSUE",
|
||||
NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY",
|
||||
CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION",
|
||||
UNKNOWN = "UNKNOWN",
|
||||
SYSTEM_FAILURE = "SYSTEM_FAILURE",
|
||||
}
|
||||
|
||||
/**
|
||||
* An error raised by the client. `code` says what went wrong in a form a host
|
||||
* can translate; `cause` holds the underlying error where there is one.
|
||||
*/
|
||||
export class MatrixRTCError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public readonly code: ErrorCode = ErrorCode.UNKNOWN_ERROR,
|
||||
public readonly category: ErrorCategory = ErrorCategory.UNKNOWN,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "MatrixRTCError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps whatever a backend threw into the SDK's error type, keeping it as the cause. */
|
||||
export function toMatrixRTCError(error: unknown): MatrixRTCError {
|
||||
if (error instanceof MatrixRTCError) return error;
|
||||
if (error instanceof Error)
|
||||
return new MatrixRTCError(
|
||||
error.message || error.name,
|
||||
ErrorCode.UNKNOWN_ERROR,
|
||||
ErrorCategory.UNKNOWN,
|
||||
{ cause: error },
|
||||
);
|
||||
return new MatrixRTCError(String(error));
|
||||
}
|
||||
|
||||
export class MatrixRTCTransportMissingError extends MatrixRTCError {
|
||||
public constructor(public readonly domain: string) {
|
||||
super(
|
||||
`The homeserver ${domain} advertises no MatrixRTC transport`,
|
||||
ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class MembershipManagerError extends MatrixRTCError {
|
||||
public constructor(cause: Error) {
|
||||
super(
|
||||
`The membership manager stopped: ${cause.message}`,
|
||||
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
|
||||
ErrorCategory.SYSTEM_FAILURE,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnknownRTCError extends MatrixRTCError {
|
||||
public constructor(cause: Error) {
|
||||
super(
|
||||
cause.message || "Unknown error",
|
||||
ErrorCode.UNKNOWN_ERROR,
|
||||
ErrorCategory.UNKNOWN,
|
||||
{
|
||||
cause,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FailToGetOpenIdToken extends MatrixRTCError {
|
||||
public constructor(cause: Error) {
|
||||
super(
|
||||
cause instanceof MatrixRTCError
|
||||
? cause.message
|
||||
: `Could not authenticate with the transport: ${cause.message}`,
|
||||
ErrorCode.OPEN_ID_ERROR,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class NoMatrix2AuthorizationService extends MatrixRTCError {
|
||||
public constructor(cause: Error) {
|
||||
super(
|
||||
"The transport's authorization service does not support Matrix 2.0",
|
||||
ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FailToStartLivekitConnection extends MatrixRTCError {
|
||||
public constructor(detail?: string) {
|
||||
super(
|
||||
`Failed to start the media connection${detail ? `: ${detail}` : ""}`,
|
||||
ErrorCode.FAILED_TO_START_LIVEKIT,
|
||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class InsufficientCapacityError extends MatrixRTCError {
|
||||
public constructor() {
|
||||
super(
|
||||
"The media server has no capacity left",
|
||||
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
|
||||
ErrorCategory.UNKNOWN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SFURoomCreationRestrictedError extends MatrixRTCError {
|
||||
public constructor() {
|
||||
super(
|
||||
"The media server did not let this client create the room",
|
||||
ErrorCode.SFU_ERROR,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PeerConnectionTimeoutError extends MatrixRTCError {
|
||||
public constructor() {
|
||||
super(
|
||||
"Timed out establishing the media connection",
|
||||
ErrorCode.SFU_ERROR,
|
||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class LivekitConnectionError extends MatrixRTCError {
|
||||
public constructor(cause: ConnectionError) {
|
||||
super(
|
||||
`Could not connect to the media server: ${cause.message}`,
|
||||
ErrorCode.SFU_ERROR,
|
||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 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 { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "events";
|
||||
import { ClientEvent, SyncState } from "matrix-js-sdk";
|
||||
import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { ObservableScope } from "../reactive/ObservableScope";
|
||||
import { createHomeserverConnected$ } from "./HomeserverConnected";
|
||||
import { testScope, withTestScheduler } from "../utils/test";
|
||||
|
||||
/**
|
||||
* Minimal stub of a Matrix client sufficient for our tests:
|
||||
```
|
||||
createHomeserverConnected$(
|
||||
scope: ObservableScope,
|
||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||
matrixRTCSession: NodeStyleEventEmitter &
|
||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||
)
|
||||
```
|
||||
*/
|
||||
class MockMatrixClient extends EventEmitter {
|
||||
private syncState: SyncState;
|
||||
public constructor(initial: SyncState) {
|
||||
super();
|
||||
this.syncState = initial;
|
||||
}
|
||||
public setSyncState(state: SyncState): void {
|
||||
this.syncState = state;
|
||||
// Matrix's Sync event in createHomeserverConnected$ expects [SyncState]
|
||||
this.emit(ClientEvent.Sync, [state]);
|
||||
}
|
||||
public getSyncState(): SyncState {
|
||||
return this.syncState;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal stub of MatrixRTCSession (membership manager):
|
||||
```
|
||||
createHomeserverConnected$(
|
||||
scope: ObservableScope,
|
||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||
matrixRTCSession: NodeStyleEventEmitter &
|
||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||
)
|
||||
```
|
||||
*/
|
||||
class MockMatrixRTCSession extends EventEmitter {
|
||||
public membershipStatus: Status;
|
||||
public probablyLeft: boolean;
|
||||
|
||||
public constructor(props: {
|
||||
membershipStatus: Status;
|
||||
probablyLeft: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.membershipStatus = props.membershipStatus;
|
||||
this.probablyLeft = props.probablyLeft;
|
||||
}
|
||||
|
||||
public setMembershipStatus(status: Status): void {
|
||||
this.membershipStatus = status;
|
||||
this.emit(MembershipManagerEvent.StatusChanged);
|
||||
}
|
||||
|
||||
public setProbablyLeft(flag: boolean): void {
|
||||
this.probablyLeft = flag;
|
||||
this.emit(MembershipManagerEvent.ProbablyLeft);
|
||||
}
|
||||
}
|
||||
|
||||
describe("createHomeserverConnected$", () => {
|
||||
let scope: ObservableScope;
|
||||
let client: MockMatrixClient;
|
||||
let session: MockMatrixRTCSession;
|
||||
|
||||
beforeEach(() => {
|
||||
scope = new ObservableScope();
|
||||
client = new MockMatrixClient(SyncState.Error); // start disconnected
|
||||
session = new MockMatrixRTCSession({
|
||||
membershipStatus: Status.Disconnected,
|
||||
probablyLeft: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
});
|
||||
|
||||
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
||||
// easy enough to read them so I think they can stay.
|
||||
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
|
||||
it("reports syncing reason when sync state is not Syncing", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||
});
|
||||
|
||||
it("reports membership reason when sync is Syncing but membership is not Connected", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||
});
|
||||
|
||||
it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
// Make sync loop OK
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
// Indicate probable leave before connection
|
||||
session.setProbablyLeft(true);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||
});
|
||||
|
||||
it("becomes null (connected) only when all three conditions are satisfied", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
// 1. Sync loop connected
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("membership"); // not yet membership connected
|
||||
// 2. Membership connected
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null); // probablyLeft is false
|
||||
});
|
||||
|
||||
it("returns syncing reason when sync loop leaves Syncing", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
// Reach connected state
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
// Sync loop error => should report syncing reason
|
||||
client.setSyncState(SyncState.Error);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||
});
|
||||
|
||||
it("returns membershipConnected reason when membership status becomes disconnected", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
session.setMembershipStatus(Status.Disconnected);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||
});
|
||||
|
||||
it("returns certainlyConnected reason when ProbablyLeft is emitted", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||
});
|
||||
|
||||
it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||
|
||||
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
||||
session.setProbablyLeft(false);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
});
|
||||
|
||||
it("composite sequence reflects each individual failure reason", () => {
|
||||
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||
|
||||
// Initially: sync error + membership disconnected → syncing wins (highest priority)
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||
|
||||
// Fix sync only → membershipConnected is now the blocker
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("membership");
|
||||
|
||||
// Fix membership → all conditions satisfied
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
// Introduce probablyLeft → certainlyConnected
|
||||
session.setProbablyLeft(true);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("probablyLeft");
|
||||
|
||||
// Restore notProbablyLeft → connected again
|
||||
session.setProbablyLeft(false);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual(null);
|
||||
|
||||
// Drop sync → syncing reason
|
||||
client.setSyncState(SyncState.Error);
|
||||
expect(hsConnected.disconnectReason$.value).toEqual("sync");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHomeserverConnected$ - disconnectReason$ reason values", () => {
|
||||
let scope: ObservableScope;
|
||||
let client: MockMatrixClient;
|
||||
let session: MockMatrixRTCSession;
|
||||
|
||||
beforeEach(() => {
|
||||
scope = new ObservableScope();
|
||||
// Start with sync failing and membership disconnected
|
||||
client = new MockMatrixClient(SyncState.Error);
|
||||
session = new MockMatrixRTCSession({
|
||||
membershipStatus: Status.Disconnected,
|
||||
probablyLeft: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scope.end();
|
||||
});
|
||||
|
||||
it("is null when all three conditions are satisfied", () => {
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(disconnectReason$.value).toEqual(null);
|
||||
});
|
||||
|
||||
it("reports syncing when sync loop is not Syncing", () => {
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
// client starts with SyncState.Error, membership also disconnected
|
||||
expect(disconnectReason$.value).toEqual("sync");
|
||||
});
|
||||
|
||||
it("reports membershipConnected when sync is fine but membership is not Connected", () => {
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
// session still Status.Disconnected
|
||||
expect(disconnectReason$.value).toEqual("membership");
|
||||
});
|
||||
|
||||
it("reports certainlyConnected when probablyLeft is true", () => {
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
session.setProbablyLeft(true);
|
||||
expect(disconnectReason$.value).toEqual("probablyLeft");
|
||||
});
|
||||
|
||||
it("prioritises syncing over membershipConnected when both fail", () => {
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
// Both sync (Error) and membership (Disconnected) are failing
|
||||
expect(disconnectReason$.value).toEqual("sync");
|
||||
});
|
||||
|
||||
it("updates reason as conditions change", () => {
|
||||
const { disconnectReason$ } = createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
session,
|
||||
0,
|
||||
);
|
||||
// Initially: syncing fails
|
||||
expect(disconnectReason$.value).toEqual("sync");
|
||||
|
||||
// Fix sync → membershipConnected is now the blocker
|
||||
client.setSyncState(SyncState.Syncing);
|
||||
expect(disconnectReason$.value).toEqual("membership");
|
||||
|
||||
// Fix membership → probablyLeft makes certainlyConnected fail
|
||||
session.setProbablyLeft(true);
|
||||
session.setMembershipStatus(Status.Connected);
|
||||
expect(disconnectReason$.value).toEqual("probablyLeft");
|
||||
|
||||
// Clear probablyLeft → all conditions satisfied
|
||||
session.setProbablyLeft(false);
|
||||
expect(disconnectReason$.value).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHomeserverConnected$ - Grace Period", () => {
|
||||
const GRACE_PERIOD = 5;
|
||||
|
||||
function marbleTest(
|
||||
syncStateMarbles: string,
|
||||
expectedConnectedMarbles: string,
|
||||
): void {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const syncState$ = behavior(syncStateMarbles, {
|
||||
s: SyncState.Syncing,
|
||||
e: SyncState.Error,
|
||||
});
|
||||
const client = new MockMatrixClient(syncState$.value);
|
||||
schedule(syncStateMarbles, {
|
||||
s: () => client.setSyncState(SyncState.Syncing),
|
||||
e: () => client.setSyncState(SyncState.Error),
|
||||
});
|
||||
const session = new MockMatrixRTCSession({
|
||||
membershipStatus: Status.Connected,
|
||||
probablyLeft: false,
|
||||
});
|
||||
const hsConnected = createHomeserverConnected$(
|
||||
testScope(),
|
||||
client,
|
||||
session,
|
||||
GRACE_PERIOD,
|
||||
);
|
||||
expectObservable(hsConnected.disconnectReason$).toBe(
|
||||
expectedConnectedMarbles,
|
||||
{
|
||||
y: null,
|
||||
n: "sync",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
|
||||
// - Initial state: Everything is connected
|
||||
// - Sync error occurs -> should remain connected due to grace period
|
||||
// - After grace period, not connected
|
||||
marbleTest("se", "y-----n");
|
||||
// If the sync error takes longer to occur, it should take equally long for
|
||||
// the connection state to change
|
||||
marbleTest("s--e", "y-------n");
|
||||
});
|
||||
|
||||
it("recovers immediately if sync returns during grace period", () => {
|
||||
// - Initial state: Connected
|
||||
// - Sync error occurs
|
||||
// - Sync recovers BEFORE the grace period expires
|
||||
// - Connection state remains constant
|
||||
marbleTest("se--s", "y");
|
||||
});
|
||||
|
||||
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
|
||||
// - Initial error: connection eventually flips to false
|
||||
// - Back to Syncing -> Must be connected immediately (synchronously)
|
||||
marbleTest("e-----s", "y----ny");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 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 {
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||
import {
|
||||
fromEvent,
|
||||
startWith,
|
||||
map,
|
||||
tap,
|
||||
type Observable,
|
||||
distinctUntilChanged,
|
||||
switchMap,
|
||||
of,
|
||||
delay,
|
||||
combineLatest,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { type Behavior } from "../reactive/Behavior";
|
||||
import { defaultSessionTimings } from "../config";
|
||||
import { type MatrixDisconnectReason } from "../api";
|
||||
|
||||
type NodeEventHandler = (...args: unknown[]) => void;
|
||||
|
||||
interface NodeStyleEventEmitter {
|
||||
addListener(eventName: string | symbol, handler: NodeEventHandler): this;
|
||||
removeListener(eventName: string | symbol, handler: NodeEventHandler): this;
|
||||
}
|
||||
|
||||
export interface HomeserverConnected {
|
||||
/** Null while the homeserver connection is healthy, the first failing link otherwise. */
|
||||
disconnectReason$: Behavior<MatrixDisconnectReason | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
|
||||
* for the purposes of a MatrixRTC session.
|
||||
*
|
||||
* `disconnectReason$` emits `null` when all conditions are satisfied, or the first failing
|
||||
* reason (priority: syncing > membershipConnected > certainlyConnected):
|
||||
* 1. Sync loop is not in SyncState.Syncing (after grace period) → "sync"
|
||||
* 2. membershipStatus !== Status.Connected → "membership"
|
||||
* 3. probablyLeft === true → "probablyLeft"
|
||||
*
|
||||
* @param scope - The observable scope for lifecycle management.
|
||||
* @param client - The Matrix client to monitor sync state.
|
||||
* @param matrixRTCSession - The RTC session to monitor membership.
|
||||
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
|
||||
* If not provided, uses the config value (default 10000ms).
|
||||
*/
|
||||
export function createHomeserverConnected$(
|
||||
scope: ObservableScope,
|
||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||
matrixRTCSession: NodeStyleEventEmitter &
|
||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||
gracePeriodMs?: number,
|
||||
): HomeserverConnected {
|
||||
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||
// Get grace period from parameter or config (default 10000ms)
|
||||
const graceMs =
|
||||
gracePeriodMs ?? defaultSessionTimings.syncDisconnectGracePeriodMs;
|
||||
|
||||
const syncing$ = (
|
||||
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
||||
).pipe(
|
||||
startWith([client.getSyncState()]),
|
||||
map(([state]) => state === SyncState.Syncing),
|
||||
distinctUntilChanged(),
|
||||
switchMap((isSyncing) => {
|
||||
if (isSyncing || graceMs <= 0) {
|
||||
return of(isSyncing);
|
||||
}
|
||||
return of(false).pipe(delay(graceMs), startWith(true));
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const rtsSession$ = scope.behavior<Status>(
|
||||
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
|
||||
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
|
||||
),
|
||||
matrixRTCSession.membershipStatus ?? Status.Unknown,
|
||||
);
|
||||
|
||||
const membershipConnected$ = rtsSession$.pipe(
|
||||
map((status) => status === Status.Connected),
|
||||
);
|
||||
|
||||
// This is basically notProbablyLeft$
|
||||
//
|
||||
// probablyLeft is computed by a local timer that mimics the server delayed event.
|
||||
// If we locally predict our server event timed out. We consider ourselves as probablyLeft
|
||||
// even though we might not yet have received the delayed event leave.
|
||||
//
|
||||
// If that is not the case we certainly still have a valid membership on the matrix network
|
||||
// independet if the sync currently works.
|
||||
const certainlyConnected$ = fromEvent(
|
||||
matrixRTCSession,
|
||||
MembershipManagerEvent.ProbablyLeft,
|
||||
).pipe(
|
||||
startWith(null),
|
||||
map(() => matrixRTCSession.probablyLeft !== true),
|
||||
);
|
||||
|
||||
const disconnectReason$ = scope.behavior(
|
||||
combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe(
|
||||
map(([syncing, membership, certainly]): MatrixDisconnectReason | null => {
|
||||
if (!syncing) return "sync";
|
||||
if (!membership) return "membership";
|
||||
if (!certainly) return "probablyLeft";
|
||||
return null;
|
||||
}),
|
||||
tap((reason) => {
|
||||
logger.info(`Homeserver connected update: ${reason ?? "connected"}`);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return { disconnectReason$ };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright 2025 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 { MatrixRTCSessionEvent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { createKeyRotationSuppressed$ } from "./JsRtcSessionBehaviors";
|
||||
import { testScope } from "../utils/test";
|
||||
|
||||
describe("JsRtcSessionBehaviors", () => {
|
||||
describe("createKeyRotationSuppressed$", () => {
|
||||
it("emits initial value from isKeyRotationSuppressed and updates when KeyRotationSuppressedChanged event is emitted", () => {
|
||||
const scope = testScope();
|
||||
const emitter = new EventEmitter();
|
||||
const mockSession = Object.assign(emitter, {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
isKeyRotationSuppressed: false,
|
||||
});
|
||||
|
||||
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
|
||||
scope,
|
||||
mockSession as any,
|
||||
);
|
||||
|
||||
expect(keyRotationSuppressed$.value).toBe(false);
|
||||
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, true);
|
||||
|
||||
expect(keyRotationSuppressed$.value).toBe(true);
|
||||
|
||||
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, false);
|
||||
|
||||
expect(keyRotationSuppressed$.value).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright 2025 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 CallMembership,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { fromEvent } from "rxjs";
|
||||
|
||||
import {
|
||||
Epoch,
|
||||
trackEpoch,
|
||||
type ObservableScope,
|
||||
} from "../reactive/ObservableScope";
|
||||
import { type Behavior } from "../reactive/Behavior";
|
||||
|
||||
export const createMemberships$ = (
|
||||
scope: ObservableScope,
|
||||
matrixRTCSession: MatrixRTCSession,
|
||||
): Behavior<Epoch<CallMembership[]>> => {
|
||||
return scope.behavior(
|
||||
fromEvent(
|
||||
matrixRTCSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
(_, memberships: CallMembership[]) => memberships,
|
||||
).pipe(trackEpoch()),
|
||||
new Epoch(matrixRTCSession.memberships),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the session has grown large enough that MatrixRTC has stopped rotating the media
|
||||
* encryption key. While this is true the key in use is still shared with new joiners, but no new
|
||||
* key is generated when someone joins or leaves.
|
||||
*/
|
||||
export const createKeyRotationSuppressed$ = (
|
||||
scope: ObservableScope,
|
||||
matrixRTCSession: MatrixRTCSession,
|
||||
): Behavior<boolean> => {
|
||||
return scope.behavior(
|
||||
fromEvent(
|
||||
matrixRTCSession,
|
||||
MatrixRTCSessionEvent.KeyRotationSuppressedChanged,
|
||||
(suppressed: boolean) => suppressed,
|
||||
),
|
||||
matrixRTCSession.isKeyRotationSuppressed,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { BehaviorSubject, type Observable, Subject, throwError } from "rxjs";
|
||||
|
||||
import { defaultSessionTimings } from "../config";
|
||||
import { MatrixRTCError } from "../errors";
|
||||
import { type HomeserverConnected } from "./HomeserverConnected";
|
||||
import {
|
||||
createLocalMembership$,
|
||||
type LocalMembership,
|
||||
type PreparedTransport,
|
||||
} from "./LocalMembership";
|
||||
import { flushPromises, testScope } from "../utils/test";
|
||||
|
||||
const transport: Transport = { type: "livekit", livekit_service_url: "u" };
|
||||
|
||||
describe("createLocalMembership$", () => {
|
||||
it("waits for the transport, joins, and leaves on leave()", async () => {
|
||||
const { membership, homeserver, prepared$ } = setup();
|
||||
expect(membership.state$.value).toEqual({ kind: "waitingForTransport" });
|
||||
|
||||
homeserver.disconnectReason$.next("membership");
|
||||
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||
expect(membership.state$.value).toEqual({ kind: "joining" });
|
||||
|
||||
homeserver.disconnectReason$.next(null);
|
||||
expect(membership.state$.value).toEqual({ kind: "joined" });
|
||||
|
||||
membership.leave();
|
||||
await flushPromises();
|
||||
expect(membership.state$.value).toEqual({ kind: "left" });
|
||||
});
|
||||
|
||||
it("reconnects only after it was joined once", () => {
|
||||
const { membership, homeserver, prepared$ } = setup();
|
||||
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||
expect(membership.state$.value).toEqual({ kind: "joined" });
|
||||
|
||||
homeserver.disconnectReason$.next("sync");
|
||||
expect(membership.state$.value).toEqual({
|
||||
kind: "reconnecting",
|
||||
reason: "sync",
|
||||
});
|
||||
homeserver.disconnectReason$.next("probablyLeft");
|
||||
expect(membership.state$.value).toEqual({
|
||||
kind: "reconnecting",
|
||||
reason: "probablyLeft",
|
||||
});
|
||||
homeserver.disconnectReason$.next(null);
|
||||
expect(membership.state$.value).toEqual({ kind: "joined" });
|
||||
});
|
||||
|
||||
it("joins with the delegated timings and hands over each delay id where the backend can", async () => {
|
||||
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
|
||||
prepared$.next({ transport, canDelegateDelayedLeave: true });
|
||||
await flushPromises();
|
||||
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
||||
transport,
|
||||
defaultSessionTimings.delegatedDelayedLeave,
|
||||
);
|
||||
|
||||
delayId$.next("delay-1");
|
||||
await flushPromises();
|
||||
expect(delegate).toHaveBeenCalledWith("delay-1");
|
||||
delayId$.next("delay-2");
|
||||
await flushPromises();
|
||||
expect(delegate).toHaveBeenLastCalledWith("delay-2");
|
||||
});
|
||||
|
||||
it("never hands the leave over where the backend cannot take it", async () => {
|
||||
const { prepared$, delayId$, joinMatrixRTC, delegate } = setup();
|
||||
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||
delayId$.next("delay-1");
|
||||
await flushPromises();
|
||||
expect(joinMatrixRTC).toHaveBeenCalledWith(
|
||||
transport,
|
||||
defaultSessionTimings.delayedLeave,
|
||||
);
|
||||
expect(delegate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a transport that could not be prepared as failed", () => {
|
||||
const error = new MatrixRTCError("no token");
|
||||
const { membership } = setup(throwError(() => error));
|
||||
expect(membership.state$.value).toEqual({ kind: "failed", error });
|
||||
});
|
||||
|
||||
it("reports the membership manager giving up as failed, and stays failed", () => {
|
||||
const { membership, homeserver, prepared$, membershipManagerError$ } =
|
||||
setup();
|
||||
prepared$.next({ transport, canDelegateDelayedLeave: false });
|
||||
membershipManagerError$.next(new Error("gave up"));
|
||||
const state = membership.state$.value;
|
||||
expect(state.kind).toBe("failed");
|
||||
|
||||
homeserver.disconnectReason$.next("sync");
|
||||
homeserver.disconnectReason$.next(null);
|
||||
expect(membership.state$.value).toBe(state);
|
||||
});
|
||||
|
||||
it("is left after a failure once leave() is called", () => {
|
||||
const { membership } = setup(throwError(() => new MatrixRTCError("x")));
|
||||
membership.leave();
|
||||
expect(membership.state$.value).toEqual({ kind: "left" });
|
||||
});
|
||||
});
|
||||
|
||||
function setup(preparedTransportWithErrors$?: Observable<PreparedTransport>): {
|
||||
membership: LocalMembership;
|
||||
homeserver: FakeHomeserverConnected;
|
||||
prepared$: Subject<PreparedTransport>;
|
||||
delayId$: BehaviorSubject<string | null>;
|
||||
membershipManagerError$: Subject<unknown>;
|
||||
joinMatrixRTC: ReturnType<typeof vi.fn>;
|
||||
delegate: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const prepared$ = new Subject<PreparedTransport>();
|
||||
const homeserver = fakeHomeserverConnected();
|
||||
const delayId$ = new BehaviorSubject<string | null>(null);
|
||||
const membershipManagerError$ = new Subject<unknown>();
|
||||
const joinMatrixRTC = vi.fn();
|
||||
const delegate = vi.fn(async () => Promise.resolve());
|
||||
const membership = createLocalMembership$({
|
||||
scope: testScope(),
|
||||
delegateDelayedLeave: delegate,
|
||||
preparedTransport$: preparedTransportWithErrors$ ?? prepared$,
|
||||
homeserverConnected: homeserver,
|
||||
joinMatrixRTC,
|
||||
membershipManagerError$,
|
||||
matrixRTCSession: {
|
||||
leave: vi.fn(async () => Promise.resolve(true)),
|
||||
},
|
||||
delayId$,
|
||||
timings: defaultSessionTimings,
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
membership,
|
||||
homeserver,
|
||||
prepared$,
|
||||
delayId$,
|
||||
membershipManagerError$,
|
||||
joinMatrixRTC,
|
||||
delegate,
|
||||
};
|
||||
}
|
||||
|
||||
type FakeHomeserverConnected = {
|
||||
disconnectReason$: BehaviorSubject<
|
||||
HomeserverConnected["disconnectReason$"]["value"]
|
||||
>;
|
||||
};
|
||||
|
||||
function fakeHomeserverConnected(): FakeHomeserverConnected {
|
||||
return {
|
||||
disconnectReason$: new BehaviorSubject<
|
||||
HomeserverConnected["disconnectReason$"]["value"]
|
||||
>(null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
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 {
|
||||
type MatrixRTCSession as JsSdkRTCSession,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
NEVER,
|
||||
type Observable,
|
||||
scan,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../reactive/Behavior";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { type DelayedLeaveTimings, type SessionTimings } from "../config";
|
||||
import { type MatrixDisconnectReason } from "../api";
|
||||
import {
|
||||
type MatrixRTCError,
|
||||
MembershipManagerError,
|
||||
toMatrixRTCError,
|
||||
} from "../errors";
|
||||
import {
|
||||
type MediaBackend,
|
||||
type TransportCapabilities,
|
||||
} from "../media-backend/api";
|
||||
import { type HomeserverConnected } from "./HomeserverConnected";
|
||||
|
||||
export type MembershipState =
|
||||
/** Asking the homeserver for a transport, or preparing it. */
|
||||
| { kind: "waitingForTransport" }
|
||||
/** Transport known; sending the membership for the first time. */
|
||||
| { kind: "joining" }
|
||||
/** Syncing, membership confirmed by the server, delayed leave still being refreshed. */
|
||||
| { kind: "joined" }
|
||||
/** Was joined and one homeserver link is down; the membership manager is retrying. */
|
||||
| { kind: "reconnecting"; reason: MatrixDisconnectReason }
|
||||
/** A transport, join or membership manager error ended the membership. Terminal unless `left`. */
|
||||
| { kind: "failed"; error: MatrixRTCError }
|
||||
/** `leave()` was called. Terminal. */
|
||||
| { kind: "left" };
|
||||
|
||||
/** The local transport, found and prepared, with what the join needs to know about it. */
|
||||
export interface PreparedTransport extends TransportCapabilities {
|
||||
transport: Transport;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
delegateDelayedLeave: MediaBackend["delegateDelayedLeave"];
|
||||
preparedTransport$: Observable<PreparedTransport>;
|
||||
homeserverConnected: HomeserverConnected;
|
||||
joinMatrixRTC: (
|
||||
transport: Transport,
|
||||
delayedLeave: DelayedLeaveTimings,
|
||||
) => void;
|
||||
/** The membership manager giving up on keeping the membership alive. */
|
||||
membershipManagerError$: Observable<unknown>;
|
||||
matrixRTCSession: Pick<JsSdkRTCSession, "leave">;
|
||||
/** The id of the delayed leave event, for the backend to take over. */
|
||||
delayId$: Behavior<string | null>;
|
||||
timings: SessionTimings;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface LocalMembership {
|
||||
/** Sends the leave. Final: there is no joining again. */
|
||||
leave: () => void;
|
||||
state$: Behavior<MembershipState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Matrix side of the local member: waits for the transport, enters the
|
||||
* MatrixRTC session, keeps the delayed leave delegated, and leaves on
|
||||
* `leave()`. Knows nothing about the media.
|
||||
*/
|
||||
export function createLocalMembership$({
|
||||
scope,
|
||||
delegateDelayedLeave,
|
||||
preparedTransport$: preparedTransportWithErrors$,
|
||||
homeserverConnected,
|
||||
joinMatrixRTC,
|
||||
membershipManagerError$,
|
||||
matrixRTCSession,
|
||||
delayId$,
|
||||
timings,
|
||||
logger: parentLogger,
|
||||
}: Props): LocalMembership {
|
||||
const logger = parentLogger.getChild("[LocalMembership]");
|
||||
|
||||
const transportError$ = new BehaviorSubject<MatrixRTCError | null>(null);
|
||||
const transport$ = scope.behavior<PreparedTransport | null>(
|
||||
preparedTransportWithErrors$.pipe(
|
||||
catchError((e: unknown) => {
|
||||
transportError$.next(toMatrixRTCError(e));
|
||||
return NEVER;
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
const joined$ = new BehaviorSubject(true);
|
||||
const matrixError$ = new BehaviorSubject<MatrixRTCError | null>(null);
|
||||
|
||||
scope.reconcile(
|
||||
scope.behavior(combineLatest([transport$, joined$])),
|
||||
async ([prepared, joined]) => {
|
||||
if (prepared === null || !joined) return;
|
||||
try {
|
||||
joinMatrixRTC(
|
||||
prepared.transport,
|
||||
prepared.canDelegateDelayedLeave
|
||||
? timings.delegatedDelayedLeave
|
||||
: timings.delayedLeave,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error("Failed to enter the session", e);
|
||||
if (matrixError$.value === null) matrixError$.next(toMatrixRTCError(e));
|
||||
}
|
||||
return Promise.resolve(async (): Promise<void> => {
|
||||
try {
|
||||
await matrixRTCSession.leave(1000);
|
||||
} catch (e) {
|
||||
logger.error("Failed to leave the session", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
scope.reconcile(
|
||||
scope.behavior(combineLatest([transport$, delayId$])),
|
||||
async ([prepared, delayId]) => {
|
||||
if (!prepared?.canDelegateDelayedLeave || delayId === null) return;
|
||||
try {
|
||||
await delegateDelayedLeave(delayId);
|
||||
} catch (e) {
|
||||
logger.error("Failed to delegate the leave", e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
membershipManagerError$.pipe(scope.bind()).subscribe((e) => {
|
||||
logger.error("The membership manager stopped", e);
|
||||
if (matrixError$.value === null)
|
||||
matrixError$.next(
|
||||
new MembershipManagerError(
|
||||
e instanceof Error ? e : new Error(String(e)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const state$ = scope.behavior<MembershipState>(
|
||||
combineLatest([
|
||||
joined$,
|
||||
transportError$,
|
||||
matrixError$,
|
||||
transport$,
|
||||
homeserverConnected.disconnectReason$,
|
||||
]).pipe(
|
||||
scan(
|
||||
(previous, [joined, transportError, matrixError, transport, reason]) =>
|
||||
membershipState(previous, {
|
||||
joined,
|
||||
error: transportError ?? matrixError,
|
||||
hasTransport: transport !== null,
|
||||
reason,
|
||||
}),
|
||||
{ kind: "waitingForTransport" } as MembershipState,
|
||||
),
|
||||
distinctUntilChanged(deepCompare),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
leave: () => joined$.next(false),
|
||||
state$,
|
||||
};
|
||||
}
|
||||
|
||||
interface MembershipInputs {
|
||||
joined: boolean;
|
||||
error: MatrixRTCError | null;
|
||||
hasTransport: boolean;
|
||||
reason: MatrixDisconnectReason | null;
|
||||
}
|
||||
|
||||
/** The next state, in priority order; `left` and `failed` are absorbing. */
|
||||
function membershipState(
|
||||
previous: MembershipState,
|
||||
{ joined, error, hasTransport, reason }: MembershipInputs,
|
||||
): MembershipState {
|
||||
if (!joined) return { kind: "left" };
|
||||
if (previous.kind === "failed") return previous;
|
||||
if (error !== null) return { kind: "failed", error };
|
||||
if (!hasTransport) return { kind: "waitingForTransport" };
|
||||
if (reason === null) return { kind: "joined" };
|
||||
const wasJoined =
|
||||
previous.kind === "joined" || previous.kind === "reconnecting";
|
||||
return wasJoined ? { kind: "reconnecting", reason } : { kind: "joining" };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
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 { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { MatrixRTCTransportMissingError } from "../errors";
|
||||
import { discoverLocalTransport } from "./LocalTransport";
|
||||
|
||||
const homeserverTransport: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://homeserver.example.org",
|
||||
};
|
||||
const hostTransport: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://host.example.org",
|
||||
};
|
||||
|
||||
function client(
|
||||
advertised: Transport[],
|
||||
): Parameters<typeof discoverLocalTransport>[0] {
|
||||
return {
|
||||
getDomain: () => "example.org",
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue(advertised),
|
||||
};
|
||||
}
|
||||
|
||||
describe("discoverLocalTransport", () => {
|
||||
it("takes the transport the host names without asking the homeserver", async () => {
|
||||
const c = client([homeserverTransport]);
|
||||
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
c,
|
||||
"livekit",
|
||||
{ transport: hostTransport },
|
||||
logger,
|
||||
),
|
||||
).resolves.toBe(hostTransport);
|
||||
expect(c._unstable_getRTCTransports).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers the homeserver's transport over the host's fallback", async () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([homeserverTransport]),
|
||||
"livekit",
|
||||
{ fallbackTransport: hostTransport },
|
||||
logger,
|
||||
),
|
||||
).resolves.toEqual(homeserverTransport);
|
||||
});
|
||||
|
||||
it("falls back to the host's transport where the homeserver advertises none of the type", async () => {
|
||||
await expect(
|
||||
discoverLocalTransport(
|
||||
client([{ type: "not_livekit" }]),
|
||||
"livekit",
|
||||
{ fallbackTransport: hostTransport },
|
||||
logger,
|
||||
),
|
||||
).resolves.toEqual(hostTransport);
|
||||
});
|
||||
|
||||
it("fails naming the homeserver where there is no transport at all", async () => {
|
||||
const promise = discoverLocalTransport(client([]), "livekit", {}, logger);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(
|
||||
MatrixRTCTransportMissingError,
|
||||
);
|
||||
await expect(promise).rejects.toHaveProperty("domain", "example.org");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { type RTCParticipationOptions } from "../api";
|
||||
import { MatrixRTCTransportMissingError } from "../errors";
|
||||
import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
|
||||
|
||||
/**
|
||||
* The transport the local member advertises: the one the host names, else the
|
||||
* homeserver's first of the type the backend serves, else the host's fallback.
|
||||
*/
|
||||
export async function discoverLocalTransport(
|
||||
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
|
||||
transportType: string,
|
||||
{
|
||||
transport: override,
|
||||
fallbackTransport,
|
||||
}: Pick<RTCParticipationOptions, "transport" | "fallbackTransport">,
|
||||
logger: Logger,
|
||||
): Promise<Transport> {
|
||||
const transport =
|
||||
override ??
|
||||
(await new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType,
|
||||
fallbackTransport,
|
||||
logger,
|
||||
}).discoverPreferredTransport());
|
||||
if (transport === null)
|
||||
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
||||
return transport;
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
/*
|
||||
Copyright 2025 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, vi } from "vitest";
|
||||
import {
|
||||
type MatrixEvent,
|
||||
type RoomMember,
|
||||
type RoomState,
|
||||
RoomStateEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import EventEmitter from "events";
|
||||
import { it } from "vitest";
|
||||
|
||||
import { ObservableScope } from "../reactive/ObservableScope";
|
||||
import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room";
|
||||
import {
|
||||
mockRtcMembership,
|
||||
mockMatrixRoomMember,
|
||||
withTestScheduler,
|
||||
} from "../utils/test";
|
||||
import {
|
||||
createMatrixMemberMetadata$,
|
||||
createRoomMembers$,
|
||||
} from "./MatrixMemberMetadata.ts";
|
||||
let testScope: ObservableScope;
|
||||
let mockMatrixRoom: MatrixRoom;
|
||||
|
||||
describe("MatrixMemberMetadata", () => {
|
||||
/*
|
||||
* To be populated in the test setup.
|
||||
* Maps userId to a partial/mock RoomMember object.
|
||||
*/
|
||||
let fakeMembersMap: Map<string, Partial<RoomMember>>;
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
fakeMembersMap = new Map<string, Partial<RoomMember>>();
|
||||
|
||||
const roomEmitter = new EventEmitter();
|
||||
mockMatrixRoom = {
|
||||
on: roomEmitter.on.bind(roomEmitter),
|
||||
off: roomEmitter.off.bind(roomEmitter),
|
||||
emit: roomEmitter.emit.bind(roomEmitter),
|
||||
// addListener: roomEmitter.addListener.bind(roomEmitter),
|
||||
// removeListener: roomEmitter.removeListener.bind(roomEmitter),
|
||||
getMember: vi.fn().mockImplementation((userId: string) => {
|
||||
const member = fakeMembersMap.get(userId);
|
||||
if (member) {
|
||||
return member as RoomMember;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
getMembers: vi.fn().mockImplementation(() => {
|
||||
const members = Array.from(fakeMembersMap.values());
|
||||
return members;
|
||||
}),
|
||||
getMembersWithMembership: vi.fn().mockImplementation(() => {
|
||||
const members = Array.from(fakeMembersMap.values());
|
||||
return members;
|
||||
}),
|
||||
} as unknown as MatrixRoom;
|
||||
});
|
||||
|
||||
function fakeMemberWith(data: Partial<RoomMember>): void {
|
||||
const userId = data.userId || "@alice:example.com";
|
||||
const member: Partial<RoomMember> = {
|
||||
userId: userId,
|
||||
rawDisplayName: data.rawDisplayName ?? userId,
|
||||
getMxcAvatarUrl:
|
||||
data.getMxcAvatarUrl ||
|
||||
vi.fn().mockImplementation(() => {
|
||||
return `mxc://example.com/${userId}`;
|
||||
}),
|
||||
...data,
|
||||
} as unknown as RoomMember;
|
||||
fakeMembersMap.set(userId, member);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
fakeMembersMap.clear();
|
||||
});
|
||||
|
||||
describe("displayname", () => {
|
||||
function updateDisplayName(
|
||||
userId: `@${string}:${string}`,
|
||||
newDisplayName: string,
|
||||
): void {
|
||||
const member = fakeMembersMap.get(userId);
|
||||
if (member) {
|
||||
member.rawDisplayName = newDisplayName;
|
||||
// Emit the event to notify listeners
|
||||
mockMatrixRoom.emit(
|
||||
RoomStateEvent.Members,
|
||||
{} as unknown as MatrixEvent,
|
||||
{} as unknown as RoomState,
|
||||
member as RoomMember,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`No member found with userId: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
it("should show our own user if present in rtc session and room", () => {
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
fakeMemberWith({
|
||||
userId: "@local:example.com",
|
||||
rawDisplayName: "it's a me",
|
||||
});
|
||||
const memberships$ = behavior("a", {
|
||||
a: [mockRtcMembership("@local:example.com", "DEVICE1")],
|
||||
});
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
const dn$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@local:example.com",
|
||||
);
|
||||
|
||||
expectObservable(dn$).toBe("a", {
|
||||
a: "it's a me",
|
||||
});
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||
a: new Map<string, string>([["@local:example.com", "it's a me"]]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setUpBasicRoom(): void {
|
||||
fakeMemberWith({
|
||||
userId: "@local:example.com",
|
||||
rawDisplayName: "it's a me",
|
||||
});
|
||||
fakeMemberWith({ userId: "@alice:example.com", rawDisplayName: "Alice" });
|
||||
fakeMemberWith({ userId: "@bob:example.com", rawDisplayName: "Bob" });
|
||||
fakeMemberWith({ userId: "@carl:example.com", rawDisplayName: "Carl" });
|
||||
fakeMemberWith({ userId: "@evil:example.com", rawDisplayName: "Carl" });
|
||||
fakeMemberWith({ userId: "@bob:foo.bar", rawDisplayName: "Bob" });
|
||||
fakeMemberWith({ userId: "@no-name:foo.bar" });
|
||||
}
|
||||
|
||||
it("should get displayName for users", () => {
|
||||
setUpBasicRoom();
|
||||
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
const memberships$ = behavior("a", {
|
||||
a: [
|
||||
mockRtcMembership("@alice:example.com", "DEVICE1"),
|
||||
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||
],
|
||||
});
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
const aliceDispName$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@alice:example.com",
|
||||
);
|
||||
|
||||
expectObservable(aliceDispName$).toBe("a", {
|
||||
a: "Alice",
|
||||
});
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||
a: new Map<string, string>([
|
||||
["@alice:example.com", "Alice"],
|
||||
["@bob:example.com", "Bob"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should use userId if no display name", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
|
||||
const memberships$ = behavior("a", {
|
||||
a: [mockRtcMembership("@no-name:foo.bar", "D000")],
|
||||
});
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||
a: new Map<string, string>([
|
||||
["@no-name:foo.bar", "@no-name:foo.bar"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should disambiguate users with same display name", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
|
||||
const memberships$ = behavior("a", {
|
||||
a: [
|
||||
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||
mockRtcMembership("@bob:example.com", "DEVICE2"),
|
||||
mockRtcMembership("@bob:foo.bar", "BOB000"),
|
||||
mockRtcMembership("@carl:example.com", "C000"),
|
||||
mockRtcMembership("@evil:example.com", "E000"),
|
||||
],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("a", {
|
||||
a: new Map<string, string>([
|
||||
// ["@local:example.com", "it's a me"],
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||
["@carl:example.com", "Carl (@carl:example.com)"],
|
||||
["@evil:example.com", "Carl (@evil:example.com)"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should start to disambiguate reactivly when needed", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
|
||||
const memberships$ = behavior("ab", {
|
||||
a: [mockRtcMembership("@bob:example.com", "DEVICE1")],
|
||||
b: [
|
||||
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||
mockRtcMembership("@bob:foo.bar", "BOB000"),
|
||||
],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||
a: new Map<string, string>([["@bob:example.com", "Bob"]]),
|
||||
b: new Map<string, string>([
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep disambiguated name when other leave", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
|
||||
const memberships$ = behavior("ab", {
|
||||
a: [
|
||||
mockRtcMembership("@bob:example.com", "DEVICE1"),
|
||||
mockRtcMembership("@bob:foo.bar", "BOB000"),
|
||||
],
|
||||
b: [mockRtcMembership("@bob:example.com", "DEVICE1")],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||
a: new Map<string, string>([
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||
]),
|
||||
b: new Map<string, string>([
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should disambiguate on name change", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
|
||||
const memberships$ = behavior("a", {
|
||||
a: [
|
||||
mockRtcMembership("@bob:example.com", "B000"),
|
||||
mockRtcMembership("@carl:example.com", "C000"),
|
||||
],
|
||||
});
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
schedule("-a", {
|
||||
a: () => {
|
||||
updateDisplayName("@carl:example.com", "Bob");
|
||||
},
|
||||
});
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||
a: new Map<string, string>([
|
||||
["@bob:example.com", "Bob"],
|
||||
["@carl:example.com", "Carl"],
|
||||
]),
|
||||
b: new Map<string, string>([
|
||||
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||
["@carl:example.com", "Bob (@carl:example.com)"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should track individual member id with createDisplayNameBehavior", () => {
|
||||
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
|
||||
setUpBasicRoom();
|
||||
const BOB = "@bob:example.com";
|
||||
const CARL = "@carl:example.com";
|
||||
// for this test we build a mock environment that does all possible changes:
|
||||
// - memberships join/leave
|
||||
// - room join/leave
|
||||
// - disambiguate
|
||||
const memberships$ = behavior("ab-d", {
|
||||
a: [mockRtcMembership(CARL, "C000")],
|
||||
b: [
|
||||
mockRtcMembership(CARL, "C000"),
|
||||
// bob joins
|
||||
mockRtcMembership(BOB, "B000"),
|
||||
],
|
||||
// c carl gets renamed to BOB
|
||||
d: [
|
||||
// carl leaves
|
||||
mockRtcMembership(BOB, "B000"),
|
||||
],
|
||||
});
|
||||
schedule("--a-", {
|
||||
a: () => {
|
||||
// carl renames
|
||||
updateDisplayName(CARL, "Bob");
|
||||
},
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
const bob$ = metadataStore.createDisplayNameBehavior$(scope, BOB);
|
||||
const carl$ = metadataStore.createDisplayNameBehavior$(scope, CARL);
|
||||
|
||||
expectObservable(bob$).toBe("abc-", {
|
||||
a: undefined,
|
||||
b: "Bob",
|
||||
c: "Bob (@bob:example.com)",
|
||||
// bob stays disambiguate even though carl left
|
||||
// d: "Bob (@bob:example.com)",
|
||||
});
|
||||
|
||||
expectObservable(carl$).toBe("a-cd", {
|
||||
a: "Carl",
|
||||
// b: "Carl",
|
||||
// carl gets renamed and disambiguate
|
||||
c: "Bob (@carl:example.com)",
|
||||
d: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should disambiguate users with invisible characters", () => {
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
|
||||
const bobZeroWidthSpaceRtcMember = mockRtcMembership(
|
||||
"@bob2:example.org",
|
||||
"BBBB",
|
||||
);
|
||||
const bob = mockMatrixRoomMember(bobRtcMember, {
|
||||
rawDisplayName: "Bob",
|
||||
});
|
||||
const bobZeroWidthSpace = mockMatrixRoomMember(
|
||||
bobZeroWidthSpaceRtcMember,
|
||||
{
|
||||
rawDisplayName: "Bo\u200bb",
|
||||
},
|
||||
);
|
||||
fakeMemberWith(bob);
|
||||
fakeMemberWith(bobZeroWidthSpace);
|
||||
fakeMemberWith({ userId: "@carol:example.org" });
|
||||
const memberships$ = behavior("ab", {
|
||||
a: [mockRtcMembership("@carol:example.org", "1111"), bobRtcMember],
|
||||
b: [
|
||||
mockRtcMembership("@carol:example.org", "1111"),
|
||||
bobRtcMember,
|
||||
bobZeroWidthSpaceRtcMember,
|
||||
],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
const bob$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@bob:example.org",
|
||||
);
|
||||
const bob2$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@bob2:example.org",
|
||||
);
|
||||
const carol$ = metadataStore.createDisplayNameBehavior$(
|
||||
scope,
|
||||
"@carol:example.org",
|
||||
);
|
||||
expectObservable(bob$).toBe("ab", {
|
||||
a: "Bob",
|
||||
b: "Bob (@bob:example.org)",
|
||||
});
|
||||
expectObservable(bob2$).toBe("ab", {
|
||||
a: undefined,
|
||||
b: "Bo\u200bb (@bob2:example.org)",
|
||||
});
|
||||
expectObservable(carol$).toBe("a-", {
|
||||
a: "@carol:example.org",
|
||||
});
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([
|
||||
["@carol:example.org", "@carol:example.org"],
|
||||
["@bob:example.org", "Bob"],
|
||||
]),
|
||||
// Other Bob joins, and should handle zero width hacks.
|
||||
b: new Map([
|
||||
["@carol:example.org", "@carol:example.org"],
|
||||
[bobRtcMember.userId, `Bob (@bob:example.org)`],
|
||||
[
|
||||
bobZeroWidthSpace.userId,
|
||||
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should strip RTL characters from displayname", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
||||
const daveRTLRtcMember = mockRtcMembership(
|
||||
"@dave2:example.org",
|
||||
"DDDD",
|
||||
);
|
||||
const dave = mockMatrixRoomMember(daveRtcMember, {
|
||||
rawDisplayName: "Dave",
|
||||
});
|
||||
const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
|
||||
rawDisplayName: "\u202eevaD",
|
||||
});
|
||||
|
||||
fakeMemberWith({ userId: "@carol:example.org" });
|
||||
fakeMemberWith(daveRTL);
|
||||
fakeMemberWith(dave);
|
||||
const memberships$ = behavior("ab", {
|
||||
a: [mockRtcMembership("@carol:example.org", "DDDD")],
|
||||
b: [
|
||||
mockRtcMembership("@carol:example.org", "DDDD"),
|
||||
daveRtcMember,
|
||||
daveRTLRtcMember,
|
||||
],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([["@carol:example.org", "@carol:example.org"]]),
|
||||
// Both Dave's join. Since after stripping
|
||||
b: new Map([
|
||||
["@carol:example.org", "@carol:example.org"],
|
||||
// Not disambiguated
|
||||
["@dave:example.org", "Dave"],
|
||||
// This one is, since it's using RTL.
|
||||
["@dave2:example.org", "evaD (@dave2:example.org)"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatarUrl", () => {
|
||||
function updateAvatarUrl(
|
||||
userId: `@${string}:${string}`,
|
||||
avatarUrl: string,
|
||||
): void {
|
||||
const member = fakeMembersMap.get(userId);
|
||||
if (member) {
|
||||
member.getMxcAvatarUrl = vi.fn().mockReturnValue(avatarUrl);
|
||||
// Emit the event to notify listeners
|
||||
mockMatrixRoom.emit(
|
||||
RoomStateEvent.Members,
|
||||
{} as unknown as MatrixEvent,
|
||||
{} as unknown as RoomState,
|
||||
member as RoomMember,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`No member found with userId: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
it("should use avatar url from room members", () => {
|
||||
withTestScheduler(({ scope, behavior, expectObservable }) => {
|
||||
fakeMemberWith({
|
||||
userId: "@local:example.com",
|
||||
});
|
||||
fakeMemberWith({
|
||||
userId: "@alice:example.com",
|
||||
getMxcAvatarUrl: vi.fn().mockReturnValue("mxc://custom.url/avatar"),
|
||||
});
|
||||
const memberships$ = behavior("a", {
|
||||
a: [
|
||||
mockRtcMembership("@local:example.com", "DEVICE1"),
|
||||
mockRtcMembership("@alice:example.com", "DEVICE1"),
|
||||
],
|
||||
});
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
const local$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@local:example.com",
|
||||
);
|
||||
|
||||
const alice$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@alice:example.com",
|
||||
);
|
||||
|
||||
expectObservable(local$).toBe("a", {
|
||||
a: "mxc://example.com/@local:example.com",
|
||||
});
|
||||
expectObservable(alice$).toBe("a", {
|
||||
a: "mxc://custom.url/avatar",
|
||||
});
|
||||
expectObservable(metadataStore.avatarMap$).toBe("a", {
|
||||
a: new Map<string, string>([
|
||||
["@local:example.com", "mxc://example.com/@local:example.com"],
|
||||
["@alice:example.com", "mxc://custom.url/avatar"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should update on avatar change and user join/leave", () => {
|
||||
withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
|
||||
fakeMemberWith({ userId: "@carl:example.com" });
|
||||
fakeMemberWith({ userId: "@bob:example.com" });
|
||||
const memberships$ = behavior("ab-d", {
|
||||
a: [mockRtcMembership("@bob:example.com", "B000")],
|
||||
b: [
|
||||
mockRtcMembership("@bob:example.com", "B000"),
|
||||
mockRtcMembership("@carl:example.com", "C000"),
|
||||
],
|
||||
d: [mockRtcMembership("@carl:example.com", "C000")],
|
||||
});
|
||||
|
||||
const metadataStore = createMatrixMemberMetadata$(
|
||||
testScope,
|
||||
memberships$,
|
||||
createRoomMembers$(testScope, mockMatrixRoom),
|
||||
);
|
||||
|
||||
schedule("--c-", {
|
||||
c: () => {
|
||||
updateAvatarUrl(
|
||||
"@carl:example.com",
|
||||
"mxc://updated.me/updatedAvatar",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const bob$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@bob:example.com",
|
||||
);
|
||||
const carl$ = metadataStore.createAvatarUrlBehavior$(
|
||||
scope,
|
||||
"@carl:example.com",
|
||||
);
|
||||
expectObservable(bob$).toBe("a---", {
|
||||
a: "mxc://example.com/@bob:example.com",
|
||||
});
|
||||
expectObservable(carl$).toBe("a-c-", {
|
||||
a: "mxc://example.com/@carl:example.com",
|
||||
|
||||
c: "mxc://updated.me/updatedAvatar",
|
||||
});
|
||||
expectObservable(metadataStore.avatarMap$).toBe("a-c-", {
|
||||
a: new Map<string, string>([
|
||||
["@bob:example.com", "mxc://example.com/@bob:example.com"],
|
||||
["@carl:example.com", "mxc://example.com/@carl:example.com"],
|
||||
]),
|
||||
// expect an update once we update the avatar URL
|
||||
c: new Map<string, string>([
|
||||
["@bob:example.com", "mxc://example.com/@bob:example.com"],
|
||||
["@carl:example.com", "mxc://updated.me/updatedAvatar"],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
Copyright 2025 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 RoomMember, RoomStateEvent } from "matrix-js-sdk";
|
||||
import { combineLatest, fromEvent, map } from "rxjs";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
KnownMembership,
|
||||
type Room as MatrixRoom,
|
||||
} from "matrix-js-sdk/lib/matrix";
|
||||
// eslint-disable-next-line rxjs/no-internal
|
||||
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
|
||||
import { type Behavior } from "../reactive/Behavior";
|
||||
|
||||
export type RoomMemberMap = Map<
|
||||
string,
|
||||
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
|
||||
>;
|
||||
export function roomToMembersMap(matrixRoom: MatrixRoom): RoomMemberMap {
|
||||
const members = matrixRoom
|
||||
.getMembersWithMembership(KnownMembership.Join)
|
||||
.concat(matrixRoom.getMembersWithMembership(KnownMembership.Invite));
|
||||
return members.reduce((acc, member) => {
|
||||
acc.set(member.userId, {
|
||||
userId: member.userId,
|
||||
getMxcAvatarUrl: member.getMxcAvatarUrl.bind(member),
|
||||
rawDisplayName: member.rawDisplayName,
|
||||
});
|
||||
return acc;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
export function createRoomMembers$(
|
||||
scope: ObservableScope,
|
||||
matrixRoom: MatrixRoom,
|
||||
): Behavior<RoomMemberMap> {
|
||||
return scope.behavior(
|
||||
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(
|
||||
map(() => roomToMembersMap(matrixRoom)),
|
||||
),
|
||||
roomToMembersMap(matrixRoom),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displayname for each member of the call. This will disambiguate
|
||||
* any displayname that clashes with another member. Only members
|
||||
* joined to the call are considered here.
|
||||
*
|
||||
* @returns Map<userId, displayname> uses the Matrix user ID as the key.
|
||||
*/
|
||||
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
||||
export const memberDisplaynames$ = (
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||
roomMembers$: Behavior<RoomMemberMap>,
|
||||
): Behavior<Map<string, string>> => {
|
||||
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
|
||||
// This map tracks userIds that at some point needed disambiguation.
|
||||
// This is a memory leak bound to the number of participants.
|
||||
// A call application will always increase the memory if there have been more members in a call.
|
||||
// Its capped by room member participants.
|
||||
const shouldDisambiguateTrackerMap = new Set<string>();
|
||||
return scope.behavior(
|
||||
combineLatest([
|
||||
// Handle call membership changes
|
||||
memberships$,
|
||||
// Additionally handle display name changes (implicitly reacting to them)
|
||||
roomMembers$,
|
||||
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
|
||||
]).pipe(
|
||||
map(([memberships, roomMembers]) => {
|
||||
const displaynameMap = new Map<string, string>();
|
||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||
for (const rtcMember of memberships) {
|
||||
const member = roomMembers.get(rtcMember.userId);
|
||||
if (!member) {
|
||||
logger.error(`Could not find member for user ${rtcMember.userId}`);
|
||||
continue;
|
||||
}
|
||||
const disambiguateComputed = shouldDisambiguate(
|
||||
member,
|
||||
memberships,
|
||||
roomMembers,
|
||||
);
|
||||
|
||||
const disambiguate =
|
||||
shouldDisambiguateTrackerMap.has(rtcMember.userId) ||
|
||||
disambiguateComputed;
|
||||
if (disambiguate) shouldDisambiguateTrackerMap.add(rtcMember.userId);
|
||||
displaynameMap.set(
|
||||
rtcMember.userId,
|
||||
calculateDisplayName(member, disambiguate),
|
||||
);
|
||||
}
|
||||
return displaynameMap;
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const createMatrixMemberMetadata$ = (
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||
roomMembers$: Behavior<RoomMemberMap>,
|
||||
): {
|
||||
createDisplayNameBehavior$: (
|
||||
scope: ObservableScope,
|
||||
userId: string,
|
||||
) => Behavior<string | undefined>;
|
||||
createAvatarUrlBehavior$: (
|
||||
scope: ObservableScope,
|
||||
userId: string,
|
||||
) => Behavior<string | undefined>;
|
||||
displaynameMap$: Behavior<Map<string, string>>;
|
||||
avatarMap$: Behavior<Map<string, string | undefined>>;
|
||||
} => {
|
||||
const displaynameMap$ = memberDisplaynames$(
|
||||
scope,
|
||||
memberships$,
|
||||
roomMembers$,
|
||||
);
|
||||
const avatarMap$ = scope.behavior(
|
||||
roomMembers$.pipe(
|
||||
map((roomMembers) =>
|
||||
Array.from(roomMembers.keys()).reduce((acc, key) => {
|
||||
acc.set(key, roomMembers.get(key)?.getMxcAvatarUrl());
|
||||
return acc;
|
||||
}, new Map<string, string | undefined>()),
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
createDisplayNameBehavior$: (scope: ObservableScope, userId: string) =>
|
||||
scope.behavior(
|
||||
displaynameMap$.pipe(
|
||||
map((displaynameMap) => displaynameMap.get(userId)),
|
||||
),
|
||||
),
|
||||
createAvatarUrlBehavior$: (scope: ObservableScope, userId: string) =>
|
||||
scope.behavior(
|
||||
roomMembers$.pipe(
|
||||
map((roomMembers) => roomMembers.get(userId)?.getMxcAvatarUrl()),
|
||||
),
|
||||
),
|
||||
// mostly for testing purposes
|
||||
displaynameMap$,
|
||||
avatarMap$,
|
||||
};
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
import { distinctUntilChanged, map } from "rxjs";
|
||||
|
||||
import { type Behavior } from "../reactive/Behavior";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { type createMatrixMemberMetadata$ } from "./MatrixMemberMetadata";
|
||||
import {
|
||||
type LocalRTCMember,
|
||||
type RemoteRTCMember,
|
||||
type RTCMember,
|
||||
} from "../api";
|
||||
import { type LocalMemberMedia, type MemberMedia } from "../media-api";
|
||||
import { type TransportRegistry } from "./Transports";
|
||||
|
||||
/** What every member is built from, besides its own membership. */
|
||||
export interface MemberContext {
|
||||
metadata: ReturnType<typeof createMatrixMemberMetadata$>;
|
||||
transports: TransportRegistry;
|
||||
/** Whose memberships are `local`. */
|
||||
own: { userId: string; deviceId: string };
|
||||
}
|
||||
|
||||
/** Everything that tells one membership from another, for keying items. */
|
||||
export function membershipKeys(
|
||||
membership: CallMembership,
|
||||
): [string, string, string, string] {
|
||||
return [
|
||||
membership.userId,
|
||||
membership.deviceId,
|
||||
membership.memberId,
|
||||
membership.rtcBackendIdentity,
|
||||
];
|
||||
}
|
||||
|
||||
/** The slot's view of a membership: identity, room state and transport, no media. */
|
||||
export function createRTCMember(
|
||||
scope: ObservableScope,
|
||||
membership$: Behavior<CallMembership>,
|
||||
{ metadata, transports, own }: MemberContext,
|
||||
): RTCMember {
|
||||
const { userId, deviceId, memberId, rtcBackendIdentity, backendIdentities } =
|
||||
membership$.value;
|
||||
return {
|
||||
local: userId === own.userId && deviceId === own.deviceId,
|
||||
// Read off the membership for now; the backend will derive them once the
|
||||
// identity scheme is its own and not the js-sdk's
|
||||
rtcBackendIdentity,
|
||||
backendIdentities,
|
||||
userId,
|
||||
deviceId,
|
||||
memberId,
|
||||
displayName$: scope.behavior(
|
||||
metadata
|
||||
.createDisplayNameBehavior$(scope, userId)
|
||||
.pipe(map((name) => name ?? userId)),
|
||||
),
|
||||
avatarUrl$: metadata.createAvatarUrlBehavior$(scope, userId),
|
||||
transport$: scope.behavior(
|
||||
membership$.pipe(
|
||||
map((membership) => membership.getTransport()),
|
||||
distinctUntilChanged(),
|
||||
map((transport) => transport && transports.get(transport)),
|
||||
),
|
||||
),
|
||||
applicationData$: scope.behavior(
|
||||
membership$.pipe(
|
||||
map((membership) => membership.applicationData),
|
||||
distinctUntilChanged(deepCompare),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** The participation's view of a remote member: the slot's member plus its media. */
|
||||
export function createRemoteRTCMember(
|
||||
member: RTCMember,
|
||||
media: MemberMedia,
|
||||
): RemoteRTCMember {
|
||||
return { ...member, local: false, ...media };
|
||||
}
|
||||
|
||||
export function createLocalRTCMember(
|
||||
member: RTCMember,
|
||||
media: LocalMemberMedia,
|
||||
): LocalRTCMember {
|
||||
return { ...member, local: true, ...media };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
Copyright 2025 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 {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type MockedObject,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import {
|
||||
TransportAutoDiscovery,
|
||||
type TransportAutoDiscoveryProps,
|
||||
} from "./TransportAutoDiscovery.ts";
|
||||
|
||||
type DiscoveryClient = TransportAutoDiscoveryProps["client"];
|
||||
|
||||
const backendTransport: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://backend.example.org",
|
||||
};
|
||||
|
||||
const configTransport: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://config.example.org",
|
||||
};
|
||||
|
||||
function makeClient(): MockedObject<DiscoveryClient> {
|
||||
return {
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
baseUrl: "https://matrix.example.org",
|
||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
} as unknown as MockedObject<DiscoveryClient>;
|
||||
}
|
||||
|
||||
describe("TransportAutoDiscovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const VALID_TEST_CASES: Array<{ transports: Transport[] }> = [
|
||||
{ transports: [backendTransport] },
|
||||
// will pick the first livekit transport in the list, even if there are other non-livekit transports
|
||||
{ transports: [{ type: "not_livekit" }, backendTransport] },
|
||||
];
|
||||
it.each(VALID_TEST_CASES)(
|
||||
"prefers backend transport other app config $transports",
|
||||
async ({ transports }) => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
const discoveredTransport = await discovery.discoverPreferredTransport();
|
||||
|
||||
expect(discoveredTransport).toStrictEqual(backendTransport);
|
||||
expect(discoveredTransport).not.toStrictEqual(configTransport);
|
||||
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("Retries limit_exceeded backend transport", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports
|
||||
.mockRejectedValueOnce(
|
||||
new MatrixError(
|
||||
{
|
||||
errcode: "M_LIMIT_EXCEEDED",
|
||||
error: "Too many requests",
|
||||
retry_after_ms: 100,
|
||||
},
|
||||
429,
|
||||
),
|
||||
)
|
||||
.mockResolvedValue([backendTransport]);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
|
||||
backendTransport,
|
||||
);
|
||||
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
|
||||
{ transports: [] },
|
||||
{ transports: [{ type: "not_livekit" }] },
|
||||
];
|
||||
it.each(INVALID_TEST_CASES)(
|
||||
"falls back to config when backend has no (valid) livekit transports $transports",
|
||||
async ({ transports }) => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue(transports);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType: "livekit",
|
||||
fallbackTransport: configTransport,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
const discoveredTransport = await discovery.discoverPreferredTransport();
|
||||
expect(discoveredTransport).not.toStrictEqual(backendTransport);
|
||||
expect(discoveredTransport).toStrictEqual(configTransport);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns null when backend and config are all unavailable", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports.mockResolvedValue([]);
|
||||
|
||||
const discovery = new TransportAutoDiscovery({
|
||||
client,
|
||||
transportType: "livekit",
|
||||
fallbackTransport: undefined,
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
await expect(discovery.discoverPreferredTransport()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { doNetworkOperationWithRetry } from "../utils/network";
|
||||
|
||||
type TransportDiscoveryClient = Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "_unstable_getRTCTransports"
|
||||
>;
|
||||
|
||||
export interface TransportAutoDiscoveryProps {
|
||||
client: TransportDiscoveryClient;
|
||||
/** The transport type the media backend serves. */
|
||||
transportType: string;
|
||||
/** A transport to use when the homeserver advertises none of the type. */
|
||||
fallbackTransport?: Transport;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export class TransportAutoDiscovery {
|
||||
private readonly client: TransportDiscoveryClient;
|
||||
private readonly transportType: string;
|
||||
private readonly fallbackTransport: Transport | undefined;
|
||||
private readonly logger: Logger;
|
||||
|
||||
public constructor({
|
||||
client,
|
||||
transportType,
|
||||
fallbackTransport,
|
||||
logger,
|
||||
}: TransportAutoDiscoveryProps) {
|
||||
this.client = client;
|
||||
this.transportType = transportType;
|
||||
this.fallbackTransport = fallbackTransport;
|
||||
this.logger = logger.getChild("[TransportAutoDiscovery]");
|
||||
}
|
||||
|
||||
public async discoverPreferredTransport(): Promise<Transport | null> {
|
||||
// 1) backend transports
|
||||
const backendTransport = await this.tryBackendTransports();
|
||||
if (backendTransport) {
|
||||
this.logger.info(
|
||||
`Found backend transport: ${JSON.stringify(backendTransport)}`,
|
||||
);
|
||||
return backendTransport;
|
||||
}
|
||||
|
||||
// 2) the host's fallback
|
||||
if (this.fallbackTransport) {
|
||||
this.logger.info(
|
||||
`Found fallback transport: ${JSON.stringify(this.fallbackTransport)}`,
|
||||
);
|
||||
return this.fallbackTransport;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the first transport of the backend's type from the homeserver.
|
||||
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
|
||||
* @private
|
||||
*/
|
||||
private async tryBackendTransports(): Promise<Transport | null> {
|
||||
const client = this.client;
|
||||
// MSC4143: Attempt to fetch transports from backend.
|
||||
this.logger.info("First try to use getRTCTransports end point ...");
|
||||
try {
|
||||
const transportList = await doNetworkOperationWithRetry(async () =>
|
||||
client._unstable_getRTCTransports(),
|
||||
);
|
||||
const first = transportList.find(
|
||||
(transport) => transport.type === this.transportType,
|
||||
);
|
||||
if (first) {
|
||||
return first;
|
||||
} else {
|
||||
this.logger.info(
|
||||
`No ${this.transportType} transport found in getRTCTransports end point`,
|
||||
transportList,
|
||||
);
|
||||
}
|
||||
} catch (ex) {
|
||||
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
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 Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { type ResolvedTransport } from "../api";
|
||||
import {
|
||||
type BackendConnection,
|
||||
MediaConnectionState,
|
||||
} from "../media-backend/api";
|
||||
import { testScope } from "../utils/test";
|
||||
import { createTransportRegistry, transportId } from "./Transports";
|
||||
|
||||
const sfu: Transport = { type: "livekit", livekit_service_url: "https://sfu" };
|
||||
const resolved: ResolvedTransport = {
|
||||
type: "livekit",
|
||||
url: "wss://sfu",
|
||||
token: "jwt",
|
||||
roomAlias: "room",
|
||||
identity: "me",
|
||||
};
|
||||
|
||||
describe("transportId", () => {
|
||||
it("does not depend on the order a sender wrote the keys in", () => {
|
||||
expect(transportId({ type: "livekit", livekit_service_url: "u" })).toBe(
|
||||
transportId({ livekit_service_url: "u", type: "livekit" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("tells transports of the same type apart", () => {
|
||||
expect(transportId(sfu)).not.toBe(
|
||||
transportId({ ...sfu, livekit_service_url: "https://other" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTransportRegistry", () => {
|
||||
it("hands out one metadata object per transport", () => {
|
||||
const registry = createTransportRegistry(
|
||||
testScope(),
|
||||
new BehaviorSubject<BackendConnection[]>([]),
|
||||
);
|
||||
expect(registry.get(sfu)).toBe(registry.get({ ...sfu }));
|
||||
expect(registry.get(sfu).id).toBe(transportId(sfu));
|
||||
});
|
||||
|
||||
it("derives the connected transports and what was resolved from the backend's connections", () => {
|
||||
const connections$ = new BehaviorSubject<BackendConnection[]>([]);
|
||||
const registry = createTransportRegistry(testScope(), connections$);
|
||||
const metadata = registry.get(sfu);
|
||||
expect(registry.connected$.value).toEqual([]);
|
||||
expect(metadata.resolved$.value).toBeUndefined();
|
||||
|
||||
connections$.next([
|
||||
{ transport: sfu, state: MediaConnectionState.Connecting, resolved },
|
||||
]);
|
||||
expect(registry.connected$.value).toEqual([]);
|
||||
expect(metadata.resolved$.value).toBe(resolved);
|
||||
|
||||
connections$.next([
|
||||
{ transport: sfu, state: MediaConnectionState.Connected, resolved },
|
||||
]);
|
||||
expect(registry.connected$.value).toEqual([metadata]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { distinctUntilChanged, map } from "rxjs";
|
||||
|
||||
import { type Behavior } from "../reactive/Behavior";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import { type TransportMetadata } from "../api";
|
||||
import { type MediaBackend, MediaConnectionState } from "../media-backend/api";
|
||||
|
||||
export interface TransportRegistry {
|
||||
/** The one `TransportMetadata` for a transport, however many memberships name it. */
|
||||
get(raw: Transport): TransportMetadata;
|
||||
/** Transports with a live connection. */
|
||||
connected$: Behavior<TransportMetadata[]>;
|
||||
}
|
||||
|
||||
export function createTransportRegistry(
|
||||
scope: ObservableScope,
|
||||
connections$: MediaBackend["connections$"],
|
||||
): TransportRegistry {
|
||||
const transports = new Map<string, TransportMetadata>();
|
||||
|
||||
const get = (raw: Transport): TransportMetadata => {
|
||||
const id = transportId(raw);
|
||||
let transport = transports.get(id);
|
||||
if (transport === undefined) {
|
||||
transport = createTransportMetadata(scope, connections$, raw, id);
|
||||
transports.set(id, transport);
|
||||
}
|
||||
return transport;
|
||||
};
|
||||
|
||||
// Deduplicated: a backend may hold more than one connection to a transport,
|
||||
// one per homeserver it has to authenticate through
|
||||
const connected$ = scope.behavior(
|
||||
connections$.pipe(
|
||||
map((connections) => [
|
||||
...new Set(
|
||||
connections
|
||||
.filter(({ state }) => state === MediaConnectionState.Connected)
|
||||
.map(({ transport }) => get(transport)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
return { get, connected$ };
|
||||
}
|
||||
|
||||
function createTransportMetadata(
|
||||
scope: ObservableScope,
|
||||
connections$: MediaBackend["connections$"],
|
||||
raw: Transport,
|
||||
id: string,
|
||||
): TransportMetadata {
|
||||
return {
|
||||
type: raw.type,
|
||||
id,
|
||||
raw,
|
||||
resolved$: scope.behavior(
|
||||
connections$.pipe(
|
||||
map(
|
||||
(connections) =>
|
||||
connections.find((c) => transportId(c.transport) === id)?.resolved,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transports are plain JSON out of membership events, so the same transport
|
||||
* serialises the same way wherever it appears. Keys are sorted so that the
|
||||
* order a sender wrote them in does not matter.
|
||||
*/
|
||||
export function transportId(raw: Transport): string {
|
||||
return JSON.stringify(raw, Object.keys(raw).sort());
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
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 MatrixRTCSession,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { describe, expect, it, type Mock, vi } from "vitest";
|
||||
|
||||
import { defaultSessionTimings, MatrixRTCMode } from "../config";
|
||||
import { ownMemberMock } from "../utils/test";
|
||||
import { joinJsSdkSession } from "./joinJsSdkSession";
|
||||
|
||||
const transport: Transport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
};
|
||||
|
||||
/** A session that only records the join; nothing else of it is touched. */
|
||||
function session(): MatrixRTCSession & {
|
||||
join: Mock<MatrixRTCSession["join"]>;
|
||||
} {
|
||||
return { join: vi.fn() } as unknown as MatrixRTCSession & {
|
||||
join: Mock<MatrixRTCSession["join"]>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("joinJsSdkSession", () => {
|
||||
it("joins with the identity, the transport and the delayed leave timings it was given", () => {
|
||||
const s = session();
|
||||
const timings = {
|
||||
...defaultSessionTimings,
|
||||
waitForKeyRotationMs: 500,
|
||||
membershipEventExpiryMs: 60_000,
|
||||
keyRotationParticipantLimit: 20,
|
||||
};
|
||||
|
||||
joinJsSdkSession(s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
sendNotificationType: "ring",
|
||||
timings,
|
||||
delayedLeave: {
|
||||
delay_ms: 3_600_000,
|
||||
restart_ms: 300_000,
|
||||
restart_timeout_ms: 5_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transport], {
|
||||
notificationType: "ring",
|
||||
callIntent: undefined,
|
||||
applicationData: {},
|
||||
manageMediaKeys: true,
|
||||
delayedLeaveEventRestartMs: 300_000,
|
||||
delayedLeaveEventDelayMs: 3_600_000,
|
||||
delayedLeaveEventRestartLocalTimeoutMs: 5_000,
|
||||
networkErrorRetryMs: 1_000,
|
||||
makeKeyDelay: 500,
|
||||
membershipEventExpiryMs: 60_000,
|
||||
keyRotationParticipantLimit: 20,
|
||||
unstableSendStickyEvents: false,
|
||||
// The sync grace period is the shorter wait: 10 s at 1 s per retry, plus one
|
||||
maximumNetworkErrorRetryCount: 11,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives up on the network once the delayed leave has probably been sent", () => {
|
||||
const s = session();
|
||||
|
||||
joinJsSdkSession(s, ownMemberMock, transport, {
|
||||
encryptMedia: false,
|
||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||
timings: { ...defaultSessionTimings, networkErrorRetryMs: 4_000 },
|
||||
delayedLeave: { delay_ms: 18_000 },
|
||||
});
|
||||
|
||||
expect(s.join).toHaveBeenCalledWith(
|
||||
ownMemberMock,
|
||||
[transport],
|
||||
expect.objectContaining({
|
||||
manageMediaKeys: false,
|
||||
maximumNetworkErrorRetryCount: 4,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
|
||||
const s = session();
|
||||
|
||||
joinJsSdkSession(s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
|
||||
expect(s.join).toHaveBeenCalledWith(
|
||||
ownMemberMock,
|
||||
[transport],
|
||||
expect.objectContaining({
|
||||
callIntent: "audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type MatrixRTCSession as JsSdkRTCSession,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import {
|
||||
type DelayedLeaveTimings,
|
||||
MatrixRTCMode,
|
||||
type SessionTimings,
|
||||
} from "../config";
|
||||
|
||||
interface Options {
|
||||
encryptMedia: boolean;
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
applicationData?: Record<string, unknown>;
|
||||
timings: SessionTimings;
|
||||
/** The delayed leave timings to use: delegated to the SFU or not. */
|
||||
delayedLeave: DelayedLeaveTimings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the membership and starts the membership manager, which keeps it
|
||||
* alive and retries on failure.
|
||||
* @throws If the join could not be started.
|
||||
*/
|
||||
export function joinJsSdkSession(
|
||||
session: JsSdkRTCSession,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
transport: Transport,
|
||||
{
|
||||
encryptMedia,
|
||||
matrixRTCMode,
|
||||
sendNotificationType,
|
||||
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
|
||||
const maxWaitMs = Math.min(
|
||||
timings.syncDisconnectGracePeriodMs,
|
||||
delayedLeave.delay_ms,
|
||||
);
|
||||
session.join(ownMembershipIdentity, [transport], {
|
||||
notificationType: sendNotificationType,
|
||||
callIntent,
|
||||
applicationData: rest,
|
||||
manageMediaKeys: encryptMedia,
|
||||
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
|
||||
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
|
||||
delayedLeaveEventRestartLocalTimeoutMs: delayedLeave.restart_timeout_ms,
|
||||
networkErrorRetryMs: timings.networkErrorRetryMs,
|
||||
makeKeyDelay: timings.waitForKeyRotationMs,
|
||||
membershipEventExpiryMs: timings.membershipEventExpiryMs,
|
||||
keyRotationParticipantLimit: timings.keyRotationParticipantLimit,
|
||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||
maximumNetworkErrorRetryCount:
|
||||
Math.ceil(maxWaitMs / timings.networkErrorRetryMs) + 1,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
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 public media types of the SDK: what a member publishes, the tracks it
|
||||
* arrives as and how a host renders them. The client and member types are in
|
||||
* `api.ts`.
|
||||
*/
|
||||
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import { type Behavior } from "./reactive/Behavior";
|
||||
|
||||
export interface AudioCaptureSettings {
|
||||
echoCancellation?: boolean;
|
||||
noiseSuppression?: boolean;
|
||||
autoGainControl?: boolean;
|
||||
}
|
||||
|
||||
export type VideoCodec = "vp8" | "h264" | "vp9" | "av1" | "h265";
|
||||
|
||||
export interface VideoCaptureSettings {
|
||||
resolution?: { width: number; height: number; frameRate?: number };
|
||||
maxBitrate?: number;
|
||||
maxFramerate?: number;
|
||||
codec?: VideoCodec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a camera track before it is published: background blur and the
|
||||
* like. `init` resolves with the track to publish in place of the captured
|
||||
* one; `restart` does the same for a new capture, where the camera changes.
|
||||
*/
|
||||
export interface VideoProcessor {
|
||||
name: string;
|
||||
init(
|
||||
track: MediaStreamTrack,
|
||||
element?: HTMLMediaElement,
|
||||
): Promise<MediaStreamTrack>;
|
||||
restart(
|
||||
track: MediaStreamTrack,
|
||||
element?: HTMLMediaElement,
|
||||
): Promise<MediaStreamTrack>;
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One thing to publish: a source, where to capture it from and how to encode
|
||||
* it. Where the device and the settings are left out, the browser's and
|
||||
* LiveKit's defaults apply.
|
||||
*/
|
||||
export type PublishRequest =
|
||||
| { source: "microphone"; deviceId?: string; capture?: AudioCaptureSettings }
|
||||
| {
|
||||
source: "camera";
|
||||
deviceId?: string;
|
||||
/** Background blur and the like, applied before the first frame is published. */
|
||||
processor?: VideoProcessor;
|
||||
capture?: VideoCaptureSettings;
|
||||
}
|
||||
| {
|
||||
source: "screenShare";
|
||||
/** Whether to capture the screen's audio too. Default true. */
|
||||
audio?: boolean;
|
||||
capture?: VideoCaptureSettings;
|
||||
};
|
||||
|
||||
export type MediaSource =
|
||||
| "microphone"
|
||||
| "camera"
|
||||
| "screenShare"
|
||||
| "screenShareAudio"
|
||||
/** Published without a source; the application knows what it is. */
|
||||
| "unknown";
|
||||
|
||||
export type MediaStreamStats =
|
||||
| RTCInboundRtpStreamStats
|
||||
| RTCOutboundRtpStreamStats
|
||||
| undefined;
|
||||
|
||||
/** One published track of a member. */
|
||||
export interface MediaTrack {
|
||||
source: MediaSource;
|
||||
kind: "audio" | "video";
|
||||
/** Stable for the life of the track. */
|
||||
id: string;
|
||||
muted$: Behavior<boolean>;
|
||||
/**
|
||||
* False when the SFU reports the track as unencrypted. Fixed for the life
|
||||
* of the track: a publisher that changes its encryption publishes anew.
|
||||
*/
|
||||
encrypted: boolean;
|
||||
/**
|
||||
* Inbound statistics for a remote track, outbound for a local one, polled
|
||||
* every `intervalMs` while the observable is subscribed and not at all
|
||||
* otherwise. Each subscription runs its own poll, so a view shares one.
|
||||
*/
|
||||
stats$(intervalMs: number): Observable<MediaStreamStats>;
|
||||
/**
|
||||
* Rendering. The view hands its `<video>` or `<audio>` element over; the SDK
|
||||
* sets its stream and, for video, registers the size and on-screen observers
|
||||
* that pick a simulcast layer and pause the subscription while the element is
|
||||
* hidden. `attach` is idempotent per element; `detach` has to be called
|
||||
* before the element leaves the DOM so those observers are released.
|
||||
*/
|
||||
attach(element: HTMLMediaElement): void;
|
||||
detach(element: HTMLMediaElement): void;
|
||||
}
|
||||
|
||||
export interface AudioMediaTrack extends MediaTrack {
|
||||
kind: "audio";
|
||||
/**
|
||||
* Whether the track carries sound right now, as the backend measures it.
|
||||
* False while muted. A call reads this on the microphone track and calls it
|
||||
* "speaking".
|
||||
* This is different to audioLevel = 0. The backend might run some hysteresis on it.
|
||||
*/
|
||||
isActive$: Behavior<boolean>;
|
||||
/**
|
||||
* How loud the track is right now, 0 to 1, as the backend measures it. 0
|
||||
* while muted. Not what decides `isActive$`: the backend applies its own
|
||||
* threshold and hysteresis to that, so a level above 0 may not be active and
|
||||
* an active track may briefly sit at 0.
|
||||
*/
|
||||
audioLevel$: Behavior<number>;
|
||||
/** Route playback through Web Audio, for earpiece pan and gain. Undefined resets. */
|
||||
setAudioContext(ctx: AudioContext | undefined, plugins?: AudioNode[]): void;
|
||||
setVolume(volume: number): void;
|
||||
}
|
||||
|
||||
export interface VideoMediaTrack extends MediaTrack {
|
||||
kind: "video";
|
||||
}
|
||||
|
||||
/** The controls a member has over a track it publishes itself. */
|
||||
export interface LocalMediaTrack {
|
||||
/**
|
||||
* Mutes or unmutes. Resolves with the state that resulted, which differs
|
||||
* from the request where the device could not be used.
|
||||
*/
|
||||
setEnabled(enabled: boolean): Promise<boolean>;
|
||||
/** Captures from another device. Rejects for a screen share, which has none. */
|
||||
setDevice(deviceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalAudioMediaTrack
|
||||
extends AudioMediaTrack, LocalMediaTrack {}
|
||||
|
||||
export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
|
||||
/** For mirroring; undefined where the camera does not say which way it faces. */
|
||||
facingMode$: Behavior<"user" | "environment" | undefined>;
|
||||
/**
|
||||
* Restarts the camera facing the other way, on devices with a front and a
|
||||
* back camera, and resolves with the id of the device now in use. Does
|
||||
* nothing where the facing mode is unknown.
|
||||
*/
|
||||
switchFacingMode(): Promise<string | undefined>;
|
||||
/** Background blur and the like; undefined removes the processor. */
|
||||
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
export type EncryptionError = "MissingKey" | "InvalidKey";
|
||||
|
||||
/**
|
||||
* What a member sends, once it has arrived on its transport: its tracks, null
|
||||
* while nothing has arrived for it, and the key errors beside them. The media
|
||||
* backend supplies exactly these two fields per member.
|
||||
*/
|
||||
export interface MemberMedia {
|
||||
/**
|
||||
* The member's tracks, in publication order, once it has shown up on its
|
||||
* transport; null until then ("waiting for media"). An entry stays the same
|
||||
* object for as long as the same publication is behind it. Which track is
|
||||
* which is in its `source`; `trackBySource$` picks one out.
|
||||
*/
|
||||
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
|
||||
/** Emits when the SFU reports a key problem for this member. */
|
||||
encryptionError$: Observable<EncryptionError>;
|
||||
}
|
||||
|
||||
export interface LocalMemberMedia extends MemberMedia {
|
||||
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
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 seam between the session and whatever carries its media. The session
|
||||
* speaks MatrixRTC: memberships, transports as they appear in them, the
|
||||
* delayed leave. A backend speaks one media protocol and hands back the
|
||||
* media of each member. `media-backend/livekit/` is the one backend today.
|
||||
*/
|
||||
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import { type ResolvedTransport, type RTCMember } from "../api";
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalMemberMedia,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type MemberMedia,
|
||||
type PublishRequest,
|
||||
} from "../media-api";
|
||||
import { type SessionTimings } from "../config";
|
||||
import { type EncryptionSystem } from "../encryption";
|
||||
import { type Behavior } from "../reactive/Behavior";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
|
||||
export enum MediaConnectionState {
|
||||
/** No connection attempt yet, or no connection at all. */
|
||||
Initialized = "initialized",
|
||||
/** Fetching whatever the connection needs first; the SFU token for LiveKit. */
|
||||
Preparing = "preparing",
|
||||
Connecting = "connecting",
|
||||
Connected = "connected",
|
||||
Reconnecting = "reconnecting",
|
||||
Disconnected = "disconnected",
|
||||
Stopped = "stopped",
|
||||
}
|
||||
|
||||
/** One per-participant media key, as the session's key exchange delivers it. */
|
||||
export interface MediaKey {
|
||||
/** The `rtcBackendIdentity` of the member the key belongs to. */
|
||||
participantId: string;
|
||||
index: number;
|
||||
key: Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
|
||||
export interface MediaBackendContext {
|
||||
roomId: string;
|
||||
/** The slot the session is in, `application#id`; a token is issued for it. */
|
||||
slotId: string;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/** What the local member publishes at the join. */
|
||||
publish: PublishRequest[];
|
||||
encryptionSystem: EncryptionSystem;
|
||||
/**
|
||||
* Every media key the session knows: the current ones replayed on
|
||||
* subscribe, new ones as they arrive. Empty for a shared key, which is in
|
||||
* `encryptionSystem`.
|
||||
*/
|
||||
mediaKeys$: Observable<MediaKey>;
|
||||
timings: SessionTimings;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the `backend` option: the session calls it once with its scope
|
||||
* and the context. A backend's own factory keeps the shape of every other
|
||||
* factory here, `createLivekitBackend(scope, context, options)`, and a host
|
||||
* closes over its options. The Matrix client is not in the context: a backend
|
||||
* that needs it takes a `Pick` of exactly what it calls in its own options.
|
||||
*/
|
||||
export type MediaBackendFactory = (
|
||||
scope: ObservableScope,
|
||||
context: MediaBackendContext,
|
||||
) => MediaBackend;
|
||||
|
||||
/** What preparing the local transport established; what the MatrixRTC join needs from the media side. */
|
||||
export interface TransportCapabilities {
|
||||
/**
|
||||
* Whether the backend can restart the delayed leave on the client's behalf,
|
||||
* so that a client that vanishes is removed by the backend rather than by
|
||||
* the timeout. Decides which delayed leave timings the join uses, and
|
||||
* whether `delegateDelayedLeave` is ever called.
|
||||
*/
|
||||
canDelegateDelayedLeave: boolean;
|
||||
}
|
||||
|
||||
/** One connection a backend holds, for diagnostics. */
|
||||
export interface BackendConnection {
|
||||
/** The transport as it appears in memberships; what the registry keys on. */
|
||||
transport: Transport;
|
||||
state: MediaConnectionState | Error;
|
||||
/**
|
||||
* What the backend fetched to connect; undefined until it has, and again
|
||||
* after the connection stops. One object per fetch, so that a change of
|
||||
* state does not read as a change of this.
|
||||
*/
|
||||
resolved: ResolvedTransport | undefined;
|
||||
}
|
||||
|
||||
export interface DataPacket {
|
||||
/** The backend identity of the sender. */
|
||||
senderId: string;
|
||||
topic: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MediaBackend {
|
||||
/** The membership transport type this backend serves: "livekit". */
|
||||
readonly transportType: string;
|
||||
|
||||
/**
|
||||
* Makes `transport` usable as the one the local member publishes on. The
|
||||
* 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.
|
||||
*/
|
||||
prepareLocalTransport(transport: Transport): Promise<TransportCapabilities>;
|
||||
|
||||
/**
|
||||
* Hands the delayed leave event to the backend, which restarts it from
|
||||
* then on. Called each time the membership manager reports a new delay id,
|
||||
* only when `canDelegateDelayedLeave` is true. A rejection is logged by
|
||||
* the session, not fatal.
|
||||
*/
|
||||
delegateDelayedLeave(delayId: string): Promise<void>;
|
||||
|
||||
readonly local: LocalMediaBackend;
|
||||
|
||||
/**
|
||||
* The media of one remote member: its tracks, null while nothing has
|
||||
* arrived for it on its transport, and its key errors. Built in `scope`,
|
||||
* which the caller ends with the member or the participation, whichever
|
||||
* goes first. This is also how the backend learns which remote transports
|
||||
* exist: it follows the member's `transport$` for as long as `scope` lives,
|
||||
* and connects to every transport some member is on.
|
||||
*/
|
||||
mediaFor(scope: ObservableScope, member: RTCMember): MemberMedia;
|
||||
|
||||
/** Every connection the backend holds. For debugging and devtool purposes.*/
|
||||
readonly connections$: Behavior<BackendConnection[]>;
|
||||
|
||||
/**
|
||||
* Plays the members' audio on this device: on the local transport's
|
||||
* connection, whether it is connected yet or not, and on every connection
|
||||
* created from then on. A connection to a remote transport that already
|
||||
* exists keeps the device it was created with.
|
||||
*/
|
||||
setAudioOutputDeviceId(deviceId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* A text channel beside the media: sent on the local transport, received on every one.
|
||||
* This could well be just another track kind. Not needed for element call. just for the sdk target.
|
||||
* Keeping it on the top level for visibility. Should be tackled in a future refactor.
|
||||
*/
|
||||
sendData(topic: string, text: string): Promise<void>;
|
||||
readonly data$: Observable<DataPacket>;
|
||||
}
|
||||
|
||||
/** `tracks$` is null until the connection carries a local participant. */
|
||||
export interface LocalMediaBackend extends LocalMemberMedia {
|
||||
/**
|
||||
* State of the connection the local member publishes on. `Initialized`
|
||||
* while there is none; an `Error` is the connection's failure.
|
||||
*/
|
||||
readonly connectionState$: Behavior<MediaConnectionState | Error>;
|
||||
|
||||
/**
|
||||
* Whether the local tracks reach the transport. The session sets it to
|
||||
* "joined and the homeserver is reachable". Remembered across connections:
|
||||
* the tracks are created on the first `true`, and their upstream is paused
|
||||
* while `false`, so the local preview stays live while nothing is heard.
|
||||
*/
|
||||
setPublishing(publish: boolean): void;
|
||||
/** The first failure to publish, kept until the session ends. Not fatal. */
|
||||
readonly publishError$: Behavior<Error | null>;
|
||||
|
||||
/**
|
||||
* Publishes a source and resolves with its track once it is in `tracks$`.
|
||||
* Remembered across connections, so a request before the
|
||||
* transport is up is applied once it is, and republished after a
|
||||
* reconnection. Rejects where the device could not be used.
|
||||
*/
|
||||
publish(
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||
/** Removes one of our publications; a screen share takes its audio with it. */
|
||||
unpublish(source: MediaSource): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type MockedObject,
|
||||
onTestFinished,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrack,
|
||||
type RemoteTrackPublication,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
Track,
|
||||
ConnectionState as LivekitConnectionState,
|
||||
} from "livekit-client";
|
||||
import fetchMock from "fetch-mock";
|
||||
import EventEmitter from "events";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
import { logger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type LivekitGetTokenResponse,
|
||||
type UnstableLivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import {
|
||||
Connection,
|
||||
type ConnectionState,
|
||||
type ConnectionOpts,
|
||||
} from "./Connection.ts";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
|
||||
import { MatrixRTCError } from "../../errors";
|
||||
import { testJWTToken } from "../../utils/test-fixtures";
|
||||
import { ownMemberMock } from "../../utils/test";
|
||||
import { mockRemoteParticipant } from "./test";
|
||||
|
||||
let testScope: ObservableScope;
|
||||
|
||||
let client: MockedObject<ClientGetTokenParts & ClientOpenIDParts>;
|
||||
|
||||
let fakeLivekitRoom: MockedObject<LivekitRoom>;
|
||||
|
||||
let localParticipantEventEmiter: EventEmitter;
|
||||
let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
||||
|
||||
const ROOM_ID = "!roomID:example.org";
|
||||
const SLOT_ID = "m.call#ROOM";
|
||||
|
||||
const transport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
|
||||
};
|
||||
|
||||
/** The options of a subscriber connection to `transport`. */
|
||||
function connectionOpts(): ConnectionOpts {
|
||||
return {
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
slotId: SLOT_ID,
|
||||
role: "subscriber",
|
||||
transport,
|
||||
serverName: "m.localhost",
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
livekitRoomFactory: () => fakeLivekitRoom,
|
||||
};
|
||||
}
|
||||
|
||||
function setupTest(): void {
|
||||
testScope = new ObservableScope();
|
||||
client = vi.mocked<ClientGetTokenParts & ClientOpenIDParts>({
|
||||
getOpenIdToken: vi.fn().mockResolvedValue({
|
||||
access_token: "rYsmGUEwNjKgJYyeNUkZseJN",
|
||||
token_type: "Bearer",
|
||||
matrix_server_name: "example.org",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
|
||||
});
|
||||
|
||||
localParticipantEventEmiter = new EventEmitter();
|
||||
|
||||
fakeLocalParticipant = vi.mocked<LocalParticipant>({
|
||||
identity: "@me:example.org",
|
||||
isMicrophoneEnabled: vi.fn().mockReturnValue(true),
|
||||
getTrackPublication: vi.fn().mockReturnValue(undefined),
|
||||
on: localParticipantEventEmiter.on.bind(localParticipantEventEmiter),
|
||||
off: localParticipantEventEmiter.off.bind(localParticipantEventEmiter),
|
||||
addListener: localParticipantEventEmiter.addListener.bind(
|
||||
localParticipantEventEmiter,
|
||||
),
|
||||
removeListener: localParticipantEventEmiter.removeListener.bind(
|
||||
localParticipantEventEmiter,
|
||||
),
|
||||
removeAllListeners: localParticipantEventEmiter.removeAllListeners.bind(
|
||||
localParticipantEventEmiter,
|
||||
),
|
||||
} as unknown as LocalParticipant);
|
||||
|
||||
const fakeRoomEventEmitter = new EventEmitter();
|
||||
fakeLivekitRoom = vi.mocked<LivekitRoom>({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
remoteParticipants: new Map(),
|
||||
localParticipant: fakeLocalParticipant,
|
||||
state: LivekitConnectionState.Disconnected,
|
||||
on: fakeRoomEventEmitter.on.bind(fakeRoomEventEmitter),
|
||||
off: fakeRoomEventEmitter.off.bind(fakeRoomEventEmitter),
|
||||
addListener: fakeRoomEventEmitter.addListener.bind(fakeRoomEventEmitter),
|
||||
removeListener:
|
||||
fakeRoomEventEmitter.removeListener.bind(fakeRoomEventEmitter),
|
||||
removeAllListeners:
|
||||
fakeRoomEventEmitter.removeAllListeners.bind(fakeRoomEventEmitter),
|
||||
setE2EEEnabled: vi.fn().mockResolvedValue(undefined),
|
||||
emit: (eventName: string | symbol, ...args: unknown[]) => {
|
||||
fakeRoomEventEmitter.emit(eventName, ...args);
|
||||
},
|
||||
} as unknown as LivekitRoom);
|
||||
}
|
||||
|
||||
function setupRemoteConnection(): Connection {
|
||||
const opts = connectionOpts();
|
||||
|
||||
fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
|
||||
const changeEv = RoomEvent.ConnectionStateChanged;
|
||||
|
||||
fakeLivekitRoom.state = LivekitConnectionState.Connecting;
|
||||
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
|
||||
fakeLivekitRoom.state = LivekitConnectionState.Connected;
|
||||
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
return new Connection(opts, logger);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
describe("Start connection states", () => {
|
||||
it("start in initialized state", () => {
|
||||
setupTest();
|
||||
|
||||
const opts = connectionOpts();
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
expect(connection.state$.getValue()).toEqual({ state: "initialized" });
|
||||
});
|
||||
|
||||
it("fail to get SFU token then error state", async () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const opts = connectionOpts();
|
||||
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
const deferred = Promise.withResolvers<LivekitGetTokenResponse>();
|
||||
|
||||
client._unstable_getLivekitToken.mockImplementation(
|
||||
async (): Promise<LivekitGetTokenResponse> => {
|
||||
return await deferred.promise;
|
||||
},
|
||||
);
|
||||
|
||||
connection.start().catch(() => {
|
||||
// expected to throw
|
||||
});
|
||||
|
||||
let capturedState = capturedStates.pop();
|
||||
expect(capturedState).toBeDefined();
|
||||
expect(capturedState).toEqual({ state: "authenticating" });
|
||||
|
||||
deferred.reject(new MatrixRTCError("Failed to get token"));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
capturedState = capturedStates.pop();
|
||||
if (capturedState?.state === "error") {
|
||||
expect(capturedState.error.message).toEqual("Failed to get token");
|
||||
expect(connection.transport).toEqual(transport);
|
||||
} else {
|
||||
expect.fail(
|
||||
"Expected error state but got " + JSON.stringify(capturedState),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("fail to get JWT token and error state", async () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const opts = connectionOpts();
|
||||
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
const deferredSFU = Promise.withResolvers<void>();
|
||||
client._unstable_getLivekitToken.mockImplementation(async () => {
|
||||
await deferredSFU.promise;
|
||||
throw new MatrixError(
|
||||
{
|
||||
errcode: "M_LOOKUP_FAILED",
|
||||
error: "Failed to look up user info from homeserver",
|
||||
},
|
||||
500,
|
||||
);
|
||||
});
|
||||
|
||||
connection.start().catch(() => {
|
||||
// expected to throw
|
||||
});
|
||||
|
||||
let capturedState = capturedStates.pop();
|
||||
expect(capturedState).toBeDefined();
|
||||
expect(capturedState).toEqual({ state: "authenticating" });
|
||||
|
||||
deferredSFU.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
capturedState = capturedStates.pop();
|
||||
|
||||
if (
|
||||
capturedState?.state === "error" &&
|
||||
capturedState.error.cause instanceof Error
|
||||
) {
|
||||
expect(capturedState.error.cause.message).toContain(
|
||||
"Failed to look up user info from homeserver",
|
||||
);
|
||||
expect(connection.transport).toEqual(transport);
|
||||
} else {
|
||||
expect.fail(
|
||||
"Expected error state but got " + JSON.stringify(capturedState),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("fail to connect to livekit error state", async () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const opts = connectionOpts();
|
||||
|
||||
const connection = new Connection(opts, logger);
|
||||
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
const deferredSFU = Promise.withResolvers<void>();
|
||||
fakeLivekitRoom.connect.mockImplementation(async () => {
|
||||
await deferredSFU.promise;
|
||||
throw new Error("Failed to connect to livekit");
|
||||
});
|
||||
|
||||
connection.start().catch(() => {
|
||||
// expected to throw
|
||||
});
|
||||
|
||||
let capturedState = capturedStates.pop();
|
||||
expect(capturedState).toBeDefined();
|
||||
|
||||
expect(capturedState).toEqual({ state: "authenticating" });
|
||||
|
||||
deferredSFU.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
capturedState = capturedStates.pop();
|
||||
|
||||
if (
|
||||
capturedState?.state === "error" &&
|
||||
capturedState.error.cause instanceof Error
|
||||
) {
|
||||
expect(capturedState.error.cause.message).toContain(
|
||||
"Failed to connect to livekit",
|
||||
);
|
||||
expect(connection.transport).toEqual(transport);
|
||||
} else {
|
||||
expect.fail(
|
||||
"Expected FailedToStart state but got " + JSON.stringify(capturedState),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("connection states happy path", async () => {
|
||||
vi.useFakeTimers();
|
||||
setupTest();
|
||||
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
await connection.start();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const initialState = capturedStates.shift();
|
||||
expect(initialState).toEqual({ state: "initialized" });
|
||||
const fetchingState = capturedStates.shift();
|
||||
expect(fetchingState).toEqual({ state: "authenticating" });
|
||||
const disconnectedState = capturedStates.shift();
|
||||
expect(disconnectedState).toEqual({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Disconnected,
|
||||
});
|
||||
const connectingState = capturedStates.shift();
|
||||
expect(connectingState).toEqual({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connecting,
|
||||
});
|
||||
const connectedState = capturedStates.shift();
|
||||
expect(connectedState).toEqual({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
});
|
||||
});
|
||||
|
||||
it("authenticates a publisher on a livekit_service_url transport through the JWT service", async () => {
|
||||
setupTest();
|
||||
const serviceUrl = "https://matrix-rtc.example.org/livekit/jwt";
|
||||
fetchMock.post(`${serviceUrl}/sfu/get`, {
|
||||
status: 200,
|
||||
body: { url: "wss://sfu", jwt: testJWTToken },
|
||||
});
|
||||
fakeLivekitRoom.connect.mockResolvedValue(undefined);
|
||||
const connection = new Connection(
|
||||
{
|
||||
...connectionOpts(),
|
||||
role: "publisher",
|
||||
transport: { type: "livekit", livekit_service_url: serviceUrl },
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
await connection.start();
|
||||
|
||||
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
|
||||
expect(fetchMock.called(`${serviceUrl}/sfu/get`)).toBe(true);
|
||||
expect(fakeLivekitRoom.connect).toHaveBeenCalledWith(
|
||||
"wss://sfu",
|
||||
testJWTToken,
|
||||
);
|
||||
expect(connection.livekitAlias).toBe("!example_room_id");
|
||||
});
|
||||
|
||||
it("stopping while connecting does not report an error", async () => {
|
||||
setupTest();
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
const capturedStates: ConnectionState[] = [];
|
||||
const s = connection.state$.subscribe((value) => {
|
||||
capturedStates.push(value);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
// livekit-client rejects a pending connect() when disconnect() is called.
|
||||
const pendingConnect = Promise.withResolvers<void>();
|
||||
fakeLivekitRoom.connect.mockReturnValue(pendingConnect.promise);
|
||||
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
|
||||
|
||||
const started = connection.start();
|
||||
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
|
||||
const stopping = connection.stop();
|
||||
pendingConnect.reject(new Error("Client initiated disconnect"));
|
||||
await stopping;
|
||||
|
||||
// start() resolves rather than rejecting (it is not awaited by the caller,
|
||||
// so a rejection would be unhandled).
|
||||
await expect(started).resolves.toBeUndefined();
|
||||
expect(capturedStates.at(-1)).toEqual({ state: "stopped" });
|
||||
expect(capturedStates.some((st) => st.state === "error")).toBe(false);
|
||||
});
|
||||
|
||||
it("shutting down the scope should stop the connection", async () => {
|
||||
setupTest();
|
||||
vi.useFakeTimers();
|
||||
|
||||
const connection = setupRemoteConnection();
|
||||
await connection.start();
|
||||
|
||||
const stopSpy = vi.spyOn(connection, "stop");
|
||||
testScope.end();
|
||||
|
||||
expect(stopSpy).toHaveBeenCalled();
|
||||
expect(fakeLivekitRoom.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote participants", () => {
|
||||
it("emits the list of remote participants", () => {
|
||||
setupTest();
|
||||
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
const observedParticipants: RemoteParticipant[][] = [];
|
||||
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||
observedParticipants.push(participants);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
// The remoteParticipants$ observable is derived from the current members of the
|
||||
// livekitRoom and the rtc membership in order to publish the members that are publishing
|
||||
// on this connection.
|
||||
|
||||
let participants: RemoteParticipant[] = [
|
||||
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||
// Mock Dan to have no published tracks. We want him to still show show up
|
||||
// in the participants list.
|
||||
mockRemoteParticipant({
|
||||
identity: "@dan:example.org:DEV333",
|
||||
getTrackPublication: () => undefined,
|
||||
getTrackPublications: () => [],
|
||||
}),
|
||||
];
|
||||
|
||||
// Let's simulate 3 members on the livekitRoom
|
||||
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockImplementation(
|
||||
() => new Map(participants.map((p) => [p.identity, p])),
|
||||
);
|
||||
|
||||
participants.forEach((p) =>
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||
);
|
||||
|
||||
// At this point there should be ~~no~~ publishers
|
||||
// We do have publisher now, since we do not filter for publishers anymore (to also have participants with only data tracks)
|
||||
// The filtering we do is just based on the matrixRTC member events.
|
||||
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||
|
||||
participants = [
|
||||
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||
mockRemoteParticipant({ identity: "@dan:example.org:DEV333" }),
|
||||
];
|
||||
participants.forEach((p) =>
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||
);
|
||||
|
||||
// At this point there should be no publishers
|
||||
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||
});
|
||||
|
||||
it("should be scoped to parent scope", (): void => {
|
||||
setupTest();
|
||||
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
let observedParticipants: RemoteParticipant[][] = [];
|
||||
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||
observedParticipants.push(participants);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
let participants: RemoteParticipant[] = [
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
];
|
||||
|
||||
// Let's simulate 3 members on the livekitRoom
|
||||
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockImplementation(
|
||||
() => new Map(participants.map((p) => [p.identity, p])),
|
||||
);
|
||||
|
||||
for (const participant of participants) {
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
||||
}
|
||||
|
||||
// We should have bob as a participant now
|
||||
const ps = observedParticipants.pop();
|
||||
expect(ps?.length).toEqual(1);
|
||||
expect(ps?.[0]?.identity).toEqual("@bob:example.org:DEV111");
|
||||
|
||||
// end the parent scope
|
||||
testScope.end();
|
||||
observedParticipants = [];
|
||||
|
||||
// SHOULD NOT emit any more participants as the scope is ended
|
||||
participants = participants.filter(
|
||||
(p) => p.identity !== "@bob:example.org:DEV111",
|
||||
);
|
||||
|
||||
fakeLivekitRoom.emit(
|
||||
RoomEvent.ParticipantDisconnected,
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
);
|
||||
|
||||
expect(observedParticipants.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote track logging", () => {
|
||||
it("logs remote participant and track events on the connection logger", () => {
|
||||
setupTest();
|
||||
const info = vi.fn();
|
||||
const warn = vi.fn();
|
||||
const testLogger = {
|
||||
getChild: (): unknown => testLogger,
|
||||
info,
|
||||
debug: vi.fn(),
|
||||
warn,
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
new Connection(connectionOpts(), testLogger);
|
||||
info.mockClear(); // drop the constructor log line
|
||||
|
||||
const bob = mockRemoteParticipant({
|
||||
identity: "@bob:example.org:DEV111",
|
||||
sid: "PA_bob",
|
||||
});
|
||||
const pub = {
|
||||
kind: "audio",
|
||||
source: "microphone",
|
||||
trackSid: "TR_mic",
|
||||
isMuted: false,
|
||||
isEncrypted: true,
|
||||
} as unknown as RemoteTrackPublication;
|
||||
const messages = (): string[] => info.mock.calls.map((c) => c[0] as string);
|
||||
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, bob);
|
||||
fakeLivekitRoom.emit(
|
||||
RoomEvent.TrackSubscribed,
|
||||
{} as RemoteTrack,
|
||||
pub,
|
||||
bob,
|
||||
);
|
||||
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, bob);
|
||||
fakeLivekitRoom.emit(
|
||||
RoomEvent.TrackStreamStateChanged,
|
||||
pub,
|
||||
Track.StreamState.Paused,
|
||||
bob,
|
||||
);
|
||||
expect(messages()).toEqual([
|
||||
"Participant connected: @bob:example.org:DEV111 (PA_bob)",
|
||||
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true muted=false",
|
||||
"Muted: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
|
||||
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
|
||||
]);
|
||||
|
||||
// Encryption status changes are logged; cryptor errors are warnings
|
||||
fakeLivekitRoom.emit(
|
||||
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||
false,
|
||||
bob,
|
||||
);
|
||||
expect(messages().at(-1)).toBe(
|
||||
"Encryption status of @bob:example.org:DEV111: encrypted=false",
|
||||
);
|
||||
const cryptorError = new Error("missing key at index 3");
|
||||
fakeLivekitRoom.emit(RoomEvent.EncryptionError, cryptorError, bob);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Encryption error for @bob:example.org:DEV111:",
|
||||
cryptorError,
|
||||
);
|
||||
|
||||
// Local mute events are already logged by the Publisher
|
||||
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, {
|
||||
...fakeLocalParticipant,
|
||||
isLocal: true,
|
||||
} as unknown as LocalParticipant);
|
||||
expect(messages().filter((m) => m.startsWith("Muted"))).toHaveLength(1);
|
||||
|
||||
// Listeners are removed when the scope ends
|
||||
testScope.end();
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantDisconnected, bob);
|
||||
expect(
|
||||
messages().filter((m) => m.startsWith("Participant disconnected")),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
//
|
||||
// NOT USED ANYMORE ?
|
||||
//
|
||||
// This setup look like sth for the Publisher. Not a connection.
|
||||
|
||||
// describe("PublishConnection", () => {
|
||||
// // let fakeBlurProcessor: ProcessorWrapper<BackgroundOptions>;
|
||||
// let roomFactoryMock: Mock<() => LivekitRoom>;
|
||||
// let muteStates: MockedObject<MuteStates>;
|
||||
|
||||
// function setUpPublishConnection(): void {
|
||||
// setupTest();
|
||||
|
||||
// roomFactoryMock = vi.fn().mockReturnValue(fakeLivekitRoom);
|
||||
|
||||
// muteStates = mockMuteStates();
|
||||
|
||||
// // fakeBlurProcessor = vi.mocked<ProcessorWrapper<BackgroundOptions>>({
|
||||
// // name: "BackgroundBlur",
|
||||
// // restart: vi.fn().mockResolvedValue(undefined),
|
||||
// // setOptions: vi.fn().mockResolvedValue(undefined),
|
||||
// // getOptions: vi.fn().mockReturnValue({ strength: 0.5 }),
|
||||
// // isRunning: vi.fn().mockReturnValue(false)
|
||||
// // });
|
||||
// }
|
||||
|
||||
// describe("Livekit room creation", () => {
|
||||
// function createSetup(): void {
|
||||
// setUpPublishConnection();
|
||||
|
||||
// const fakeTrackProcessorSubject$ = new BehaviorSubject<ProcessorState>({
|
||||
// supported: true,
|
||||
// processor: undefined,
|
||||
// });
|
||||
|
||||
// const opts: ConnectionOpts = {
|
||||
// client: client,
|
||||
// transport: livekitFocus,
|
||||
// scope: testScope,
|
||||
// livekitRoomFactory: roomFactoryMock,
|
||||
// };
|
||||
|
||||
// const audioInput = {
|
||||
// available$: of(new Map([["mic1", { id: "mic1" }]])),
|
||||
// selected$: new BehaviorSubject({ id: "mic1" }),
|
||||
// select(): void {},
|
||||
// };
|
||||
|
||||
// const videoInput = {
|
||||
// available$: of(new Map([["cam1", { id: "cam1" }]])),
|
||||
// selected$: new BehaviorSubject({ id: "cam1" }),
|
||||
// select(): void {},
|
||||
// };
|
||||
|
||||
// const audioOutput = {
|
||||
// available$: of(new Map([["speaker", { id: "speaker" }]])),
|
||||
// selected$: new BehaviorSubject({ id: "speaker" }),
|
||||
// select(): void {},
|
||||
// };
|
||||
|
||||
// // TODO understand what is wrong with our mocking that requires ts-expect-error
|
||||
// const fakeDevices = mockMediaDevices({
|
||||
// // @ts-expect-error Mocking only
|
||||
// audioInput,
|
||||
// // @ts-expect-error Mocking only
|
||||
// videoInput,
|
||||
// // @ts-expect-error Mocking only
|
||||
// audioOutput,
|
||||
// });
|
||||
|
||||
// new Connection(
|
||||
// opts,
|
||||
// fakeDevices,
|
||||
// muteStates,
|
||||
// undefined,
|
||||
// fakeTrackProcessorSubject$,
|
||||
// );
|
||||
// }
|
||||
|
||||
// it("should create room with proper initial audio and video settings", () => {
|
||||
// createSetup();
|
||||
|
||||
// expect(roomFactoryMock).toHaveBeenCalled();
|
||||
|
||||
// const lastCallArgs =
|
||||
// roomFactoryMock.mock.calls[roomFactoryMock.mock.calls.length - 1];
|
||||
|
||||
// const roomOptions = lastCallArgs.pop() as unknown as RoomOptions;
|
||||
// expect(roomOptions).toBeDefined();
|
||||
|
||||
// expect(roomOptions!.videoCaptureDefaults?.deviceId).toEqual("cam1");
|
||||
// expect(roomOptions!.audioCaptureDefaults?.deviceId).toEqual("mic1");
|
||||
// expect(roomOptions!.audioOutput?.deviceId).toEqual("speaker");
|
||||
// });
|
||||
|
||||
// it("respect controlledAudioDevices", () => {
|
||||
// // TODO: Refactor the code to make it testable.
|
||||
// // The UrlParams module is a singleton has a cache and is very hard to test.
|
||||
// // This breaks other tests as well if not handled properly.
|
||||
// // vi.mock(import("./../UrlParams"), () => {
|
||||
// // return {
|
||||
// // getUrlParams: vi.fn().mockReturnValue({
|
||||
// // controlledAudioDevices: true
|
||||
// // })
|
||||
// // };
|
||||
// // });
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
@@ -0,0 +1,414 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
connectedParticipantsObserver,
|
||||
connectionStateObserver,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
type ConnectionState as LivekitConnectionState,
|
||||
type Participant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrackPublication,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
type SubscriptionError,
|
||||
type Track,
|
||||
type TrackPublication,
|
||||
} from "livekit-client";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import {
|
||||
type ClientGetTokenParts,
|
||||
type ClientOpenIDParts,
|
||||
type ConnectionRole,
|
||||
getSFUConfig,
|
||||
type SFUConfig,
|
||||
} from "./auth";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { type ObservableScope } from "../../reactive/ObservableScope";
|
||||
import {
|
||||
MatrixRTCError,
|
||||
InsufficientCapacityError,
|
||||
LivekitConnectionError,
|
||||
PeerConnectionTimeoutError,
|
||||
SFURoomCreationRestrictedError,
|
||||
UnknownRTCError,
|
||||
} from "../../errors";
|
||||
|
||||
export interface ConnectionOpts {
|
||||
/** The identity parts to use on this connection */
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/** Whether we publish on this connection or only subscribe. */
|
||||
role: ConnectionRole;
|
||||
/** The media transport to connect to. */
|
||||
transport: UnstableLivekitTransport;
|
||||
/** The homeserver the transport belongs to. */
|
||||
serverName: string;
|
||||
/** The Matrix client, for the token exchange. */
|
||||
client: ClientGetTokenParts & ClientOpenIDParts;
|
||||
/** The Matrix room the session is in. */
|
||||
roomId: string;
|
||||
/** The MatrixRTC slot the session is in. */
|
||||
slotId: string;
|
||||
/** The observable scope to use for this connection. */
|
||||
scope: ObservableScope;
|
||||
|
||||
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
|
||||
livekitRoomFactory: () => LivekitRoom;
|
||||
}
|
||||
|
||||
export type ConnectionState =
|
||||
/** Created, nothing started yet. */
|
||||
| { state: "initialized" }
|
||||
/** `start` was called; the SFU token is being fetched. */
|
||||
| { state: "authenticating" }
|
||||
/** The token is in hand; the LiveKit room's own state from here. */
|
||||
| { state: "authenticated"; livekitState: LivekitConnectionState }
|
||||
| { state: "error"; error: MatrixRTCError }
|
||||
| { state: "stopped" };
|
||||
|
||||
/**
|
||||
* A connection to a Matrix RTC LiveKit backend.
|
||||
*
|
||||
* Expose observables for participants and connection state.
|
||||
*/
|
||||
export class Connection {
|
||||
// Private Behavior
|
||||
private readonly _state$ = new BehaviorSubject<ConnectionState>({
|
||||
state: "initialized",
|
||||
});
|
||||
|
||||
/**
|
||||
* The current state of the connection to the media transport.
|
||||
*/
|
||||
public readonly state$: Behavior<ConnectionState> = this._state$;
|
||||
|
||||
/** Whether we publish on this connection or only subscribe. */
|
||||
public readonly role: ConnectionRole;
|
||||
|
||||
/**
|
||||
* The media transport to connect to.
|
||||
*/
|
||||
public readonly transport: UnstableLivekitTransport;
|
||||
|
||||
/** The homeserver the transport belongs to. */
|
||||
public readonly serverName: string;
|
||||
|
||||
public readonly livekitRoom: LivekitRoom;
|
||||
|
||||
private scope: ObservableScope;
|
||||
|
||||
/**
|
||||
* The remote LiveKit participants that are visible on this connection.
|
||||
*
|
||||
* Note that this may include participants that are connected only to
|
||||
* subscribe, or publishers that are otherwise unattested in MatrixRTC state.
|
||||
* It is therefore more low-level than what should be presented to the user.
|
||||
*/
|
||||
public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||
|
||||
/**
|
||||
* The alias of the LiveKit room.
|
||||
*/
|
||||
public get livekitAlias(): string | undefined {
|
||||
return this._livekitAlias;
|
||||
}
|
||||
private _livekitAlias?: string;
|
||||
|
||||
/**
|
||||
* Whether the connection has been stopped.
|
||||
* @see Connection.stop
|
||||
* */
|
||||
protected stopped = false;
|
||||
|
||||
private readonly client: ClientGetTokenParts & ClientOpenIDParts;
|
||||
private readonly roomId: string;
|
||||
private readonly slotId: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/**
|
||||
* Creates a new connection to a matrix RTC LiveKit backend.
|
||||
*
|
||||
* @param opts - Connection options {@link ConnectionOpts}.
|
||||
*
|
||||
* @param logger - The logger to use.
|
||||
*/
|
||||
public constructor(opts: ConnectionOpts, logger: Logger) {
|
||||
this.ownMembershipIdentity = opts.ownMembershipIdentity;
|
||||
this.roomId = opts.roomId;
|
||||
this.slotId = opts.slotId;
|
||||
this.logger = logger.getChild(
|
||||
`[Connection ${JSON.stringify(opts.transport)}]`,
|
||||
);
|
||||
this.logger.debug(`constructor called`);
|
||||
const { role, transport, serverName, client, scope } = opts;
|
||||
|
||||
this.scope = scope;
|
||||
this.livekitRoom = opts.livekitRoomFactory();
|
||||
this.role = role;
|
||||
this.transport = transport;
|
||||
this.serverName = serverName;
|
||||
this.client = client;
|
||||
|
||||
this.remoteParticipants$ = scope.behavior(
|
||||
// Only tracks remote participants
|
||||
connectedParticipantsObserver(this.livekitRoom),
|
||||
);
|
||||
this.logRemoteTrackEvents();
|
||||
|
||||
scope.onEnd(() => {
|
||||
this.logger.info(`Connection scope ended, stopping connection`);
|
||||
void this.stop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the lifecycle of remote participants and their tracks as seen by
|
||||
* livekit-client, so that rageshakes show what a tile should have been
|
||||
* rendering (published, subscribed, muted, paused) for each remote member.
|
||||
*/
|
||||
private logRemoteTrackEvents(): void {
|
||||
const room = this.livekitRoom;
|
||||
const log = this.logger.getChild("[RemoteTracks]");
|
||||
// The encryption flag matters: if the publisher encrypts but this client
|
||||
// believes the track is unencrypted, the cryptor is bypassed and raw
|
||||
// ciphertext reaches the decoder (audible as loud noise bursts).
|
||||
const track = (pub: TrackPublication, p: Participant): string =>
|
||||
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity} encrypted=${pub.isEncrypted}`;
|
||||
|
||||
const onParticipantConnected = (p: RemoteParticipant): void =>
|
||||
log.info(`Participant connected: ${p.identity} (${p.sid})`);
|
||||
const onParticipantDisconnected = (p: RemoteParticipant): void =>
|
||||
log.info(`Participant disconnected: ${p.identity} (${p.sid})`);
|
||||
const onTrackPublished = (
|
||||
pub: RemoteTrackPublication,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Published: ${track(pub, p)} muted=${pub.isMuted}`);
|
||||
const onTrackUnpublished = (
|
||||
pub: RemoteTrackPublication,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Unpublished: ${track(pub, p)}`);
|
||||
const onTrackSubscribed = (
|
||||
_t: Track,
|
||||
pub: RemoteTrackPublication,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Subscribed: ${track(pub, p)} muted=${pub.isMuted}`);
|
||||
const onTrackUnsubscribed = (
|
||||
_t: Track,
|
||||
pub: RemoteTrackPublication,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Unsubscribed: ${track(pub, p)}`);
|
||||
const onTrackSubscriptionFailed = (
|
||||
trackSid: string,
|
||||
p: RemoteParticipant,
|
||||
reason?: SubscriptionError,
|
||||
): void =>
|
||||
log.warn(
|
||||
`Subscription failed: ${trackSid} of ${p.identity} reason=${reason}`,
|
||||
);
|
||||
// Mute events also fire for the local participant, which the Publisher
|
||||
// already logs.
|
||||
const onTrackMuted = (pub: TrackPublication, p: Participant): void => {
|
||||
if (!p.isLocal) log.info(`Muted: ${track(pub, p)}`);
|
||||
};
|
||||
const onTrackUnmuted = (pub: TrackPublication, p: Participant): void => {
|
||||
if (!p.isLocal) log.info(`Unmuted: ${track(pub, p)}`);
|
||||
};
|
||||
const onTrackStreamStateChanged = (
|
||||
pub: RemoteTrackPublication,
|
||||
state: Track.StreamState,
|
||||
p: RemoteParticipant,
|
||||
): void => log.info(`Stream ${state}: ${track(pub, p)}`);
|
||||
const onEncryptionStatusChanged = (
|
||||
encrypted: boolean,
|
||||
p?: Participant,
|
||||
): void =>
|
||||
log.info(
|
||||
`Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`,
|
||||
);
|
||||
// livekit-client throttles these per cryptor; they indicate frames being
|
||||
// dropped (missing/invalid key).
|
||||
const onEncryptionError = (error: Error, p?: Participant): void =>
|
||||
log.warn(
|
||||
`Encryption error for ${p?.identity ?? "unknown participant"}:`,
|
||||
error,
|
||||
);
|
||||
|
||||
room
|
||||
.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||
.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||
.on(RoomEvent.TrackPublished, onTrackPublished)
|
||||
.on(RoomEvent.TrackUnpublished, onTrackUnpublished)
|
||||
.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||
.on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
|
||||
.on(RoomEvent.TrackMuted, onTrackMuted)
|
||||
.on(RoomEvent.TrackUnmuted, onTrackUnmuted)
|
||||
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
|
||||
.on(
|
||||
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||
onEncryptionStatusChanged,
|
||||
)
|
||||
.on(RoomEvent.EncryptionError, onEncryptionError);
|
||||
this.scope.onEnd(() => {
|
||||
room
|
||||
.off(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||
.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||
.off(RoomEvent.TrackPublished, onTrackPublished)
|
||||
.off(RoomEvent.TrackUnpublished, onTrackUnpublished)
|
||||
.off(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||
.off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
|
||||
.off(RoomEvent.TrackMuted, onTrackMuted)
|
||||
.off(RoomEvent.TrackUnmuted, onTrackUnmuted)
|
||||
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
|
||||
.off(
|
||||
RoomEvent.ParticipantEncryptionStatusChanged,
|
||||
onEncryptionStatusChanged,
|
||||
)
|
||||
.off(RoomEvent.EncryptionError, onEncryptionError);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the connection.
|
||||
*
|
||||
* This will:
|
||||
* 1. Authenticate with the transport for our role, through the homeserver
|
||||
* or the JWT service.
|
||||
* 2. Connect to the LiveKit room the token is for.
|
||||
*
|
||||
* The errors are also represented as a state in the `state$` observable.
|
||||
* It is safe to ignore those errors and handle them accordingly via the `state$` observable.
|
||||
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
|
||||
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
|
||||
*/
|
||||
// TODO consider an autostart pattern...
|
||||
public async start(): Promise<void> {
|
||||
this.logger.debug("Starting Connection");
|
||||
this.stopped = false;
|
||||
try {
|
||||
this._state$.next({ state: "authenticating" });
|
||||
const { url, jwt, livekitAlias } = await this.getSFUConfig();
|
||||
this.logger.debug(
|
||||
"Starting Connection - jwt: ",
|
||||
jwt,
|
||||
"wss: ",
|
||||
url,
|
||||
"livekitAlias: ",
|
||||
livekitAlias,
|
||||
);
|
||||
this._livekitAlias = livekitAlias;
|
||||
// If we were stopped while fetching the config, don't proceed to connect
|
||||
if (this.stopped) return;
|
||||
|
||||
// Setup observer once we are done with getSFUConfig
|
||||
connectionStateObserver(this.livekitRoom)
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((livekitState) =>
|
||||
this._state$.next({ state: "authenticated", livekitState }),
|
||||
);
|
||||
|
||||
try {
|
||||
this.logger.info(`livekitRoom.connect ${url}`);
|
||||
await this.livekitRoom.connect(url, jwt);
|
||||
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
|
||||
} catch (e) {
|
||||
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
|
||||
// LiveKit uses 503 to indicate that the server has hit its track limits.
|
||||
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
|
||||
// It also errors with a status code of 200 (yes, really) for room
|
||||
// participant limits.
|
||||
// LiveKit Cloud uses 429 for connection limits.
|
||||
// Either way, all these errors can be explained as "insufficient capacity".
|
||||
if (e instanceof ConnectionError) {
|
||||
if (e.status === 503 || e.status === 200 || e.status === 429) {
|
||||
throw new InsufficientCapacityError();
|
||||
}
|
||||
if (e.status === 404) {
|
||||
// error msg is "Failed to create call"
|
||||
// error description is "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists."
|
||||
// The room does not exist. There are two different modes of operation for the SFU:
|
||||
// - the room is created on the fly when connecting (livekit `auto_create` option)
|
||||
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
|
||||
// In the first case there will not be a 404, so we are in the second case.
|
||||
throw new SFURoomCreationRestrictedError();
|
||||
}
|
||||
|
||||
if (e.reason === ConnectionErrorReason.Timeout) {
|
||||
// Unabled to establish peer connection within the timeout
|
||||
throw new PeerConnectionTimeoutError();
|
||||
}
|
||||
|
||||
throw new LivekitConnectionError(e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
// If we were stopped while connecting, don't proceed to update state.
|
||||
if (this.stopped) return;
|
||||
} catch (error) {
|
||||
if (this.stopped) {
|
||||
// stop() was called while we were connecting, which makes the pending
|
||||
// connect reject. That is the abort we asked for, not a failure, so
|
||||
// don't record an error state on a stopped connection or rethrow it
|
||||
// (start() is not necessarily awaited by the caller, so a throw here
|
||||
// becomes an unhandled promise rejection).
|
||||
this.logger.debug(`Connect aborted because the connection was stopped`);
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
|
||||
this._state$.next({
|
||||
state: "error",
|
||||
error:
|
||||
error instanceof MatrixRTCError
|
||||
? error
|
||||
: error instanceof Error
|
||||
? new UnknownRTCError(error)
|
||||
: new UnknownRTCError(new Error(`${error}`)),
|
||||
});
|
||||
// Its okay to ignore the throw. The error is part of the state.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
protected async getSFUConfig(): Promise<SFUConfig> {
|
||||
return await getSFUConfig({
|
||||
client: this.client,
|
||||
membership: this.ownMembershipIdentity,
|
||||
role: this.role,
|
||||
transport: this.transport,
|
||||
serverName: this.serverName,
|
||||
roomId: this.roomId,
|
||||
slotId: this.slotId,
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the connection.
|
||||
*
|
||||
* This will disconnect from the LiveKit room.
|
||||
* If the connection is already stopped, this is a no-op.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.logger.debug("stop: disconnecting from lk room");
|
||||
if (this.stopped) return;
|
||||
// Mark as stopped before disconnecting so that a connect() aborted by the
|
||||
// disconnect sees the flag and does not report the abort as an error.
|
||||
this.stopped = true;
|
||||
await this.livekitRoom.disconnect();
|
||||
this._state$.next({ state: "stopped" });
|
||||
this.logger.debug("stop: DONE disconnecting from lk room");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
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 {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
type RoomOptions,
|
||||
} from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type MediaQuality } from "../../config";
|
||||
import { type PublishRequest } from "../../media-api";
|
||||
import { constant } from "../../reactive/Behavior";
|
||||
import { ownMemberMock, testScope } from "../../utils/test";
|
||||
import { testJWTToken } from "../../utils/test-fixtures";
|
||||
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
|
||||
import {
|
||||
LivekitConnectionFactory,
|
||||
type ResolvedConnection,
|
||||
} from "./ConnectionFactory";
|
||||
import { exampleTransport } from "./test";
|
||||
|
||||
const urlTransport: UnstableLivekitTransport = {
|
||||
type: "livekit",
|
||||
url: "wss://sfu.example.org",
|
||||
};
|
||||
|
||||
function client(): ClientGetTokenParts & ClientOpenIDParts {
|
||||
return {
|
||||
getOpenIdToken: vi.fn(),
|
||||
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a subscriber connection and hands back the options its LiveKit room
|
||||
* was built with. The room is a real one: it never connects, and its options
|
||||
* are what the factory gave it, filled in with LiveKit's defaults.
|
||||
*/
|
||||
function create(
|
||||
publish: PublishRequest[],
|
||||
mediaQuality?: MediaQuality,
|
||||
audioOutputDeviceId?: string,
|
||||
transport = exampleTransport,
|
||||
): { connection: ResolvedConnection; options: RoomOptions } {
|
||||
const factory = new LivekitConnectionFactory(
|
||||
client(),
|
||||
"!room:example.org",
|
||||
"m.call#ROOM",
|
||||
publish,
|
||||
constant(audioOutputDeviceId),
|
||||
undefined,
|
||||
mediaQuality,
|
||||
);
|
||||
const connection = factory.createConnection(
|
||||
testScope(),
|
||||
"subscriber",
|
||||
transport,
|
||||
"example.org",
|
||||
ownMemberMock,
|
||||
logger,
|
||||
) as ResolvedConnection;
|
||||
return { connection, options: connection.livekitRoom.options };
|
||||
}
|
||||
|
||||
describe("LivekitConnectionFactory", () => {
|
||||
it("builds the room on the defaults where nothing is requested", () => {
|
||||
const { options } = create([]);
|
||||
|
||||
expect(options.publishDefaults).toMatchObject({ videoCodec: "vp8" });
|
||||
expect(options.videoCaptureDefaults).toMatchObject({
|
||||
resolution: expect.objectContaining({ height: 720 }),
|
||||
});
|
||||
expect(options.audioOutput).toEqual({ deviceId: undefined });
|
||||
expect(options.e2ee).toBeUndefined();
|
||||
});
|
||||
|
||||
it("makes the initial microphone request the room's audio defaults", () => {
|
||||
const { options } = create([
|
||||
{
|
||||
source: "microphone",
|
||||
deviceId: "mic-1",
|
||||
capture: {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(options.audioCaptureDefaults).toMatchObject({
|
||||
deviceId: "mic-1",
|
||||
echoCancellation: false,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("makes the initial camera request the room's video and publish defaults", () => {
|
||||
const { options } = create(
|
||||
[
|
||||
{
|
||||
source: "camera",
|
||||
deviceId: "cam-1",
|
||||
capture: {
|
||||
resolution: { width: 1920, height: 1080, frameRate: 60 },
|
||||
maxBitrate: 4_000_000,
|
||||
maxFramerate: 60,
|
||||
codec: "vp9",
|
||||
},
|
||||
},
|
||||
],
|
||||
{ video_codec: "h264" },
|
||||
);
|
||||
|
||||
expect(options.videoCaptureDefaults).toMatchObject({
|
||||
deviceId: "cam-1",
|
||||
resolution: { width: 1920, height: 1080, frameRate: 60 },
|
||||
});
|
||||
// The request wins over the media quality settings
|
||||
expect(options.publishDefaults).toMatchObject({
|
||||
videoEncoding: { maxBitrate: 4_000_000, maxFramerate: 60 },
|
||||
videoCodec: "vp9",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the media quality settings where the requests leave the defaults", () => {
|
||||
const { options } = create([{ source: "camera" }], {
|
||||
video_codec: "av1",
|
||||
video: { max_bitrate: 900_000, max_framerate: 24 },
|
||||
});
|
||||
|
||||
expect(options.publishDefaults).toMatchObject({
|
||||
videoEncoding: { maxBitrate: 900_000, maxFramerate: 24 },
|
||||
videoCodec: "av1",
|
||||
});
|
||||
});
|
||||
|
||||
it("plays on the output device selected when the room is built", () => {
|
||||
const { options } = create([], undefined, "speaker");
|
||||
expect(options.audioOutput).toEqual({ deviceId: "speaker" });
|
||||
});
|
||||
|
||||
it("reports what it fetched while connected, and nothing before or after", async () => {
|
||||
const { connection } = create([], undefined, undefined, urlTransport);
|
||||
// The room must not reach for the network; its state is left as it is
|
||||
vi.spyOn(connection.livekitRoom, "connect").mockResolvedValue(undefined);
|
||||
vi.spyOn(connection.livekitRoom, "disconnect").mockResolvedValue(undefined);
|
||||
expect(connection.resolved$.value).toBeUndefined();
|
||||
|
||||
await connection.start();
|
||||
|
||||
expect(connection.state$.value).toEqual({
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Disconnected,
|
||||
});
|
||||
expect(connection.resolved$.value).toEqual({
|
||||
type: "livekit",
|
||||
url: "wss://sfu.example.org",
|
||||
token: testJWTToken,
|
||||
roomAlias: "!example_room_id",
|
||||
identity: "@me:example.org:ABCDEF",
|
||||
});
|
||||
|
||||
await connection.stop();
|
||||
expect(connection.resolved$.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user