mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-13 01:12:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e504d307 | ||
|
|
de16cb4e37 |
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
prevent-blocked:
|
||||
name: Prevent blocked
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
|
||||
@@ -17,7 +17,7 @@ env:
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
name: Build & publish docker
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write # needed for publishing packages to GHCR
|
||||
|
||||
@@ -29,7 +29,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build Element Call
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
# Deploy main branch to call.element.dev after build completes
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build_full_element_call
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to call.element.dev
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
build_storybook:
|
||||
name: Build Storybook
|
||||
if: contains(github.event.pull_request.labels.*.name, 'storybook build')
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pr-changelog-label:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Review
|
||||
# Only run on labelling and skip forks.
|
||||
if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -32,7 +32,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
deployments: write
|
||||
environment: Netlify
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint, format & type check
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_number: ${{ steps.prdetails.outputs.pr_id }}
|
||||
pr_data_json: ${{ steps.prdetails.outputs.data }}
|
||||
|
||||
@@ -17,7 +17,7 @@ permissions: {}
|
||||
jobs:
|
||||
versioning:
|
||||
name: Versioning
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
|
||||
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
publish_npm:
|
||||
needs: versioning
|
||||
name: Publish NPM
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
needs: [versioning, publish_npm]
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
name: Update release notes
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
# Shared with publish-embedded-packages.yaml, which appends to the same release: two
|
||||
# appends at once would each write back the body without the other's section
|
||||
concurrency:
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
jobs:
|
||||
versioning:
|
||||
name: Versioning
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
|
||||
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish tarball
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
steps:
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish NPM
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish Android AAR
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish SwiftPM Library
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
needs: [versioning, publish_npm, publish_android, publish_ios]
|
||||
if: always()
|
||||
name: Update release notes
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
# Shared with publish-component.yaml, which appends to the same release: two appends
|
||||
# at once would each write back the body without the other's section
|
||||
concurrency:
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
needs: build_element_call
|
||||
if: always()
|
||||
name: Publish tarball
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
add_docker_release_note:
|
||||
needs: publish_docker
|
||||
name: Add docker release note
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add release note
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
jobs:
|
||||
vitest:
|
||||
name: Run unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
@@ -71,27 +71,10 @@ jobs:
|
||||
flags: unittests
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests (${{ matrix.part }})
|
||||
name: Run end-to-end tests
|
||||
if: ${{ !inputs.disable_e2e_tests }}
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-24.04
|
||||
# We manually shard the tests across runners. Playwright's own shards cut
|
||||
# the list of tests into equal counts in file order, which would leave the
|
||||
# widget suite, the slowest tests by far, together at the end.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- part: chromium-1
|
||||
args: --project=chromium --shard=1/2
|
||||
- part: chromium-2
|
||||
args: --project=chromium --shard=2/2
|
||||
- part: widget-1
|
||||
args: --project=widget --shard=1/2
|
||||
- part: widget-2
|
||||
args: --project=widget --shard=2/2
|
||||
- part: firefox-mobile
|
||||
args: --project=firefox --project=mobile
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
@@ -124,64 +107,17 @@ jobs:
|
||||
run: pnpm playwright install --with-deps
|
||||
- name: Run backend components
|
||||
run: |
|
||||
# Make sure the directory for the embedded build exists as Docker might
|
||||
# create it with wrong access rights otherwise.
|
||||
mkdir -p embedded/web/dist
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml pull
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml up -d
|
||||
docker ps
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
USE_DOCKER: 1
|
||||
ARGS: ${{ matrix.args }}
|
||||
# Unquoted on purpose: the part's arguments are several words
|
||||
run: pnpm exec playwright test $ARGS
|
||||
run: pnpm exec playwright test
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: blob-report-${{ matrix.part }}
|
||||
path: blob-report
|
||||
if-no-files-found: error
|
||||
# Only read by the merge below.
|
||||
retention-days: 1
|
||||
|
||||
e2e_report:
|
||||
# Combines the blobs into a single HTML report and blocks PRs on failure.
|
||||
name: Run end-to-end tests
|
||||
needs: playwright
|
||||
if: ${{ always() && !inputs.disable_e2e_tests }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
|
||||
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: scripts/layered.sh --ignore-pnpmfile
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
||||
- name: Download the parts' reports
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: blob-report-*
|
||||
path: all-blob-reports
|
||||
merge-multiple: true
|
||||
- name: Merge into one HTML report
|
||||
run: pnpm exec playwright merge-reports --reporter html ./all-blob-reports
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: html-report
|
||||
path: playwright-report
|
||||
if-no-files-found: error
|
||||
retention-days: 4
|
||||
- name: Fail on a failed part
|
||||
if: ${{ needs.playwright.result != 'success' }}
|
||||
run: exit 1
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
download:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions: {}
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor 🌈
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
|
||||
@@ -221,68 +221,119 @@ See also:
|
||||
|
||||
- [Developing with linked packages](./docs/linking.md)
|
||||
|
||||
#### Element Call as a component
|
||||
#### Element Call as a component (experimental)
|
||||
|
||||
Element Call can be rendered inside another React app instead of an iframe.
|
||||
The host supplies its own Matrix client, and the call stays inside the element
|
||||
it is mounted in.
|
||||
Element Call can also be embedded directly into another React application
|
||||
rather than being loaded in an iframe as a widget. `pnpm build:component`
|
||||
builds it as a library into `component/dist` (the bundle, its stylesheet and
|
||||
type declarations), and
|
||||
|
||||
##### Install
|
||||
```sh
|
||||
pnpm dev:component
|
||||
```
|
||||
|
||||
serves a harness on port 3001 that stands in for such an application: it signs
|
||||
in twice against the development backend and shows two calls side by side, in
|
||||
resizable boxes, with page furniture of its own around them. Use it to see how
|
||||
Element Call behaves when it does not own the page — the size it is given,
|
||||
whether it stays inside its container, and what it says to its host, which is
|
||||
logged along the bottom. The harness is served with the same development
|
||||
certificate as the app, so unless the development CA is trusted, the browser
|
||||
needs a certificate exception for `https://localhost:3001` as well (see the
|
||||
note under [Backend](#backend)). It reads the same `public/config.json` as
|
||||
`pnpm dev` if one exists, and runs with Element Call's defaults otherwise.
|
||||
|
||||
The call lays itself out for the size of the element it is mounted in, not the
|
||||
window: a host that shrinks the container to a corner of its page gets the
|
||||
picture-in-picture layout, just as a host that shrank the whole iframe used to.
|
||||
The breakpoints in the stylesheets the component uses are
|
||||
`@container element-call` queries against its root element for the same reason;
|
||||
for the standalone app the root is the page, so they mean what the media queries
|
||||
they replaced did. (The standalone-only views, such as the home and login pages,
|
||||
still use plain media queries, since the component never shows them.)
|
||||
|
||||
The component's stylesheet is confined to the element it is mounted in: the
|
||||
build rewrites every selector so that it matches only Element Call's root or
|
||||
what is inside it, with `html`, `body` and `:root` standing for that root (see
|
||||
`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles,
|
||||
and Element Call brings its own fonts and design tokens along.
|
||||
|
||||
The component speaks every language the app does. English is bundled in; the
|
||||
other locales are split into chunks the host's bundler loads the first time
|
||||
they are needed. It starts in the browser's language, and follows the host's
|
||||
own language setting through the `language` prop (`supportedLanguages` lists
|
||||
the tags it accepts, and anything else falls back to its base language or to
|
||||
English). The `theme` prop works the same way and takes the same values as the
|
||||
widget's `theme` URL parameter: `light`, `dark`, `light-high-contrast` or
|
||||
`dark-high-contrast`. Both can change while a call is running without
|
||||
disturbing it.
|
||||
|
||||
A host must call and await `initializeElementCall(config)` once before
|
||||
rendering the component: it loads the `Intl` polyfills, applies the
|
||||
deployment-wide `config.json`-style configuration and sets up translations.
|
||||
The component itself takes the host's `client` and the `roomId` to call in, an
|
||||
`intent` saying what the user asked for (which decides whether to show the
|
||||
lobby, ring, and so on), an optional `config` overriding what the intent
|
||||
implies, and an optional `hostBridge` through which Element Call tells the host
|
||||
that the user has joined or hung up, that it wants to stay on screen, and so
|
||||
on. The host makes its own requests (`join`, `hangUp`, `setDeviceMute`) through
|
||||
the handle exposed on `ref`. The full API is documented in the type declarations
|
||||
(`component/index.tsx` and `component/host.ts`).
|
||||
|
||||
A few things differ from the widget on purpose: the component draws a solid
|
||||
background rather than a gradient unless told otherwise, never offers to edit
|
||||
the user's profile (the account is the host's), scopes its keyboard shortcuts to
|
||||
its own root element so that several instances can share a page, and only shows
|
||||
its own post-call and error screens when the host has not supplied a `close()`
|
||||
callback; with one, it asks the host to unmount it instead. The
|
||||
[global JS controls](./docs/controls.md) on `window` are unchanged and remain
|
||||
page-wide, so with several instances on one page they apply to all of them.
|
||||
|
||||
Each release publishes the package to npm, with the same version as the release:
|
||||
|
||||
```sh
|
||||
npm install @element-hq/element-call-component
|
||||
```
|
||||
|
||||
Releases publish the package with the same version as Element Call. For an
|
||||
unreleased commit, depend on this repository directly:
|
||||
To use an unreleased commit, a host can instead install it as a git dependency
|
||||
on the `component` directory of this repository,
|
||||
|
||||
```json
|
||||
"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component"
|
||||
```
|
||||
|
||||
This builds on install, so the host needs pnpm (via Corepack) and must allow
|
||||
the build (`allowBuilds` in its `pnpm-workspace.yaml`).
|
||||
whose `prepare` script runs the build on install. That build needs pnpm (via
|
||||
Corepack) on the host's machine, runs a full `pnpm install` of this repository,
|
||||
since it inherits the `--max-old-space-size` setting of
|
||||
the app build; the host's pnpm also has to allow it to run at all
|
||||
(`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm
|
||||
project of its own for this reason, so pnpm commands run from inside that
|
||||
directory target it rather than the repository; run them from the repository
|
||||
root. The host imports the component from
|
||||
`@element-hq/element-call-component` and the stylesheet from
|
||||
`@element-hq/element-call-component/style.css`, and has to provide `react`,
|
||||
`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
|
||||
`@vector-im/compound-design-tokens` itself, since the bundle leaves them
|
||||
external.
|
||||
Compound's stylesheet are the host's to load too: the component shares
|
||||
the host's copy of Compound, (required for example for tooltips)
|
||||
|
||||
##### Use
|
||||
The component is large, and a host will usually load it lazily, only once a
|
||||
call is shown. Everything a host needs in order to talk about a call before
|
||||
then is also exported
|
||||
from `@element-hq/element-call-component/api` (a small entry point that does not
|
||||
load the component).
|
||||
|
||||
1. Provide the externals yourself: `react`, `react-dom`, `matrix-js-sdk`,
|
||||
`@vector-im/compound-web`, `@vector-im/compound-design-tokens`, and
|
||||
Compound's stylesheet.
|
||||
2. Import `@element-hq/element-call-component/style.css`.
|
||||
3. Await `initializeElementCall(config)` once before rendering.
|
||||
4. Render the component with `client`, `roomId` and an `intent`; optionally
|
||||
pass `config`, `hostBridge`, `language` and `theme` (both can change
|
||||
mid-call). Control it through the `ref` handle (`join`, `hangUp`,
|
||||
`setDeviceMute`).
|
||||
|
||||
Load the component lazily. Types, enums and `configurationForIntent` are
|
||||
available without it from `@element-hq/element-call-component/api`. The full
|
||||
API is in `component/index.tsx`, `component/api.ts` and `component/host.ts`.
|
||||
|
||||
##### How it differs from the widget
|
||||
|
||||
- It lays out for its container, not the window: a small container gets the
|
||||
picture-in-picture layout.
|
||||
- Its styles and keyboard shortcuts are scoped to its root, so the host page is
|
||||
untouched and several instances can share a page. The
|
||||
[global JS controls](./docs/controls.md) stay page-wide.
|
||||
- It draws a solid background by default and never offers profile editing.
|
||||
- If the host bridge implements `close()`, it asks to be unmounted instead of
|
||||
showing its own post-call and error screens.
|
||||
- Sounds, images and the MediaPipe runtime are separate files the host's
|
||||
bundler copies (webpack 5, Vite, Rollup); MediaPipe is fetched only when a
|
||||
background effect is first used.
|
||||
|
||||
##### Develop
|
||||
|
||||
- `pnpm build:component` builds the library into `component/dist`.
|
||||
- `pnpm dev:component` serves a test host on `https://localhost:3001`: two
|
||||
calls side by side in resizable boxes, with host messages logged at the
|
||||
bottom. It uses the development backend, `public/config.json` if present,
|
||||
and needs the same certificate exception as `pnpm dev` (see
|
||||
[Backend](#backend)).
|
||||
- Run pnpm commands from the repository root; `component/` is its own pnpm
|
||||
project.
|
||||
- props and the types they are made of
|
||||
- the handle
|
||||
- the host bridge
|
||||
- `UserIntent`
|
||||
- `HeaderStyle`
|
||||
- `BackgroundStyle` enums
|
||||
- `configurationForIntent`, the defaults each intent implies
|
||||
Import from it wherever a value such as
|
||||
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
|
||||
entry point re-exports all of it too.
|
||||
|
||||
### Backend
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"features": {
|
||||
"feature_element_call_video_rooms": true,
|
||||
"feature_video_rooms": true,
|
||||
"feature_group_calls": true
|
||||
"feature_group_calls": true,
|
||||
"feature_release_announcement": false
|
||||
},
|
||||
"default_federate": true,
|
||||
"default_theme": "light",
|
||||
@@ -39,10 +40,7 @@
|
||||
},
|
||||
"setting_defaults": {
|
||||
"breadcrumbs": true,
|
||||
"feature_group_calls": true,
|
||||
"releaseAnnouncementData": {
|
||||
"room_list_section": true
|
||||
}
|
||||
"feature_group_calls": true
|
||||
},
|
||||
"jitsi": {
|
||||
"preferred_domain": "meet.element.io"
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"features": {
|
||||
"feature_element_call_video_rooms": true,
|
||||
"feature_video_rooms": true,
|
||||
"feature_group_calls": true
|
||||
"feature_group_calls": true,
|
||||
"feature_release_announcement": false
|
||||
},
|
||||
"default_federate": true,
|
||||
"default_theme": "light",
|
||||
@@ -39,10 +40,7 @@
|
||||
},
|
||||
"setting_defaults": {
|
||||
"breadcrumbs": true,
|
||||
"feature_group_calls": true,
|
||||
"releaseAnnouncementData": {
|
||||
"room_list_section": true
|
||||
}
|
||||
"feature_group_calls": true
|
||||
},
|
||||
"jitsi": {
|
||||
"preferred_domain": "meet.element.io"
|
||||
|
||||
+7
-13
@@ -73,7 +73,6 @@ import {
|
||||
} from "../src/UrlParams";
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { AppViewModel } from "../src/state/AppViewModel";
|
||||
import { hostClientSession } from "../src/state/ClientSession";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { BackgroundEffectsProvider } from "../src/tracks/BackgroundEffectsContext";
|
||||
import { Config } from "../src/config/Config";
|
||||
@@ -190,25 +189,20 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
[roomId, intent, stableConfig],
|
||||
);
|
||||
|
||||
// Created in an effect so that the scope it lives in ends when the component
|
||||
// is unmounted (or these options change), rather than keeping its device
|
||||
// observers running for the rest of the page's life. Null until then, which
|
||||
// is one render.
|
||||
const { controlledAudioDevices, callIntent } = params;
|
||||
const [vm, setVm] = useState<AppViewModel | null>(null);
|
||||
useEffect(() => {
|
||||
const scope = new ObservableScope();
|
||||
setVm(
|
||||
new AppViewModel(
|
||||
scope,
|
||||
{ controlledAudioDevices, callIntent },
|
||||
// The host owns the client. We create a clientSession wrapping the host client.
|
||||
hostClientSession(client),
|
||||
// The host owns the session too, so there is nothing to manage here
|
||||
null,
|
||||
),
|
||||
);
|
||||
setVm(new AppViewModel(scope, { controlledAudioDevices, callIntent }));
|
||||
return (): void => {
|
||||
setVm(null);
|
||||
scope.end();
|
||||
};
|
||||
}, [controlledAudioDevices, callIntent, client]);
|
||||
}, [controlledAudioDevices, callIntent]);
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
const rtcSession = useMemo(
|
||||
@@ -256,7 +250,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider session={vm.clientSession}>
|
||||
<ClientProvider client={client}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider
|
||||
effects={vm.backgroundEffects}
|
||||
|
||||
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
|
||||
@@ -19,12 +19,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:a79801206c628a912d57fa6f355d5c5ab9c6c40c2d1b6208d4192678fb6e80c7
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
|
||||
volumes:
|
||||
# The other site opens the embedded package under test in place of the
|
||||
# copy its image bundles (the first site opens the dev server's app.
|
||||
# Built by the Playwright run (see build.setup.ts).
|
||||
- ./embedded/web/dist:/app/widgets/element-call:ro,Z
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:a79801206c628a912d57fa6f355d5c5ab9c6c40c2d1b6208d4192678fb6e80c7
|
||||
|
||||
@@ -34,10 +34,6 @@ translations. The browser is Playwright's, so a fresh clone needs
|
||||
|
||||
## End-to-end
|
||||
|
||||
The Element Web instance `HOST1` opens the app the dev server serves. The instance
|
||||
`HOST2` (the other site) opens the embedded package as a host ships it. This package
|
||||
is built by the `build-embedded` setup project and mounted in `docker-compose-playwright.yml`.
|
||||
|
||||
- `playwright/*.spec.ts` — standalone, on Chromium and Firefox.
|
||||
- `playwright/widget/` — widget mode, where cross-context bugs surface.
|
||||
- `playwright/component/` — the component in a host page, via the harness on port
|
||||
@@ -52,8 +48,6 @@ Test what a user observes: the peer sees the change, it survives a reconnect, it
|
||||
right after a reload. Reuse `playwright/spa-helpers.ts`,
|
||||
`playwright/widget/test-helpers.ts`, `playwright/fixtures/`.
|
||||
|
||||
To mark new release announcements as viewed, add them in `backend/ew.test*.config.json`.
|
||||
|
||||
```sh
|
||||
pnpm test # unit + storybook
|
||||
pnpm backend # Synapse + LiveKit, required for e2e
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"user_menu": "User menu"
|
||||
},
|
||||
"action": {
|
||||
"add_background_image": "Add image",
|
||||
"close": "Close",
|
||||
"copy_link": "Copy link",
|
||||
"edit": "Edit",
|
||||
@@ -99,10 +98,6 @@
|
||||
},
|
||||
"disconnected_banner": "Connectivity to the server has been lost.",
|
||||
"error": {
|
||||
"background_animated": "Animated images cannot be used as a background",
|
||||
"background_not_an_image": "That file is not a supported image",
|
||||
"background_not_kept": "That background could not be saved",
|
||||
"background_undecodable": "That image could not be opened",
|
||||
"call_is_not_supported": "Call is not supported",
|
||||
"call_not_found": "Call not found",
|
||||
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
|
||||
|
||||
+1
-2
@@ -97,7 +97,6 @@
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"eslint-plugin-element-call": "link:eslint",
|
||||
"eslint-plugin-storybook": "^10.3.6",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"fetch-mock": "11.1.5",
|
||||
"i18next": "^26.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
@@ -107,7 +106,7 @@
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
+21
-33
@@ -26,19 +26,6 @@ process.env.NODE_EXTRA_CA_CERTS = join(
|
||||
"backend/dev_tls_local-ca.crt",
|
||||
);
|
||||
|
||||
const desktopChrome = {
|
||||
...devices["Desktop Chrome"],
|
||||
permissions: ["clipboard-write", "clipboard-read", "microphone", "camera"],
|
||||
ignoreHTTPSErrors: true,
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--mute-audio",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
@@ -56,11 +43,11 @@ export default defineConfig({
|
||||
* Reporter to use. See https://playwright.dev/docs/test-reporters
|
||||
*
|
||||
* On CI, `list` prints a line per test as it finishes, which keeps the job
|
||||
* log moving; `github` annotates failures on the PR; the `blob` format is
|
||||
* uploaded by sharded CI runs and combined into a HTML report by the workflow.
|
||||
* log moving; `github` annotates failures on the PR; `html` is uploaded as
|
||||
* an artifact and must never try to open a browser there.
|
||||
*/
|
||||
reporter: process.env.CI
|
||||
? [["list"], ["github"], ["blob"]]
|
||||
? [["list"], ["github"], ["html", { open: "never" }]]
|
||||
: [["list"], ["html"]],
|
||||
/* Stop a broken run early on CI instead of waiting out every retry. */
|
||||
maxFailures: process.env.CI ? 5 : undefined,
|
||||
@@ -76,28 +63,29 @@ export default defineConfig({
|
||||
},
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
// Builds the embedded package into the directory the other site's
|
||||
// Element Web container serves it from (see docker-compose-playwright.yml).
|
||||
name: "build-embedded",
|
||||
testMatch: "**/widget/embedded/build.setup.ts",
|
||||
},
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: ["**/mobile/**", "**/widget/**"],
|
||||
use: desktopChrome,
|
||||
},
|
||||
{
|
||||
// Element Call as a widget in Element Web, on Chromium. This uses its
|
||||
// own project so that it can be sharded on CI.
|
||||
name: "widget",
|
||||
dependencies: ["build-embedded"],
|
||||
testMatch: "**/widget/**/*.@(spec|test).ts",
|
||||
use: desktopChrome,
|
||||
testIgnore: "**/mobile/**",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
permissions: [
|
||||
"clipboard-write",
|
||||
"clipboard-read",
|
||||
"microphone",
|
||||
"camera",
|
||||
],
|
||||
ignoreHTTPSErrors: true,
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--mute-audio",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
dependencies: ["build-embedded"],
|
||||
testIgnore: "**/mobile/**",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
|
||||
@@ -5,15 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers.ts";
|
||||
import {
|
||||
averageColour,
|
||||
cameraColour,
|
||||
type Colour,
|
||||
distance,
|
||||
} from "./utils/colour.ts";
|
||||
|
||||
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
|
||||
// so it rightly offers none of them.
|
||||
@@ -22,13 +16,15 @@ test.skip(
|
||||
"Background effects need WebGL2, which headless Firefox on CI does not have",
|
||||
);
|
||||
|
||||
type Colour = [number, number, number];
|
||||
|
||||
test.describe("background effects", () => {
|
||||
test("pre-join preview shows the chosen effect", async ({ page }) => {
|
||||
test.slow();
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Effect user", "Background effects");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
const camera = await averageColour(preview);
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page
|
||||
@@ -106,847 +102,25 @@ async function drawsPerSecond(page: Page): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("the camera menu's preview", () => {
|
||||
test("shows the camera with the effect in force", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Preview user", "Preview in the menu");
|
||||
const camera = await cameraColour(page.locator("video").first());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const preview = page.getByRole("menu").locator("video");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), camera), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeLessThan(30);
|
||||
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
|
||||
test("shows the call's own camera once joined", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(
|
||||
page,
|
||||
"Call preview user",
|
||||
"Preview in the call's menu",
|
||||
true,
|
||||
);
|
||||
await expect(page.getByTestId("incall_videomute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const camera = await cameraColour(page.locator("video").first());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const preview = page.getByRole("menu").locator("video");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), camera), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeLessThan(30);
|
||||
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("background effects section", () => {
|
||||
test("section stays within the call area at every size", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Sizes user", "Background effect sizes");
|
||||
|
||||
for (const size of [
|
||||
{ width: 1280, height: 800 },
|
||||
{ width: 800, height: 600 },
|
||||
{ width: 600, height: 420 },
|
||||
{ width: 480, height: 360 },
|
||||
{ width: 360, height: 640 },
|
||||
]) {
|
||||
await page.setViewportSize(size);
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const box = (await menu.boundingBox())!;
|
||||
const where = `at ${size.width}x${size.height}`;
|
||||
expect(box.x, where).toBeGreaterThanOrEqual(0);
|
||||
expect(box.y, where).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width, where).toBeLessThanOrEqual(size.width);
|
||||
expect(box.y + box.height, where).toBeLessThanOrEqual(size.height);
|
||||
|
||||
const last = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio")
|
||||
.last();
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
await expect(last, where).toBeInViewport({ ratio: 1 });
|
||||
await last.click();
|
||||
await expect(last, where).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// Back to no effect for the next size, and close.
|
||||
await page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "None" })
|
||||
.click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toBeHidden();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("a pipeline that fails to build", () => {
|
||||
test("offers no effects for the session, and keeps the choice", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Failing user", "Pipeline fails");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
await page.route("**/*.tflite*", async (route) => route.abort());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
await expect(
|
||||
page.getByText("Background effects are not supported on this platform."),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(tile).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
// The camera and microphone carry on without it.
|
||||
expect(distance(await cameraColour(preview), camera)).toBeLessThan(30);
|
||||
await expect(page.getByTestId("incall_mute")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
|
||||
await page.unroute("**/*.tflite*");
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the chosen effect", () => {
|
||||
test("chosen effect survives leaving and rejoining", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Returning user", "Effect remembered");
|
||||
const { camera, picture } = await chooseAndWear(page, "Background 1");
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
await page.getByTestId("incall_leave").click();
|
||||
await expect(page.getByRole("heading")).toContainText(
|
||||
"your call has ended",
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("home_callName").fill("Effect remembered again");
|
||||
await page.getByTestId("home_go").click();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
const preview = page.locator("video").first();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
|
||||
test("second session still shows the existing error", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Two tabs", "Effect in two tabs");
|
||||
await chooseAndWear(page, "Background 1");
|
||||
|
||||
const second = await page.context().newPage();
|
||||
await second.goto("/");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Opened in another tab" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
/** Chooses an effect in the lobby, and waits until the preview wears it. */
|
||||
async function chooseAndWear(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<{ camera: Colour; picture: Colour }> {
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page.getByRole("menuitemradio", { name });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
await page.keyboard.press("Escape");
|
||||
return { camera, picture };
|
||||
}
|
||||
|
||||
test.describe("a background of one's own", () => {
|
||||
test("adds an image from the device", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Adding user", "Own background");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
const red = await redImage(page);
|
||||
|
||||
await addImage(page, red);
|
||||
const mine = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" });
|
||||
await expect(mine).toBeVisible();
|
||||
await expect(mine).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await mine.click();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), RED), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, RED) / 2);
|
||||
});
|
||||
|
||||
test("adding an image makes no upload", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Private user", "Own background kept");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
const red = await redImage(page);
|
||||
const sent: { request: string; body: Buffer | null }[] = [];
|
||||
page.on("request", (request) =>
|
||||
sent.push({
|
||||
request: `${request.method()} ${request.url()}`,
|
||||
body: request.postDataBuffer(),
|
||||
}),
|
||||
);
|
||||
|
||||
await addImage(page, red);
|
||||
await page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" })
|
||||
.click();
|
||||
// Kept, chosen and in force: anything that would send it has had its turn.
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), RED), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, RED) / 2);
|
||||
// The file as given, and the copy kept, which is encoded anew.
|
||||
const samples = [red, await keptBytes(page)].map((bytes) =>
|
||||
bytes.subarray(bytes.length - 64),
|
||||
);
|
||||
const carriesIt = sent.filter(
|
||||
({ request, body }) =>
|
||||
request.includes("/_matrix/media/") ||
|
||||
samples.some((sample) => body?.includes(sample)),
|
||||
);
|
||||
expect(carriesIt.map(({ request }) => request)).toEqual([]);
|
||||
});
|
||||
|
||||
test("added image survives rejoining and is gone after storage is cleared", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Keeping user", "Own background again");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
const red = await redImage(page);
|
||||
await addImage(page, red);
|
||||
const mine = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" });
|
||||
await mine.click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
await expect(mine).toHaveAttribute("aria-checked", "true");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), RED), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, RED) / 2);
|
||||
|
||||
await page.evaluate(
|
||||
async () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(
|
||||
"element-call-background-images",
|
||||
);
|
||||
request.onsuccess = (): void => resolve();
|
||||
request.onerror = (): void => reject(request.error);
|
||||
request.onblocked = (): void => resolve();
|
||||
}),
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("group", { name: "Background effects" }),
|
||||
).toBeVisible();
|
||||
await expect(mine).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await addImage(page, red);
|
||||
await expect(mine).toBeVisible();
|
||||
});
|
||||
|
||||
test("a removed image stays removed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(
|
||||
page,
|
||||
"Removing user",
|
||||
"Own background removed",
|
||||
);
|
||||
await addImage(page, await redImage(page));
|
||||
const mine = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" });
|
||||
await expect(mine).toHaveAttribute("aria-keyshortcuts", "Delete");
|
||||
await mine.focus();
|
||||
await page.keyboard.press("Delete");
|
||||
await expect(mine).toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("group", { name: "Background effects" }),
|
||||
).toBeVisible();
|
||||
await expect(mine).toHaveCount(0);
|
||||
expect(await keptImages(page)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("what can be added", () => {
|
||||
test("refuses an animated image", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Refused user", "Animated refused");
|
||||
await addImage(page, ANIMATED_GIF, {
|
||||
name: "moving.gif",
|
||||
type: "image/gif",
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByText("Animated images cannot be used as a background"),
|
||||
).toBeVisible();
|
||||
const section = page.getByRole("group", { name: "Background effects" });
|
||||
await expect(
|
||||
section.getByRole("menuitemradio", { name: "Background 3" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
section.getByRole("menuitemradio", { name: "None" }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
expect(await keptImages(page)).toEqual([]);
|
||||
});
|
||||
|
||||
test("reduces an oversized image", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Large user", "Oversized reduced");
|
||||
await addImage(page, await pngImage(page, { width: 4000, height: 3000 }));
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
const [kept] = await keptImages(page);
|
||||
expect(kept).toMatchObject({ width: 1920, height: 1440 });
|
||||
});
|
||||
|
||||
test("lays every image on an opaque ground", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Logo user", "Transparent made opaque");
|
||||
await addImage(
|
||||
page,
|
||||
await pngImage(page, { width: 320, height: 180, transparent: true }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" }),
|
||||
).toBeVisible();
|
||||
const [kept] = await keptImages(page);
|
||||
expect(kept.minAlpha).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
/** Two frames of one pixel, red then blue. */
|
||||
const ANIMATED_GIF = Buffer.from(
|
||||
"R0lGODlhAQABAPEAAP8AAAAA/wAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQACgAAACwAAAAAAQABAAACAkQBACH5BAAKAAAALAAAAAABAAEAAAICTAEAOw==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
/** A red PNG of the given size; transparent leaves its right half empty. */
|
||||
async function pngImage(
|
||||
page: Page,
|
||||
size: { width: number; height: number; transparent?: boolean },
|
||||
): Promise<Buffer> {
|
||||
const dataUrl = await page.evaluate(({ width, height, transparent }) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.fillStyle = "rgb(255, 0, 0)";
|
||||
context.fillRect(0, 0, transparent ? width / 2 : width, height);
|
||||
return canvas.toDataURL("image/png");
|
||||
}, size);
|
||||
return Buffer.from(dataUrl.split(",")[1], "base64");
|
||||
}
|
||||
|
||||
/** The images the device keeps, as it keeps them. */
|
||||
async function keptImages(
|
||||
page: Page,
|
||||
): Promise<{ width: number; height: number; minAlpha: number }[]> {
|
||||
return page.evaluate(async () => {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("element-call-background-images");
|
||||
request.onsuccess = (): void => resolve(request.result);
|
||||
request.onerror = (): void => reject(request.error);
|
||||
});
|
||||
if (!db.objectStoreNames.contains("backgrounds")) return [];
|
||||
const kept = await new Promise<{ image: Blob }[]>((resolve, reject) => {
|
||||
const request = db
|
||||
.transaction("backgrounds")
|
||||
.objectStore("backgrounds")
|
||||
.getAll();
|
||||
request.onsuccess = (): void => resolve(request.result);
|
||||
request.onerror = (): void => reject(request.error);
|
||||
});
|
||||
db.close();
|
||||
return Promise.all(
|
||||
kept.map(async ({ image }) => {
|
||||
const bitmap = await createImageBitmap(image);
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const { data } = context.getImageData(
|
||||
0,
|
||||
0,
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
);
|
||||
let minAlpha = 255;
|
||||
for (let i = 3; i < data.length; i += 4)
|
||||
minAlpha = Math.min(minAlpha, data[i]);
|
||||
return { width: bitmap.width, height: bitmap.height, minAlpha };
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The bytes of the one image the device keeps. */
|
||||
async function keptBytes(page: Page): Promise<Buffer> {
|
||||
const base64 = await page.evaluate(
|
||||
async () =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const open = indexedDB.open("element-call-background-images");
|
||||
open.onerror = (): void => reject(open.error);
|
||||
open.onsuccess = (): void => {
|
||||
const all = open.result
|
||||
.transaction("backgrounds")
|
||||
.objectStore("backgrounds")
|
||||
.getAll();
|
||||
all.onerror = (): void => reject(all.error);
|
||||
all.onsuccess = (): void => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (): void =>
|
||||
resolve((reader.result as string).split(",")[1]);
|
||||
reader.readAsDataURL((all.result[0] as { image: Blob }).image);
|
||||
};
|
||||
};
|
||||
}),
|
||||
);
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
const RED: Colour = [255, 0, 0];
|
||||
|
||||
/** A solid red PNG, unlike the camera and the shipped pictures. */
|
||||
async function redImage(page: Page): Promise<Buffer> {
|
||||
const dataUrl = await page.evaluate(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 180;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.fillStyle = "rgb(255, 0, 0)";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL("image/png");
|
||||
});
|
||||
return Buffer.from(dataUrl.split(",")[1], "base64");
|
||||
}
|
||||
|
||||
/** Adds an image from the camera menu's add tile, leaving the menu open. */
|
||||
async function addImage(
|
||||
page: Page,
|
||||
image: Buffer,
|
||||
{ name, type } = { name: "mine.png", type: "image/png" },
|
||||
): Promise<void> {
|
||||
if (!(await page.getByRole("menu").isVisible()))
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("menuitem", { name: "Add image" }).click();
|
||||
await (await chooser).setFiles({ name, mimeType: type, buffer: image });
|
||||
}
|
||||
|
||||
test.describe("joining with a background chosen", () => {
|
||||
test("publishes no frame of the room", async ({ browser }) => {
|
||||
// Two first builds of the pipeline, one of them held back.
|
||||
test.setTimeout(180_000);
|
||||
const hostContext = await browser.newContext();
|
||||
const host = await hostContext.newPage();
|
||||
await recordIncomingFrames(host);
|
||||
await host.goto("/");
|
||||
await SpaHelpers.createCall(host, "Host", "Join with a background", true);
|
||||
await expect(host.getByTestId("incall_videomute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(host);
|
||||
await host.keyboard.press("Escape");
|
||||
|
||||
const guestContext = await browser.newContext();
|
||||
const guest = await guestContext.newPage();
|
||||
await guest.goto(inviteLink);
|
||||
await guest.getByTestId("joincall_displayName").fill("Guest");
|
||||
await guest.getByTestId("joincall_joincall").click();
|
||||
const preview = guest.locator("video").first();
|
||||
await expect(preview).toBeVisible();
|
||||
const camera = await cameraColour(preview);
|
||||
await guest.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = guest.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
// Were the pipeline built again for the call's track, it would take as
|
||||
// long as on the slower route. Routing bypasses the cache, so it waits.
|
||||
let heldBack = 0;
|
||||
await guest.route("**/*.tflite*", async (route) => {
|
||||
heldBack += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
||||
await route.continue();
|
||||
});
|
||||
await guest.keyboard.press("Escape");
|
||||
await guest.getByTestId("lobby_joinCall").click();
|
||||
|
||||
const guestTile = host
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: host.getByTestId("name_tag").getByText("Guest") });
|
||||
await expect(guestTile.locator("video")).toBeVisible({ timeout: 60_000 });
|
||||
// Visible can come before the stream is attached.
|
||||
let trackId: string | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
trackId = await guestTile
|
||||
.locator("video")
|
||||
.evaluate(
|
||||
(video: HTMLVideoElement) =>
|
||||
(video.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id,
|
||||
);
|
||||
return trackId;
|
||||
})
|
||||
.toBeDefined();
|
||||
await expectNoFrameOfTheRoom(host, trackId!, 0, camera, picture);
|
||||
// The join adopts the lobby's pipeline, so the model is never fetched again.
|
||||
expect(heldBack).toBe(0);
|
||||
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the first build", () => {
|
||||
test("survives the camera turned off and on while it runs", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const failures: string[] = [];
|
||||
page.on("console", (m) => {
|
||||
if (m.text().includes("Failed to attach video processor"))
|
||||
failures.push(m.text());
|
||||
});
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Toggling user", "Toggle while building");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
|
||||
const held = await holdBackTheModel(page);
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await page.keyboard.press("Escape");
|
||||
// New preview tracks, each attached while the first is still building.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.getByTestId("incall_videomute").click();
|
||||
await page.getByTestId("incall_videomute").click();
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
expect(failures).toEqual([]);
|
||||
expect(held()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("turning the camera on with a background chosen", () => {
|
||||
test("publishes no frame of the room when the camera started off", async ({
|
||||
browser,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { host, guest, close } = await hostAndGuest(browser);
|
||||
const camera = await cameraColour(guest.locator("video").first());
|
||||
await guest.getByTestId("incall_videomute").click();
|
||||
await guest.getByTestId("lobby_joinCall").click();
|
||||
|
||||
const picture = await chooseBackground(guest);
|
||||
const held = await holdBackTheModel(guest);
|
||||
await guest.getByTestId("incall_videomute").click();
|
||||
|
||||
const trackId = await incomingTrackId(host);
|
||||
await expectNoFrameOfTheRoom(host, trackId, 0, camera, picture);
|
||||
expect(held()).toBeGreaterThan(0);
|
||||
await close();
|
||||
});
|
||||
|
||||
test("publishes no frame of the room when the camera was turned off", async ({
|
||||
browser,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { host, guest, close } = await hostAndGuest(browser);
|
||||
const camera = await cameraColour(guest.locator("video").first());
|
||||
await guest.getByTestId("lobby_joinCall").click();
|
||||
const trackId = await incomingTrackId(host);
|
||||
await expect
|
||||
.poll(async () => (await framesOf(host, trackId)).length, {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await guest.getByTestId("incall_videomute").click();
|
||||
|
||||
const picture = await chooseBackground(guest);
|
||||
const held = await holdBackTheModel(guest);
|
||||
const before = (await framesOf(host, trackId)).length;
|
||||
await guest.getByTestId("incall_videomute").click();
|
||||
|
||||
await expectNoFrameOfTheRoom(host, trackId, before, camera, picture);
|
||||
expect(held()).toBeGreaterThan(0);
|
||||
await close();
|
||||
});
|
||||
});
|
||||
|
||||
/** A host in a call, recording what it receives, and a guest in its lobby. */
|
||||
async function hostAndGuest(browser: Browser): Promise<{
|
||||
host: Page;
|
||||
guest: Page;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const hostContext = await browser.newContext();
|
||||
const host = await hostContext.newPage();
|
||||
await recordIncomingFrames(host);
|
||||
await host.goto("/");
|
||||
await SpaHelpers.createCall(host, "Host", "Camera with a background", true);
|
||||
await expect(host.getByTestId("incall_videomute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(host);
|
||||
await host.keyboard.press("Escape");
|
||||
|
||||
const guestContext = await browser.newContext();
|
||||
const guest = await guestContext.newPage();
|
||||
await guest.goto(inviteLink);
|
||||
await guest.getByTestId("joincall_displayName").fill("Guest");
|
||||
await guest.getByTestId("joincall_joincall").click();
|
||||
await expect(guest.locator("video").first()).toBeVisible();
|
||||
return {
|
||||
host,
|
||||
guest,
|
||||
close: async (): Promise<void> => {
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Chooses the first shipped picture in the call, and answers its colour. */
|
||||
async function chooseBackground(guest: Page): Promise<Colour> {
|
||||
// The lobby has a camera button of its own, so wait for the call's.
|
||||
await expect(guest.getByTestId("incall_leave")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(guest.getByTestId("incall_videomute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await guest.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = guest.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await guest.keyboard.press("Escape");
|
||||
return picture;
|
||||
}
|
||||
|
||||
/** Makes the pipeline's first build as slow as on the slower route. */
|
||||
/** Answers how often it held the model back, which a check should see. */
|
||||
async function holdBackTheModel(page: Page): Promise<() => number> {
|
||||
let held = 0;
|
||||
// Routing bypasses the cache, so it waits.
|
||||
await page.route("**/*.tflite*", async (route) => {
|
||||
held += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||
await route.continue();
|
||||
});
|
||||
return () => held;
|
||||
}
|
||||
|
||||
/** The id of the guest's camera track, as the host receives it. */
|
||||
async function incomingTrackId(host: Page): Promise<string> {
|
||||
const guestTile = host
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: host.getByTestId("name_tag").getByText("Guest") });
|
||||
await expect(guestTile.locator("video")).toBeVisible({ timeout: 60_000 });
|
||||
// Visible can come before the stream is attached.
|
||||
let trackId: string | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
trackId = await guestTile
|
||||
.locator("video")
|
||||
.evaluate(
|
||||
(video: HTMLVideoElement) =>
|
||||
(video.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id,
|
||||
);
|
||||
return trackId;
|
||||
})
|
||||
.toBeDefined();
|
||||
return trackId!;
|
||||
}
|
||||
|
||||
/** Waits for the picture to arrive, then finds no frame of the room since. */
|
||||
async function expectNoFrameOfTheRoom(
|
||||
host: Page,
|
||||
trackId: string,
|
||||
since: number,
|
||||
camera: Colour,
|
||||
picture: Colour,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const frames = (await framesOf(host, trackId)).slice(since);
|
||||
// A black frame, as a track starts, is nearer the picture than the
|
||||
// camera, but shows neither.
|
||||
return frames.some(
|
||||
(f) =>
|
||||
distance(f, picture) <
|
||||
Math.min(distance(f, camera), distance(f, [0, 0, 0])),
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
const frames = (await framesOf(host, trackId)).slice(since);
|
||||
expect(
|
||||
frames.filter((f) => distance(f, camera) < distance(f, picture)),
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the mean colour of every frame of every video track the page
|
||||
* receives, from the moment it arrives: a tile may attach it later than its
|
||||
* first frame.
|
||||
*/
|
||||
async function recordIncomingFrames(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const frames: Record<string, [number, number, number][]> = {};
|
||||
(window as unknown as { receivedFrames: typeof frames }).receivedFrames =
|
||||
frames;
|
||||
const record = (track: MediaStreamTrack): void => {
|
||||
if (track.kind !== "video" || frames[track.id]) return;
|
||||
const seen: [number, number, number][] = (frames[track.id] = []);
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.srcObject = new MediaStream([track]);
|
||||
/** The mean colour of a video's current frame or an image, drawn small. */
|
||||
async function averageColour(element: Locator): Promise<Colour> {
|
||||
return element.evaluate(
|
||||
async (source: HTMLVideoElement | HTMLImageElement) => {
|
||||
if (source instanceof HTMLImageElement) await source.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 16;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true })!;
|
||||
const step = (): void => {
|
||||
context.drawImage(video, 0, 0, 16, 16);
|
||||
const { data } = context.getImageData(0, 0, 16, 16);
|
||||
const sum = [0, 0, 0];
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
|
||||
seen.push(sum.map((v) => v / (data.length / 4)) as Colour);
|
||||
video.requestVideoFrameCallback(step);
|
||||
};
|
||||
video.requestVideoFrameCallback(step);
|
||||
void video.play();
|
||||
};
|
||||
const Connection = window.RTCPeerConnection;
|
||||
window.RTCPeerConnection = class extends Connection {
|
||||
public constructor(...args: ConstructorParameters<typeof Connection>) {
|
||||
super(...args);
|
||||
this.addEventListener("track", (e) => record(e.track));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function framesOf(page: Page, trackId: string): Promise<Colour[]> {
|
||||
return page.evaluate(
|
||||
(id) =>
|
||||
(
|
||||
window as unknown as {
|
||||
receivedFrames: Record<string, [number, number, number][]>;
|
||||
}
|
||||
).receivedFrames[id] ?? [],
|
||||
trackId,
|
||||
canvas.width = canvas.height = 32;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(source, 0, 0, 32, 32);
|
||||
const { data } = context.getImageData(0, 0, 32, 32);
|
||||
const sum = [0, 0, 0];
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
|
||||
const pixels = data.length / 4;
|
||||
return sum.map((s) => s / pixels) as Colour;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function distance(a: Colour, b: Colour): number {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, type Locator, type Page, test } from "@playwright/test";
|
||||
|
||||
import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
|
||||
import { averageColour, cameraColour, expectWearing } from "../utils/colour.ts";
|
||||
|
||||
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
|
||||
// so it rightly offers none of them.
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"Background effects need WebGL2, which headless Firefox on CI does not have",
|
||||
);
|
||||
|
||||
/**
|
||||
* Background effects with Element Call as a component in a host page: the
|
||||
* pipeline's model and WebAssembly have to load from the component's bundle,
|
||||
* and the portalled menu has to be sized against the call, not the window.
|
||||
* Driven from the lobby, which builds the same menu and pipeline.
|
||||
*/
|
||||
|
||||
// Sign-in, crypto setup and sync happen twice before anything shows.
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("puts a picture on the call's preview", async ({ page }) => {
|
||||
const { username, roomId } = await createUserAndRoom("effects");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
await resizeContainer(pane.getByTestId("call-container"), {
|
||||
width: 900,
|
||||
height: 480,
|
||||
});
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
const preview = pane.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
|
||||
const tile = await openEffect(page, pane, "Background 1");
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await tile.click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
await expectWearing(preview, picture, camera);
|
||||
});
|
||||
|
||||
test("keeps every effect reachable in a small container", async ({ page }) => {
|
||||
const { username, roomId } = await createUserAndRoom("effectsreach");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
const container = pane.getByTestId("call-container");
|
||||
await resizeContainer(container, { width: 400, height: 360 });
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const last = await openEffect(page, pane, "Background 2");
|
||||
const list = page.locator("[role='menu'] div[role='none']").first();
|
||||
expect((await list.boundingBox())!.height).toBeLessThanOrEqual(
|
||||
(await container.boundingBox())!.height,
|
||||
);
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
await expect(last).toBeInViewport({ ratio: 1 });
|
||||
await last.click();
|
||||
await expect(last).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
/** Opens a component's camera menu and returns one of its effects. */
|
||||
async function openEffect(
|
||||
page: Page,
|
||||
pane: Locator,
|
||||
name: string,
|
||||
): Promise<Locator> {
|
||||
await pane
|
||||
.getByRole("button", { name: "Camera", exact: true })
|
||||
.click({ timeout: 60_000 });
|
||||
// Portalled out of the pane, so found on the page.
|
||||
return page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name });
|
||||
}
|
||||
@@ -29,10 +29,47 @@ export interface MyFixtures {
|
||||
addUser: (username: string, host: string) => Promise<UserBaseFixture>;
|
||||
}
|
||||
|
||||
// Minimal config.json for the local element-web instance
|
||||
const CONFIG_JSON = {
|
||||
default_server_config: {
|
||||
"m.homeserver": {
|
||||
base_url: "https://synapse.m.localhost",
|
||||
server_name: "synapse.m.localhost",
|
||||
},
|
||||
},
|
||||
|
||||
element_call: {
|
||||
participant_limit: 8,
|
||||
brand: "Element Call",
|
||||
},
|
||||
|
||||
// The default language is set here for test consistency
|
||||
setting_defaults: {
|
||||
language: "en-GB",
|
||||
feature_group_calls: true,
|
||||
},
|
||||
|
||||
// the location tests want a map style url.
|
||||
map_style_url:
|
||||
"https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx",
|
||||
|
||||
features: {
|
||||
// We don't want to go through the feature announcement during the e2e test
|
||||
feature_release_announcement: false,
|
||||
feature_element_call_video_rooms: true,
|
||||
feature_video_rooms: true,
|
||||
feature_group_calls: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const widgetTest = test.extend<MyFixtures>({
|
||||
// allow per-test override: `widgetTest.use({ callType: "dm" })`
|
||||
callType: ["room", { option: true }],
|
||||
asWidget: async ({ browser, callType }, pUse) => {
|
||||
asWidget: async ({ browser, context, callType }, pUse) => {
|
||||
await context.route(`http://localhost:8081/config.json*`, async (route) => {
|
||||
await route.fulfill({ json: CONFIG_JSON });
|
||||
});
|
||||
|
||||
const brooksDisplayName = `brooks_${Date.now()}`;
|
||||
const whistlerDisplayName = `whistler_${Date.now()}`;
|
||||
|
||||
@@ -72,7 +109,15 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
|
||||
|
||||
// Accept the invite
|
||||
await TestHelpers.openRoomInvite(ewPage2, "Welcome Room");
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await expect(
|
||||
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
|
||||
).toBeVisible();
|
||||
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
|
||||
await ewPage2.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
ewPage2
|
||||
@@ -80,7 +125,17 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
.getByRole("heading", { name: "Welcome Room" }),
|
||||
).toBeVisible();
|
||||
} else if (callType === "dm") {
|
||||
await TestHelpers.chooseFromRoomListNewMenu(ewPage1, "Start chat");
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage1,
|
||||
"Introducing Sections",
|
||||
);
|
||||
|
||||
await ewPage1
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
|
||||
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
|
||||
await ewPage1.getByRole("textbox", { name: "Search" }).click();
|
||||
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
|
||||
await ewPage1.getByRole("button", { name: "Go" }).click();
|
||||
@@ -102,7 +157,12 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
).toBeVisible();
|
||||
|
||||
// Accept the DM invite from brooks
|
||||
await TestHelpers.openRoomInvite(ewPage2, brooksDisplayName);
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
|
||||
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
|
||||
}
|
||||
|
||||
@@ -127,14 +187,13 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
* Provide a way to add additional users within a test.
|
||||
* The returned user will be registered on the default homeserver, the name will be made unique by appending a timestamp.
|
||||
*/
|
||||
addUser: async ({ browser }, use, testInfo) => {
|
||||
addUser: async ({ browser }, use) => {
|
||||
await use(
|
||||
async (
|
||||
username: string,
|
||||
host: string = HOST1,
|
||||
): Promise<UserBaseFixture> => {
|
||||
// Tests in parallel workers can reach this in the same millisecond.
|
||||
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
|
||||
const uniqueSuffix = Date.now();
|
||||
const { page, clientHandle, mxId } = await TestHelpers.registerUser(
|
||||
browser,
|
||||
`${username.toLowerCase()}_${uniqueSuffix}`,
|
||||
|
||||
@@ -11,18 +11,25 @@ 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 ({
|
||||
test("a message sent on a data track 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
|
||||
// sender as a member
|
||||
for (const page of pages)
|
||||
await expect(page.getByTestId("member")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(tileOf(receiver, users[0])).toBeVisible();
|
||||
// The receiver listens on the sender's track, published with the join
|
||||
await expect(tileOf(receiver, users[0])).toHaveAttribute(
|
||||
"data-chat",
|
||||
"true",
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
await sender.getByPlaceholder("message").fill("hello from the SDK");
|
||||
await sender.getByRole("button", { name: "Send" }).click();
|
||||
@@ -34,7 +41,9 @@ test("a message sent beside the media reaches the peer, and only the peer", asyn
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
await expect(message).toHaveAttribute("data-topic", "chat");
|
||||
await expect(message).toHaveAttribute("data-name", "chat");
|
||||
// Decrypted with the sender's key, at the key index the stream carried
|
||||
await expect(message).toHaveAttribute("data-encrypted", "true");
|
||||
await expect(message).toHaveAttribute("data-user-id", users[0].userId);
|
||||
// Our own messages are not echoed back
|
||||
await expect(sender.getByTestId("message")).toHaveCount(0);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
export type Colour = [number, number, number];
|
||||
|
||||
/** The camera's colour, once the preview is showing it rather than nothing. */
|
||||
export async function cameraColour(preview: Locator): Promise<Colour> {
|
||||
await expect
|
||||
.poll(async () => Math.max(...(await averageColour(preview))))
|
||||
.toBeGreaterThan(40);
|
||||
return averageColour(preview);
|
||||
}
|
||||
|
||||
/** The mean colour of a video's current frame or an image, drawn small. */
|
||||
export async function averageColour(element: Locator): Promise<Colour> {
|
||||
return element.evaluate(
|
||||
async (source: HTMLVideoElement | HTMLImageElement) => {
|
||||
if (source instanceof HTMLImageElement) await source.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 32;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(source, 0, 0, 32, 32);
|
||||
const { data } = context.getImageData(0, 0, 32, 32);
|
||||
const sum = [0, 0, 0];
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
|
||||
const pixels = data.length / 4;
|
||||
return sum.map((s) => s / pixels) as Colour;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function distance(a: Colour, b: Colour): number {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
}
|
||||
|
||||
/** Waits until a preview shows a picture rather than the camera. */
|
||||
export async function expectWearing(
|
||||
preview: Locator,
|
||||
picture: Colour,
|
||||
camera: Colour,
|
||||
): Promise<void> {
|
||||
// Long enough for the first effect of a session to be built.
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { TestHelpers } from "./test-helpers.ts";
|
||||
import { averageColour, cameraColour, expectWearing } from "../utils/colour.ts";
|
||||
|
||||
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
|
||||
// so it rightly offers none of them.
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"Background effects need WebGL2, which headless Firefox on CI does not have",
|
||||
);
|
||||
|
||||
widgetTest(
|
||||
"puts a picture on the preview inside Element Web",
|
||||
async ({ asWidget }) => {
|
||||
test.slow();
|
||||
const { brooks } = asWidget;
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
|
||||
const frame = brooks.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
const preview = frame.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
|
||||
await frame.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = frame
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 1" });
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await tile.click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
await expectWearing(preview, picture, camera);
|
||||
},
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test as setup } from "@playwright/test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const EMBEDDED_DIST = "embedded/web/dist";
|
||||
|
||||
setup("build the embedded package", () => {
|
||||
setup.setTimeout(10 * 60 * 1000);
|
||||
mkdirSync(EMBEDDED_DIST, { recursive: true });
|
||||
execFileSync("pnpm", ["build:embedded", "--outDir", EMBEDDED_DIST], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, type Route } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../../fixtures/widget-user.ts";
|
||||
import { HOST2, TestHelpers } from "../test-helpers.ts";
|
||||
|
||||
/** Where the other site's Element Web serves the embedded package from */
|
||||
const CONFIG_JSON = "**/widgets/element-call/config.json";
|
||||
|
||||
const HOST_ANSWERS: Record<string, (route: Route) => Promise<void>> = {
|
||||
"a 404": async (route) => route.fulfill({ status: 404 }),
|
||||
"a failed fetch": async (route) => route.abort("failed"),
|
||||
"an HTML page": async (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: "<!doctype html><title>Element</title>",
|
||||
}),
|
||||
};
|
||||
|
||||
widgetTest.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"The widget suite does not run on Firefox, see the other specs here",
|
||||
);
|
||||
|
||||
for (const [answer, respond] of Object.entries(HOST_ANSWERS)) {
|
||||
widgetTest(
|
||||
`reaches the lobby when config.json gets ${answer}`,
|
||||
async ({ addUser }) => {
|
||||
const { page } = await addUser("Brooks", HOST2);
|
||||
await TestHelpers.createRoom("Config Room", page);
|
||||
|
||||
let answered = 0;
|
||||
await page.route(CONFIG_JSON, async (route) => {
|
||||
answered++;
|
||||
await respond(route);
|
||||
});
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(page);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByTestId("lobby_joinCall"),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
expect(answered).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -144,11 +144,7 @@ export class TestHelpers {
|
||||
|
||||
await this.dismissStartupToasts(page);
|
||||
|
||||
// The other site's Element Web opens the embedded package it serves itself
|
||||
// (see docker-compose-playwright.yml), not the dev server's app.
|
||||
if (host !== HOST2) {
|
||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||
}
|
||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||
|
||||
const clientHandle = await page.evaluateHandle(() =>
|
||||
window.mxMatrixClientPeg.get(),
|
||||
@@ -157,40 +153,29 @@ export class TestHelpers {
|
||||
return { page, clientHandle, mxId };
|
||||
}
|
||||
|
||||
// Dismisses the toasts a fresh device gets after sign-in.
|
||||
//
|
||||
// Toasts stack and only the top one can be dismissed, so look
|
||||
// at what is on top each time.
|
||||
// Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
|
||||
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
|
||||
// dismiss (if part of expected toats)
|
||||
public static async dismissStartupToasts(page: Page): Promise<void> {
|
||||
const expectedToasts: {
|
||||
title: string;
|
||||
button: string;
|
||||
confirm?: string;
|
||||
}[] = [
|
||||
const expectedToasts = [
|
||||
{ title: "Failed to load service worker", button: "OK" },
|
||||
{ title: "Verify this device", button: "Later" },
|
||||
{ title: "Back up your chats", button: "Not now" },
|
||||
{
|
||||
title: "Turn on key storage",
|
||||
button: "Dismiss",
|
||||
confirm: "Yes, dismiss",
|
||||
},
|
||||
{ title: "Notifications", button: "Dismiss" },
|
||||
{ title: "Turn on key storage", button: "Dismiss" },
|
||||
{ title: "Element does not support this browser", button: "Dismiss" },
|
||||
];
|
||||
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
const toastTitle = toast.locator(".mx_Toast_title h2");
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
await toast.waitFor({ state: "visible", timeout: 700 });
|
||||
const title = await toastTitle.textContent();
|
||||
if (!title) break;
|
||||
const title = await toast.locator(".mx_Toast_title h2").textContent();
|
||||
|
||||
// Find the matching toast config
|
||||
const toastConfig = expectedToasts.find((t) => title.includes(t.title));
|
||||
const toastConfig = expectedToasts.find((t) =>
|
||||
title?.includes(t.title),
|
||||
);
|
||||
|
||||
if (toastConfig) {
|
||||
// Bounded so that a renamed button leaves the toast in place and the
|
||||
@@ -198,16 +183,6 @@ export class TestHelpers {
|
||||
await toast
|
||||
.getByRole("button", { name: toastConfig.button })
|
||||
.click({ timeout: 5000 });
|
||||
if (toastConfig.confirm) {
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog
|
||||
.getByRole("button", { name: toastConfig.confirm })
|
||||
.click({ timeout: 5000 });
|
||||
await expect(dialog).toBeHidden({ timeout: 5000 });
|
||||
}
|
||||
// The same element shows the next toast a moment after the click, so
|
||||
// reading it straight away acts on the dismissed one a second time
|
||||
await expect(toastTitle).not.toHaveText(title, { timeout: 5000 });
|
||||
} else {
|
||||
// Unknown toast. We don't want to act on unknown toasts
|
||||
break;
|
||||
@@ -219,6 +194,20 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
public static async closeReleaseAnnouncement(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
.getByRole("dialog", { name })
|
||||
.getByRole("button", { name: "OK" })
|
||||
.click({ timeout: 2000 });
|
||||
} catch {
|
||||
// Announcement not shown; nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
public static async expandAllSections(page: Page): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
@@ -234,7 +223,16 @@ export class TestHelpers {
|
||||
page: Page,
|
||||
andInvite: string[] = [],
|
||||
): Promise<void> {
|
||||
await TestHelpers.chooseFromRoomListNewMenu(page, "New Room");
|
||||
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
|
||||
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
|
||||
await page.getByRole("menuitem", { name: "New Room" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.getByRole("textbox", { name: "Name" }).fill(name);
|
||||
await page.getByRole("button", { name: "Create room" }).click();
|
||||
await expect(page.getByText("You created this room.")).toBeVisible({
|
||||
@@ -261,24 +259,6 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the room list's "New" menu and picks the given item.
|
||||
*
|
||||
* Driven by keyboard, as a toast arriving late sits over the button and
|
||||
* swallows clicks.
|
||||
*/
|
||||
public static async chooseFromRoomListNewMenu(
|
||||
page: Page,
|
||||
itemName: string,
|
||||
): Promise<void> {
|
||||
await TestHelpers.dismissStartupToasts(page);
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.press("Enter");
|
||||
await page.getByRole("menuitem", { name: itemName }).press("Enter");
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a room invite using the room name.
|
||||
* Locatest the invite in the room list.
|
||||
@@ -288,7 +268,9 @@ export class TestHelpers {
|
||||
roomName: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await TestHelpers.openRoomInvite(page, roomName);
|
||||
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Accept" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -394,28 +376,6 @@ export class TestHelpers {
|
||||
return page.locator(`[aria-label^="Open room ${roomName}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a pending invite from the room list by its room name.
|
||||
*
|
||||
* Element Web collapses the Invites section every time it appears, so it is
|
||||
* expanded here once the invite has arrived.
|
||||
*/
|
||||
public static async openRoomInvite(
|
||||
page: Page,
|
||||
roomName: string,
|
||||
): Promise<void> {
|
||||
const invitesSection = page.getByRole("button", {
|
||||
name: "Toggle Invites section",
|
||||
});
|
||||
await expect(invitesSection).toBeVisible({ timeout: 10000 });
|
||||
// Toasts sit over the top of the room list, where this header is.
|
||||
await TestHelpers.dismissStartupToasts(page);
|
||||
if ((await invitesSection.getAttribute("aria-expanded")) !== "true") {
|
||||
await invitesSection.click();
|
||||
}
|
||||
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
|
||||
Generated
+26
-48
@@ -8,7 +8,7 @@ overrides:
|
||||
'@livekit/components-core>rxjs': ^7.8.1
|
||||
'@livekit/track-processors>@mediapipe/tasks-vision': ^0.10.18
|
||||
tar: ^7.5.11
|
||||
minimatch: ^11.0.0
|
||||
minimatch: ^10.2.3
|
||||
glob: ^10.5.0
|
||||
qs: ^6.15.2
|
||||
js-yaml: ^4.2.0
|
||||
@@ -160,9 +160,6 @@ importers:
|
||||
eslint-plugin-storybook:
|
||||
specifier: ^10.3.6
|
||||
version: 10.6.2(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
|
||||
fake-indexeddb:
|
||||
specifier: ^6.2.5
|
||||
version: 6.2.5
|
||||
fetch-mock:
|
||||
specifier: 11.1.5
|
||||
version: 11.1.5(node-fetch@2.7.0)
|
||||
@@ -191,8 +188,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a
|
||||
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.20.0
|
||||
@@ -1145,9 +1142,6 @@ packages:
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@isaacs/nonrecursive-glob-match@1.0.3':
|
||||
resolution: {integrity: sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==}
|
||||
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0':
|
||||
resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==}
|
||||
peerDependencies:
|
||||
@@ -3533,8 +3527,8 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -4054,10 +4048,6 @@ packages:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fake-indexeddb@6.2.5:
|
||||
resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -4734,8 +4724,8 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
resolution: {gitHosted: true, integrity: sha512-Ldt/VkCzBtre6m1cn0FqgrzRV0GrgWIoARgQUQO58ef2Edu2ERFCINPzp/psFS5RwoN3RIy9Rm4oPVc1ATkEWA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a}
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
|
||||
version: 45.0.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
@@ -4771,9 +4761,9 @@ packages:
|
||||
minimalistic-crypto-utils@1.0.1:
|
||||
resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==}
|
||||
|
||||
minimatch@11.0.4:
|
||||
resolution: {integrity: sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==}
|
||||
engines: {node: 20 || >=22}
|
||||
minimatch@10.2.6:
|
||||
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
@@ -4946,8 +4936,8 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-retry@9.0.0:
|
||||
resolution: {integrity: sha512-HuEats8gYn/UUYa5uyveyQGv75mQmwdFtuMgnZlS12dj/7bdnJSO6keaTjgcZUhvkANTj5EO3lACUmV3xmgj0Q==}
|
||||
p-retry@8.0.1:
|
||||
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
p-try@2.2.0:
|
||||
@@ -5318,10 +5308,6 @@ packages:
|
||||
randomfill@1.0.4:
|
||||
resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==}
|
||||
|
||||
re2js@2.9.1:
|
||||
resolution: {integrity: sha512-JIT3bEEUZTOR2YS1walNEIfH4cF/cQeZcBUFq7ldqO6lR6g0X5CBq/S8rsa9omzZcsNf8qc36jCkWpKKtncrZg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
react-docgen-typescript@2.4.0:
|
||||
resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
|
||||
peerDependencies:
|
||||
@@ -6909,7 +6895,7 @@ snapshots:
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.3.1
|
||||
minimatch: 11.0.4
|
||||
minimatch: 10.2.6
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -6988,7 +6974,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
minimatch: 11.0.4
|
||||
minimatch: 10.2.6
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7124,8 +7110,6 @@ snapshots:
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
|
||||
'@isaacs/nonrecursive-glob-match@1.0.3': {}
|
||||
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.2.4(@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:
|
||||
glob: 10.5.0
|
||||
@@ -8785,7 +8769,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.69.0
|
||||
'@typescript-eslint/visitor-keys': 8.69.0
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
minimatch: 11.0.4
|
||||
minimatch: 10.2.6
|
||||
semver: 7.8.5
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
@@ -8800,7 +8784,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.72.0
|
||||
'@typescript-eslint/visitor-keys': 8.72.0
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
minimatch: 11.0.4
|
||||
minimatch: 10.2.6
|
||||
semver: 7.8.5
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
@@ -9125,7 +9109,7 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@5.0.12:
|
||||
brace-expansion@5.0.9:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -9670,7 +9654,7 @@ snapshots:
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
levn: 0.4.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 11.0.4
|
||||
minimatch: 10.2.6
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
strip-ansi: 6.0.1
|
||||
@@ -9728,8 +9712,6 @@ snapshots:
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
fake-indexeddb@6.2.5: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
@@ -9790,7 +9772,7 @@ snapshots:
|
||||
|
||||
filelist@1.0.6:
|
||||
dependencies:
|
||||
minimatch: 11.0.4
|
||||
minimatch: 10.2.6
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
@@ -9890,7 +9872,7 @@ snapshots:
|
||||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 11.0.4
|
||||
minimatch: 10.2.6
|
||||
minipass: 7.1.3
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
@@ -10015,7 +9997,7 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
jsonc-parser: 3.3.1
|
||||
magic-string: 1.2.3
|
||||
minimatch: 11.0.4
|
||||
minimatch: 10.2.6
|
||||
ora: 9.4.1
|
||||
react: 19.2.8
|
||||
react-i18next: 17.0.13(i18next@26.4.1(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
|
||||
@@ -10413,7 +10395,7 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
@@ -10423,8 +10405,7 @@ snapshots:
|
||||
loglevel: 1.9.2
|
||||
matrix-events-sdk: 0.0.1
|
||||
matrix-widget-api: 1.20.0
|
||||
p-retry: 9.0.0
|
||||
re2js: 2.9.1
|
||||
p-retry: 8.0.1
|
||||
sdp-transform: 3.0.0
|
||||
unhomoglyph: 1.0.6
|
||||
|
||||
@@ -10459,10 +10440,9 @@ snapshots:
|
||||
|
||||
minimalistic-crypto-utils@1.0.1: {}
|
||||
|
||||
minimatch@11.0.4:
|
||||
minimatch@10.2.6:
|
||||
dependencies:
|
||||
'@isaacs/nonrecursive-glob-match': 1.0.3
|
||||
brace-expansion: 5.0.12
|
||||
brace-expansion: 5.0.9
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -10746,7 +10726,7 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-retry@9.0.0:
|
||||
p-retry@8.0.1:
|
||||
dependencies:
|
||||
is-network-error: 1.3.2
|
||||
|
||||
@@ -11170,8 +11150,6 @@ snapshots:
|
||||
randombytes: 2.1.0
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
re2js@2.9.1: {}
|
||||
|
||||
react-docgen-typescript@2.4.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ overrides:
|
||||
# Security fixes:
|
||||
# - https://github.com/advisories/GHSA-7r86-cg39-jmmj
|
||||
# - https://github.com/advisories/GHSA-23c5-xmqv-rm74
|
||||
"minimatch": "^11.0.0"
|
||||
"minimatch": "^10.2.3"
|
||||
# Security fix: https://github.com/element-hq/element-call/security/dependabot/109
|
||||
"glob": "^10.5.0"
|
||||
# Security fixes:
|
||||
|
||||
@@ -16,11 +16,25 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { EMPTY, filter, map, type Observable, of, switchMap, take } from "rxjs";
|
||||
import {
|
||||
distinct,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
filter,
|
||||
map,
|
||||
mergeMap,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type Behavior,
|
||||
createRTCSlot,
|
||||
type DataTrack,
|
||||
E2eeType,
|
||||
type LocalDataTrack,
|
||||
type LocalRTCMember,
|
||||
ObservableScope,
|
||||
type RemoteRTCMember,
|
||||
@@ -45,7 +59,7 @@ import { getUrlParams } from "../src/UrlParams";
|
||||
import { observeElementSize$ } from "../src/utils/elementSize";
|
||||
import { initializeWidget } from "../src/widget";
|
||||
|
||||
/** The data channel topic this bundle has always used, so that old and new builds talk. */
|
||||
/** The name of our data track: the topic this bundle has always used, so that old and new builds talk. */
|
||||
const TOPIC = "matrixRTC";
|
||||
|
||||
export interface MatrixRTCSdk {
|
||||
@@ -59,9 +73,9 @@ export interface MatrixRTCSdk {
|
||||
localMember$: Behavior<LocalRTCMember | null>;
|
||||
/** The other members of the slot, with the media the participation carries for them. */
|
||||
remoteMembers$: Behavior<RemoteRTCMember[]>;
|
||||
/** What other members sent with `sendData`. */
|
||||
/** What other members sent with `sendData`, on their data track. */
|
||||
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
|
||||
/** Sends to every member on our transport. Rejects while not joined. */
|
||||
/** Sends on our data track, published with the first message. Rejects while not joined. */
|
||||
sendData: (data: unknown) => Promise<void>;
|
||||
sendRoomMessage: (message: string) => Promise<void>;
|
||||
}
|
||||
@@ -100,6 +114,9 @@ export async function createMatrixRTCSdk(
|
||||
id,
|
||||
});
|
||||
let vm: CallViewModel | undefined;
|
||||
let dataTrack: Promise<LocalDataTrack> | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const failed =
|
||||
(what: string) =>
|
||||
(e: unknown): void =>
|
||||
@@ -115,6 +132,8 @@ export async function createMatrixRTCSdk(
|
||||
),
|
||||
);
|
||||
|
||||
const remoteMembers$ = over((p) => p.remoteMembers$, []);
|
||||
|
||||
return {
|
||||
join: (): void => {
|
||||
if (slot.participation$.value !== null) return;
|
||||
@@ -122,6 +141,7 @@ export async function createMatrixRTCSdk(
|
||||
widget.api
|
||||
.setAlwaysOnScreen(true)
|
||||
.catch(failed("Failed to make the widget sticky"));
|
||||
dataTrack = undefined;
|
||||
const participation = slot.join({
|
||||
...rtcParticipationOptions(urlParams),
|
||||
publish: initialPublishRequests(muteStates, mediaDevices, capture),
|
||||
@@ -169,20 +189,40 @@ export async function createMatrixRTCSdk(
|
||||
false,
|
||||
),
|
||||
localMember$: over((p) => p.localMember$, null),
|
||||
remoteMembers$: over((p) => p.remoteMembers$, []),
|
||||
// Attested members only: the SDK has matched the sender
|
||||
data$: slot.participation$.pipe(
|
||||
switchMap((p) => p?.data$ ?? EMPTY),
|
||||
filter(({ topic }) => topic === TOPIC),
|
||||
map(({ member, text }) => ({
|
||||
rtcBackendIdentity: member.rtcBackendIdentity,
|
||||
data: text,
|
||||
})),
|
||||
remoteMembers$,
|
||||
data$: remoteMembers$.pipe(
|
||||
// Each member once, for as long as it stays: a data track is only
|
||||
// subscribed while observed, so it is not dropped on every list change
|
||||
mergeMap((members) => members),
|
||||
distinct(),
|
||||
mergeMap((member) =>
|
||||
member.tracks$.pipe(
|
||||
map((tracks) =>
|
||||
tracks?.find(
|
||||
(t): t is DataTrack => t.kind === "data" && t.name === TOPIC,
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
switchMap((track) => track?.data$ ?? EMPTY),
|
||||
map((payload) => ({
|
||||
rtcBackendIdentity: member.rtcBackendIdentity,
|
||||
data: decoder.decode(payload),
|
||||
})),
|
||||
takeUntil(
|
||||
remoteMembers$.pipe(filter((members) => !members.includes(member))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
sendData: async (data): Promise<void> => {
|
||||
const participation = slot.participation$.value;
|
||||
if (participation === null) throw new Error("Not joined");
|
||||
await participation.sendData(TOPIC, JSON.stringify(data));
|
||||
dataTrack ??= participation.publish({ source: "data", name: TOPIC });
|
||||
const track = await dataTrack.catch((e: unknown) => {
|
||||
dataTrack = undefined;
|
||||
throw e;
|
||||
});
|
||||
await track.send(encoder.encode(JSON.stringify(data)));
|
||||
},
|
||||
sendRoomMessage: async (message): Promise<void> => {
|
||||
await client.sendTextMessage(room.roomId, message);
|
||||
|
||||
+93
-41
@@ -25,7 +25,7 @@ Call's `src/` to get there is listed in [`SdkMigration.md`](./SdkMigration.md).
|
||||
│ │
|
||||
│ public API, all Behaviors: │
|
||||
│ join() leave() state$ │
|
||||
│ localMember$ remoteMembers$ memberCount$ sendData() data$ │
|
||||
│ localMember$ remoteMembers$ memberCount$ publish(data) │
|
||||
│ keyRotationSuppressed$ connectedTransports$ │
|
||||
│ │
|
||||
│ per member RTCMember { id userId deviceId membership$ displayName$ │
|
||||
@@ -48,7 +48,7 @@ Call's `src/` to get there is listed in [`SdkMigration.md`](./SdkMigration.md).
|
||||
│ │ (LocalMembership) │ local.publish, │ Publisher: local tracks, │ │
|
||||
│ │ transport registry │ setPublishing▶│ upstream paused until │ │
|
||||
│ │ (TransportMetadata) │ ◀─ connections$ │ joined │ │
|
||||
│ │ data packet → member │ ◀─ data$ │ LivekitMemberMedia: │ │
|
||||
│ │ │ │ LivekitMemberMedia: │ │
|
||||
│ │ │ │ participant → tracks │ │
|
||||
│ └───────────────────────────┘ └───────────────────────────┘ │
|
||||
│ │
|
||||
@@ -146,12 +146,12 @@ 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 |
|
||||
| participation | an `RTCParticipation`: the span between our `join()` of a slot and our `leave()`. Holds the connection and its `state$`, `localMember$`, `remoteMembers$`, and the connected transports. 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 | |
|
||||
| track | a `Track`: one publication of a member. A `MediaTrack` is audio or video, with `source`, `kind`, `id` and the behaviors on it; a `DataTrack` is a named channel of bytes. The `Local…` variants add the controls over our own, and `send` for data | 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. A `DataPublishRequest` names a data track, and only goes to `publish` | |
|
||||
| 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 |
|
||||
@@ -340,28 +340,6 @@ export interface MatrixRTCClient {
|
||||
* 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;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -452,11 +430,15 @@ export interface LocalRTCMember extends RTCMember {
|
||||
* user closing the picker. One publication per source: publishing a source
|
||||
* again unmutes it.
|
||||
*/
|
||||
publish(request: DataPublishRequest): Promise<LocalDataTrack>;
|
||||
publish(
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||
/** Removes one of our tracks; a screen share takes its audio with it. */
|
||||
unpublish(id: string): Promise<void>;
|
||||
/**
|
||||
* Removes one of our tracks, as `publish` handed it out; a screen share
|
||||
* takes its audio with it. The track's controls and `send` reject from then on.
|
||||
*/
|
||||
unpublish(track: LocalTrack): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -557,8 +539,6 @@ export interface MediaBackend {
|
||||
membership$: Behavior<RTCMembership>,
|
||||
): MemberMediaFields;
|
||||
readonly connections$: Behavior<BackendConnection[]>; // transport, state, resolved; diagnostics
|
||||
sendData(topic: string, text: string): Promise<void>;
|
||||
readonly data$: Observable<DataPacket>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -568,8 +548,7 @@ LiveKit backend only, a last resort of another member's SFU that lets us
|
||||
publish (`findRemotePublishLivekitTransport`, wired in by `RTCParticipation` rather
|
||||
than by the backend interface, since it is an MSC4195 matter), 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
|
||||
`local.connectionState$` into `state$`, the choice of delayed leave timings, and the transport registry, which keys on the raw
|
||||
transport serialised with sorted keys and derives `connectedTransports$` and each
|
||||
`TransportMetadata.resolved$` from `connections$`.
|
||||
|
||||
@@ -613,6 +592,43 @@ 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.
|
||||
|
||||
### Data tracks on LiveKit
|
||||
|
||||
A data track is a track like the media, in `tracks$`, so the backend carries it
|
||||
the way it carries the media: `local.publish` takes a `DataPublishRequest`, and
|
||||
`mediaFor` lists a remote member's data tracks after its media. Its sender is
|
||||
attested by construction: a track only exists on a member. The session never
|
||||
sees a key, a frame or a stream.
|
||||
|
||||
Every data track is one LiveKit data track, whose publication announces it: a
|
||||
receiver sees the track before its first message, and sees it go. LiveKit's
|
||||
data tracks are lossy only, so the LiveKit name says how the payloads travel
|
||||
(`dataTrackName.ts`):
|
||||
|
||||
| Request | LiveKit name | Payloads |
|
||||
| ----------------- | ----------------- | --------------------------------------------------------------- |
|
||||
| `reliable: false` | `lossy:<name>` | frames on the data track: unordered, no retransmits |
|
||||
| `reliable: true` | `reliable:<name>` | one byte stream per `send` on topic `reliable:<name>`, in order |
|
||||
|
||||
A track without a prefix, from a plain LiveKit publisher, is read as lossy. A
|
||||
room takes one byte stream handler per topic, so `ByteStreams.ts` shares it
|
||||
between every reliable track on that topic and filters by sender identity.
|
||||
|
||||
Encryption is LiveKit's. The rooms are built with `encryption` rather than
|
||||
`e2ee`, which encrypts the data channel as well as the media with the same
|
||||
E2EE manager and our key provider. The sender encrypts with its current key
|
||||
and puts the key index and IV on the wire: in each frame's E2EE extension for a
|
||||
lossy track, in each packet's `EncryptedPacket` for a stream. The receiver
|
||||
decrypts with the sender's key at that index. A frame that does not decrypt is
|
||||
dropped. A stream cannot be checked: LiveKit 2.23 sends an encrypted packet
|
||||
without its encryption type, so every received stream reads as unencrypted,
|
||||
and a cleartext stream injected on the way would be accepted as the sender's.
|
||||
|
||||
A local track is published on LiveKit once publishing is allowed, like the
|
||||
media, and again on every new connection to the local transport; `publish`
|
||||
resolves with that first publication. The object a host holds outlives the
|
||||
connections.
|
||||
|
||||
## Media: `tracks$` and `MediaTrack`
|
||||
|
||||
Every member has media. Today, in Element Call, that media _is_ a `livekit-client`
|
||||
@@ -707,14 +723,52 @@ export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
|
||||
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
/** A named channel of bytes a member publishes beside its media. */
|
||||
export interface DataPublishRequest {
|
||||
source: "data";
|
||||
/** Unique per member; what receivers find the track by. */
|
||||
name: string;
|
||||
/** Whether every message arrives, in order. Default true. */
|
||||
reliable?: boolean;
|
||||
}
|
||||
|
||||
/** One data track of a member; it exists only on a member, so its sender is attested. */
|
||||
export interface DataTrack {
|
||||
source: "data";
|
||||
kind: "data";
|
||||
id: string;
|
||||
name: string;
|
||||
reliable: boolean;
|
||||
/** Whether payloads are end-to-end encrypted with the media key. */
|
||||
encrypted: boolean;
|
||||
/** What the member sends from now on, subscribed while observed. */
|
||||
data$: Observable<Uint8Array>;
|
||||
}
|
||||
|
||||
/** Our own data track; its `data$` replays what we sent. */
|
||||
export interface LocalDataTrack extends DataTrack {
|
||||
send(payload: Uint8Array): Promise<void>;
|
||||
}
|
||||
|
||||
export type Track = AudioMediaTrack | VideoMediaTrack | DataTrack;
|
||||
export type LocalTrack =
|
||||
LocalAudioMediaTrack | LocalVideoMediaTrack | LocalDataTrack;
|
||||
|
||||
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>,
|
||||
tracks$: Behavior<readonly (MediaTrack | DataTrack)[] | null>,
|
||||
source: S,
|
||||
): Behavior<TrackOfSource<S> | undefined>;
|
||||
|
||||
/** The member's data track of this name; undefined while there is none. */
|
||||
export function dataTrackByName$(
|
||||
scope: ObservableScope,
|
||||
tracks$: Behavior<readonly (MediaTrack | DataTrack)[] | null>,
|
||||
name: string,
|
||||
): Behavior<DataTrack | undefined>;
|
||||
```
|
||||
|
||||
### Rendering: `attach` and `detach`
|
||||
@@ -822,13 +876,11 @@ public API only.
|
||||
- **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.
|
||||
- **Data before a receiver listens.** Nothing is replayed: a message sent
|
||||
before a receiver has subscribed to the track, which it does once it has
|
||||
seen the track published, does not reach it. Old builds of the legacy
|
||||
widget bundle, which sent bare packets, do not hear new builds or the other
|
||||
way round.
|
||||
- **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.
|
||||
|
||||
+67
-21
@@ -12,12 +12,22 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { combineLatest, type Observable, of, switchMap } from "rxjs";
|
||||
import {
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type AudioMediaTrack,
|
||||
createRTCSlot,
|
||||
dataTrackByName$,
|
||||
E2eeType,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalRTCMember,
|
||||
type LocalVideoMediaTrack,
|
||||
MatrixRTCMode,
|
||||
ObservableScope,
|
||||
type RemoteRTCMember,
|
||||
@@ -82,7 +92,7 @@ async function start(
|
||||
state.kind === "failed" ? `Error: ${state.error.message}` : state.kind;
|
||||
});
|
||||
showMembers(scope, rtcParticipation);
|
||||
showMessages(scope, rtcParticipation);
|
||||
showMessages(rtcParticipation);
|
||||
|
||||
toggle(buttons.microphone, async (enabled) =>
|
||||
setPublished(rtcParticipation, "microphone", enabled),
|
||||
@@ -120,10 +130,11 @@ async function setPublished(
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
const track = rtcParticipation.localMember$.value?.tracks$.value?.find(
|
||||
(t) => t.source === source,
|
||||
(t): t is LocalAudioMediaTrack | LocalVideoMediaTrack =>
|
||||
t.source === source,
|
||||
);
|
||||
if (track && source === "screenShare") {
|
||||
if (!enabled) await rtcParticipation.unpublish(source);
|
||||
if (!enabled) await rtcParticipation.unpublish(track);
|
||||
return enabled;
|
||||
}
|
||||
if (track) return track.setEnabled(enabled);
|
||||
@@ -145,30 +156,63 @@ function toggle(
|
||||
};
|
||||
}
|
||||
|
||||
/** Sends what the form holds on the "chat" topic, and lists what arrives. */
|
||||
function showMessages(
|
||||
scope: ObservableScope,
|
||||
rtcParticipation: RTCParticipation,
|
||||
): void {
|
||||
/**
|
||||
* Publishes our "chat" data track with the join, so that the others hear the
|
||||
* first message, and sends what the form holds on it. What arrives is listed
|
||||
* per member, in `memberTile`.
|
||||
*/
|
||||
function showMessages(rtcParticipation: RTCParticipation): void {
|
||||
const encoder = new TextEncoder();
|
||||
const chat = rtcParticipation.publish({ source: "data", name: "chat" });
|
||||
chat.catch((e: unknown) => {
|
||||
status.textContent = `Error: ${e}`;
|
||||
});
|
||||
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}`;
|
||||
},
|
||||
);
|
||||
chat
|
||||
.then(async (track) => track.send(encoder.encode(text)))
|
||||
.then(
|
||||
() => dataForm.reset(),
|
||||
(e: unknown) => {
|
||||
status.textContent = `Error: ${e}`;
|
||||
},
|
||||
);
|
||||
};
|
||||
rtcParticipation.data$
|
||||
.pipe(scope.bind())
|
||||
.subscribe(({ member, topic, text }) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists what a remote member sends on its "chat" data track, and marks its
|
||||
* tile while it has one.
|
||||
*/
|
||||
function listMessages(
|
||||
scope: ObservableScope,
|
||||
member: RemoteRTCMember,
|
||||
tile: HTMLElement,
|
||||
): void {
|
||||
const decoder = new TextDecoder();
|
||||
const chat$ = dataTrackByName$(scope, member.tracks$, "chat");
|
||||
chat$.pipe(scope.bind()).subscribe((track) => {
|
||||
tile.dataset.chat = String(track !== undefined);
|
||||
});
|
||||
chat$
|
||||
.pipe(
|
||||
switchMap(
|
||||
(track) =>
|
||||
track?.data$.pipe(
|
||||
map((payload) => ({ payload, encrypted: track.encrypted })),
|
||||
) ?? EMPTY,
|
||||
),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe(({ payload, encrypted }) => {
|
||||
const line = messages.appendChild(document.createElement("li"));
|
||||
line.dataset.testid = "message";
|
||||
line.dataset.topic = topic;
|
||||
line.dataset.name = "chat";
|
||||
line.dataset.encrypted = String(encrypted);
|
||||
line.dataset.userId = member.userId;
|
||||
line.textContent = `${member.displayName$.value}: ${text}`;
|
||||
line.textContent = `${member.displayName$.value}: ${decoder.decode(payload)}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,12 +262,14 @@ function memberTile(
|
||||
tile.appendChild(document.createElement("video")),
|
||||
);
|
||||
// Our own microphone would only echo
|
||||
if (!member.local)
|
||||
if (!member.local) {
|
||||
render(
|
||||
scope,
|
||||
trackBySource$(scope, member.tracks$, "microphone"),
|
||||
tile.appendChild(document.createElement("audio")),
|
||||
);
|
||||
listMessages(scope, member, tile);
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -55,7 +55,11 @@ export {
|
||||
export * from "./src/api";
|
||||
export * from "./src/media-api";
|
||||
export * from "./src/media-backend/api";
|
||||
export { trackBySource$, type TrackOfSource } from "./src/utils/tracks";
|
||||
export {
|
||||
dataTrackByName$,
|
||||
trackBySource$,
|
||||
type TrackOfSource,
|
||||
} from "./src/utils/tracks";
|
||||
export { createRTCSlot } from "./src/RTCSlot";
|
||||
export {
|
||||
createLivekitBackend,
|
||||
|
||||
@@ -162,7 +162,6 @@ describe("createLocalMedia", () => {
|
||||
media.release("camera");
|
||||
expect(trackBehind(camera).stop).toHaveBeenCalled();
|
||||
expect(media.tracks$.value).toEqual([microphone]);
|
||||
expect(media.requests.map((r) => r.source)).toEqual(["microphone"]);
|
||||
|
||||
media.stop();
|
||||
expect(trackBehind(microphone).stop).toHaveBeenCalled();
|
||||
|
||||
@@ -107,7 +107,6 @@ export function createLocalMedia(
|
||||
if (source !== "microphone" && source !== "camera") return;
|
||||
capturedTracks.get(source)?.stop();
|
||||
capturedTracks.delete(source);
|
||||
requests.delete(source);
|
||||
publishCapturedTracks();
|
||||
};
|
||||
|
||||
|
||||
@@ -14,11 +14,10 @@ import {
|
||||
type Transport,
|
||||
type UnstableLivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, EMPTY, Subject } from "rxjs";
|
||||
import { BehaviorSubject, EMPTY } from "rxjs";
|
||||
import { describe, expect, it, type Mock, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type DataMessage,
|
||||
type ParticipationState,
|
||||
type RTCMember,
|
||||
type RTCParticipationOptions,
|
||||
@@ -29,13 +28,16 @@ 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 {
|
||||
type LocalDataTrack,
|
||||
type LocalVideoMediaTrack,
|
||||
type MemberMedia,
|
||||
} from "./media-api";
|
||||
import { constant } from "./reactive/Behavior";
|
||||
import { ObservableScope } from "./reactive/ObservableScope";
|
||||
import { createRTCParticipation } from "./RTCParticipation";
|
||||
@@ -103,7 +105,6 @@ interface FakeBackend {
|
||||
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>;
|
||||
@@ -151,7 +152,6 @@ function setup(
|
||||
connectionState$: new BehaviorSubject<MediaConnectionState | Error>(
|
||||
MediaConnectionState.Initialized,
|
||||
),
|
||||
data$: new Subject<DataPacket>(),
|
||||
setPublishing: vi.fn(),
|
||||
delegateDelayedLeave: vi.fn(async () => Promise.resolve()),
|
||||
mediaFor: vi.fn(() => ({
|
||||
@@ -179,8 +179,6 @@ function setup(
|
||||
mediaFor: fake.mediaFor,
|
||||
connections$: constant([]),
|
||||
setAudioOutputDeviceId: vi.fn(),
|
||||
sendData: vi.fn(),
|
||||
data$: fake.data$,
|
||||
};
|
||||
fake.context = context;
|
||||
fake.backend = backend;
|
||||
@@ -414,27 +412,30 @@ describe("createRTCParticipation", () => {
|
||||
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));
|
||||
it("hands publications to the backend, data tracks too", async () => {
|
||||
const { participation, fake, scope } = setup();
|
||||
const chat = { kind: "data", name: "chat" } as LocalDataTrack;
|
||||
const screenShare = {
|
||||
kind: "video",
|
||||
source: "screenShare",
|
||||
} as LocalVideoMediaTrack;
|
||||
|
||||
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" },
|
||||
await participation.publish({ source: "data", name: "chat" });
|
||||
await participation.publish({ source: "camera" });
|
||||
expect(fake.backend.local.publish).toHaveBeenNthCalledWith(1, {
|
||||
source: "data",
|
||||
name: "chat",
|
||||
});
|
||||
expect(fake.backend.local.publish).toHaveBeenNthCalledWith(2, {
|
||||
source: "camera",
|
||||
});
|
||||
await participation.unpublish(chat);
|
||||
await participation.unpublish(screenShare);
|
||||
expect(fake.backend.local.unpublish).toHaveBeenNthCalledWith(1, chat);
|
||||
expect(fake.backend.local.unpublish).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
screenShare,
|
||||
);
|
||||
scope.end();
|
||||
});
|
||||
|
||||
|
||||
+23
-23
@@ -20,7 +20,6 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
from,
|
||||
fromEvent,
|
||||
map,
|
||||
@@ -35,7 +34,6 @@ 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,
|
||||
@@ -45,6 +43,14 @@ import {
|
||||
type RTCSlot,
|
||||
type RTCSlotOptions,
|
||||
} from "./api";
|
||||
import {
|
||||
type DataPublishRequest,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalDataTrack,
|
||||
type LocalTrack,
|
||||
type LocalVideoMediaTrack,
|
||||
type PublishRequest,
|
||||
} from "./media-api";
|
||||
import {
|
||||
type MediaBackend,
|
||||
type MediaBackendContext,
|
||||
@@ -184,7 +190,6 @@ export function createRTCParticipation(
|
||||
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
|
||||
matrixRTCMode,
|
||||
sendNotificationType: options.sendNotificationType,
|
||||
callIntent: options.callIntent,
|
||||
applicationData: options.applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
@@ -246,22 +251,6 @@ export function createRTCParticipation(
|
||||
).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$
|
||||
@@ -286,6 +275,19 @@ export function createRTCParticipation(
|
||||
),
|
||||
);
|
||||
|
||||
function publish(request: DataPublishRequest): Promise<LocalDataTrack>;
|
||||
function publish(
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||
async function publish(
|
||||
request: DataPublishRequest | PublishRequest,
|
||||
): Promise<LocalTrack> {
|
||||
// Narrowed for the overloads; both go to the backend alike
|
||||
return request.source === "data"
|
||||
? backend.local.publish(request)
|
||||
: backend.local.publish(request);
|
||||
}
|
||||
|
||||
const leave = (): void => {
|
||||
if (state$.value.kind === "left") return;
|
||||
localMembership.leave();
|
||||
@@ -300,14 +302,12 @@ export function createRTCParticipation(
|
||||
state$,
|
||||
localMember$,
|
||||
remoteMembers$,
|
||||
publish: async (request) => backend.local.publish(request),
|
||||
unpublish: async (source) => backend.local.unpublish(source),
|
||||
publish,
|
||||
unpublish: async (track) => backend.local.unpublish(track),
|
||||
keyRotationSuppressed$: createKeyRotationSuppressed$(scope, jsSdkSession),
|
||||
connectedTransports$: transports.connected$,
|
||||
setAudioOutputDeviceId: async (deviceId) =>
|
||||
backend.setAudioOutputDeviceId(deviceId),
|
||||
sendData: async (topic, text) => backend.sendData(topic, text),
|
||||
data$,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+16
-38
@@ -12,19 +12,18 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import { type Behavior } from "./reactive/Behavior";
|
||||
import {
|
||||
type DataPublishRequest,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalDataTrack,
|
||||
type LocalMedia,
|
||||
type LocalMemberMedia,
|
||||
type LocalTrack,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type MemberMedia,
|
||||
type PublishRequest,
|
||||
} from "./media-api";
|
||||
@@ -104,14 +103,11 @@ export interface RTCParticipationOptions {
|
||||
* to a notification (ringing, timeouts, declines) is the application's job.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* The kind of call the local member intends to take part in: `"m.audio"`
|
||||
* or `"m.video"`. Carried by the membership as a field of its own.
|
||||
*/
|
||||
callIntent?: RTCCallIntent;
|
||||
/**
|
||||
* Whatever the application wants to say about itself in the membership,
|
||||
* under namespaced keys. Opaque to the SDK.
|
||||
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
|
||||
* (`"audio"` or `"video"`), which the membership format carries as a
|
||||
* field of its own.
|
||||
*/
|
||||
applicationData?: Record<string, unknown>;
|
||||
/** Timings the host has configured; the defaults otherwise. */
|
||||
@@ -180,15 +176,19 @@ export interface RTCParticipation {
|
||||
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.
|
||||
* Publishes a source, or a data track, 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: DataPublishRequest): Promise<LocalDataTrack>;
|
||||
publish(
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||
/** Removes one of our publications; a screen share takes its audio with it. */
|
||||
unpublish(source: MediaSource): Promise<void>;
|
||||
/**
|
||||
* Removes one of our publications; a screen share takes its audio with it.
|
||||
* The track's object is finished: its controls and `send` reject from then on.
|
||||
*/
|
||||
unpublish(track: LocalTrack): Promise<void>;
|
||||
|
||||
/**
|
||||
* Whether the slot has grown large enough that MatrixRTC has stopped
|
||||
@@ -205,28 +205,6 @@ export interface RTCParticipation {
|
||||
* 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -291,7 +269,7 @@ export interface RTCMember {
|
||||
memberId: string;
|
||||
/** Which transport this member is on; undefined when the membership has none. */
|
||||
transport$: Behavior<TransportMetadata | undefined>;
|
||||
/** What the member's application says about itself, e.g. `intent`. */
|
||||
/** What the member's application says about itself, e.g. `m.call.intent`. */
|
||||
applicationData$: Behavior<Record<string, unknown>>;
|
||||
|
||||
displayName$: Behavior<string>;
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("joinJsSdkSession", () => {
|
||||
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transportWithAlias], {
|
||||
notificationType: "ring",
|
||||
callIntent: undefined,
|
||||
applicationData: undefined,
|
||||
applicationData: {},
|
||||
manageMediaKeys: true,
|
||||
delayedLeaveEventRestartMs: 300_000,
|
||||
delayedLeaveEventDelayMs: 3_600_000,
|
||||
@@ -95,14 +95,13 @@ describe("joinJsSdkSession", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the call intent and application data through and sends sticky events in Matrix 2.0 mode", () => {
|
||||
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
|
||||
const s = session();
|
||||
|
||||
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
@@ -111,7 +110,7 @@ describe("joinJsSdkSession", () => {
|
||||
ownMemberMock,
|
||||
[transportWithAlias],
|
||||
expect.objectContaining({
|
||||
callIntent: "m.audio",
|
||||
callIntent: "audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
@@ -125,8 +124,7 @@ describe("joinJsSdkSession", () => {
|
||||
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
@@ -135,7 +133,7 @@ describe("joinJsSdkSession", () => {
|
||||
ownMemberMock,
|
||||
[transport],
|
||||
expect.objectContaining({
|
||||
callIntent: "m.audio",
|
||||
callIntent: "audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
|
||||
@@ -23,7 +23,6 @@ interface Options {
|
||||
encryptMedia: boolean;
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
callIntent?: RTCCallIntent;
|
||||
applicationData?: Record<string, unknown>;
|
||||
timings: SessionTimings;
|
||||
/** The delayed leave timings to use: delegated to the SFU or not. */
|
||||
@@ -44,12 +43,15 @@ export function joinJsSdkSession(
|
||||
encryptMedia,
|
||||
matrixRTCMode,
|
||||
sendNotificationType,
|
||||
callIntent,
|
||||
applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
}: Options,
|
||||
): void {
|
||||
// The membership format carries the intent as a field of its own
|
||||
const { "m.call.intent": intent, ...rest } = applicationData ?? {};
|
||||
const callIntent =
|
||||
typeof intent === "string" ? (intent as RTCCallIntent) : undefined;
|
||||
// Give up on the network as soon as either the sync has been down for the
|
||||
// grace period or the delayed leave has probably been sent, whichever is
|
||||
// sooner
|
||||
@@ -76,7 +78,7 @@ export function joinJsSdkSession(
|
||||
{
|
||||
notificationType: sendNotificationType,
|
||||
callIntent,
|
||||
applicationData,
|
||||
applicationData: rest,
|
||||
manageMediaKeys: encryptMedia,
|
||||
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
|
||||
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
|
||||
|
||||
+65
-7
@@ -69,6 +69,21 @@ export type PublishRequest =
|
||||
capture?: VideoCaptureSettings;
|
||||
};
|
||||
|
||||
/** A named channel of bytes a member publishes beside its media. */
|
||||
export interface DataPublishRequest {
|
||||
source: "data";
|
||||
/**
|
||||
* What receivers find the track by, with `dataTrackByName$`. Unique per
|
||||
* member: publishing a name that is published already rejects.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Whether every message arrives, in order. Default true. Lossy delivery is
|
||||
* for high-rate state where only the newest value matters, such as cursors.
|
||||
*/
|
||||
reliable?: boolean;
|
||||
}
|
||||
|
||||
export type MediaSource =
|
||||
| "microphone"
|
||||
| "camera"
|
||||
@@ -82,7 +97,7 @@ export type MediaStreamStats =
|
||||
| RTCOutboundRtpStreamStats
|
||||
| undefined;
|
||||
|
||||
/** One published track of a member. */
|
||||
/** One published audio or video track of a member. */
|
||||
export interface MediaTrack {
|
||||
source: MediaSource;
|
||||
kind: "audio" | "video";
|
||||
@@ -163,6 +178,48 @@ export interface LocalVideoMediaTrack extends VideoMediaTrack, LocalMediaTrack {
|
||||
setProcessor(processor: VideoProcessor | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One data track of a member. It exists only on a member of the slot, so
|
||||
* whatever arrives on it comes from an attested member.
|
||||
*/
|
||||
export interface DataTrack {
|
||||
source: "data";
|
||||
kind: "data";
|
||||
/** Stable for the life of the track. */
|
||||
id: string;
|
||||
/** As the publisher named it in its `DataPublishRequest`. */
|
||||
name: string;
|
||||
reliable: boolean;
|
||||
/** Whether payloads are end-to-end encrypted with the media key. */
|
||||
encrypted: boolean;
|
||||
/**
|
||||
* What the member sends from now on. The track is subscribed while this is
|
||||
* observed and not at all otherwise; nothing sent earlier is replayed.
|
||||
*/
|
||||
data$: Observable<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Our own data track. Its `data$` replays what we sent: each payload once
|
||||
* `send` has handed it to the transport, so a host renders its own messages
|
||||
* from the same stream as a remote member's.
|
||||
*/
|
||||
export interface LocalDataTrack extends DataTrack {
|
||||
/**
|
||||
* Sends to every member subscribed to this track. Rejects while the local
|
||||
* transport is not connected, and where the payload is larger than the
|
||||
* backend carries.
|
||||
*/
|
||||
send(payload: Uint8Array): Promise<void>;
|
||||
}
|
||||
|
||||
/** Any track of a member: its media and its data. */
|
||||
export type Track = AudioMediaTrack | VideoMediaTrack | DataTrack;
|
||||
export type LocalTrack =
|
||||
| LocalAudioMediaTrack
|
||||
| LocalVideoMediaTrack
|
||||
| LocalDataTrack;
|
||||
|
||||
export type EncryptionError = "MissingKey" | "InvalidKey";
|
||||
|
||||
/**
|
||||
@@ -172,18 +229,19 @@ export type EncryptionError = "MissingKey" | "InvalidKey";
|
||||
*/
|
||||
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.
|
||||
* The member's tracks, media and data, 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 a media track out, `dataTrackByName$` a data track.
|
||||
*/
|
||||
tracks$: Behavior<(AudioMediaTrack | VideoMediaTrack)[] | null>;
|
||||
tracks$: Behavior<Track[] | 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>;
|
||||
tracks$: Behavior<LocalTrack[] | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,11 +19,13 @@ import { type Observable } from "rxjs";
|
||||
|
||||
import { type ResolvedTransport, type RTCMember } from "../api";
|
||||
import {
|
||||
type DataPublishRequest,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalDataTrack,
|
||||
type LocalMedia,
|
||||
type LocalMemberMedia,
|
||||
type LocalTrack,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type MemberMedia,
|
||||
type PublishRequest,
|
||||
} from "../media-api";
|
||||
@@ -106,13 +108,6 @@ export interface BackendConnection {
|
||||
resolved: ResolvedTransport | undefined;
|
||||
}
|
||||
|
||||
export interface DataPacket {
|
||||
/** The backend identity of the sender. */
|
||||
senderId: string;
|
||||
topic: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MediaBackend {
|
||||
/**
|
||||
* The membership transport types this backend serves, ordered decreasingly
|
||||
@@ -158,14 +153,6 @@ export interface MediaBackend {
|
||||
* 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. */
|
||||
@@ -187,14 +174,17 @@ export interface LocalMediaBackend extends LocalMemberMedia {
|
||||
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
|
||||
* Publishes a source, or a data track, 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.
|
||||
* reconnection. Rejects where the device could not be used. A data track
|
||||
* is end-to-end encrypted by the backend wherever the media is; the session
|
||||
* never sees how.
|
||||
*/
|
||||
publish(request: DataPublishRequest): Promise<LocalDataTrack>;
|
||||
publish(
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||
/** Removes one of our publications; a screen share takes its audio with it. */
|
||||
unpublish(source: MediaSource): Promise<void>;
|
||||
unpublish(track: LocalTrack): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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 ByteStreamHandler,
|
||||
type ByteStreamReader,
|
||||
type Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { byteStreams$, type ReceivedBytes } from "./ByteStreams";
|
||||
import { flushPromises } from "../../utils/test";
|
||||
|
||||
function fakeRoom(): {
|
||||
room: LivekitRoom;
|
||||
handlers: Map<string, ByteStreamHandler>;
|
||||
} {
|
||||
const handlers = new Map<string, ByteStreamHandler>();
|
||||
const room = {
|
||||
registerByteStreamHandler: vi.fn(
|
||||
(topic: string, handler: ByteStreamHandler) => {
|
||||
if (handlers.has(topic)) throw new Error("already registered");
|
||||
handlers.set(topic, handler);
|
||||
},
|
||||
),
|
||||
unregisterByteStreamHandler: vi.fn((topic: string) =>
|
||||
handlers.delete(topic),
|
||||
),
|
||||
} as unknown as LivekitRoom;
|
||||
return { room, handlers };
|
||||
}
|
||||
|
||||
function reader(chunks: number[][]): {
|
||||
reader: ByteStreamReader;
|
||||
finish: () => void;
|
||||
} {
|
||||
const done = Promise.withResolvers<Uint8Array[]>();
|
||||
return {
|
||||
reader: {
|
||||
readAll: async () => done.promise,
|
||||
} as unknown as ByteStreamReader,
|
||||
finish: (): void => done.resolve(chunks.map((c) => new Uint8Array(c))),
|
||||
};
|
||||
}
|
||||
|
||||
describe("byteStreams$", () => {
|
||||
it("shares one handler per topic, released with the last observer", () => {
|
||||
const { room, handlers } = fakeRoom();
|
||||
const first = byteStreams$(room, "reliable:chat").subscribe();
|
||||
const second = byteStreams$(room, "reliable:chat").subscribe();
|
||||
expect(room.registerByteStreamHandler).toHaveBeenCalledTimes(1);
|
||||
first.unsubscribe();
|
||||
expect(handlers.has("reliable:chat")).toBe(true);
|
||||
second.unsubscribe();
|
||||
expect(handlers.has("reliable:chat")).toBe(false);
|
||||
});
|
||||
|
||||
it("joins the chunks and keeps the order the streams started in", async () => {
|
||||
const { room, handlers } = fakeRoom();
|
||||
const received: ReceivedBytes[] = [];
|
||||
const subscription = byteStreams$(room, "reliable:chat").subscribe((r) =>
|
||||
received.push(r),
|
||||
);
|
||||
const handler = handlers.get("reliable:chat")!;
|
||||
const one = reader([[1, 2], [3]]);
|
||||
const two = reader([[4]]);
|
||||
handler(one.reader, { identity: "alice" });
|
||||
handler(two.reader, { identity: "bob" });
|
||||
two.finish();
|
||||
await flushPromises();
|
||||
expect(received).toEqual([]);
|
||||
one.finish();
|
||||
await flushPromises();
|
||||
|
||||
expect(received).toEqual([
|
||||
{ identity: "alice", payload: new Uint8Array([1, 2, 3]) },
|
||||
{ identity: "bob", payload: new Uint8Array([4]) },
|
||||
]);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
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 as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type ByteStreamReader,
|
||||
type Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
import { catchError, concatMap, EMPTY, from, Observable, share } from "rxjs";
|
||||
|
||||
/** One byte stream, read to its end. */
|
||||
export interface ReceivedBytes {
|
||||
/** The participant identity of the sender. */
|
||||
identity: string;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
const byTopicByRoom = new WeakMap<
|
||||
LivekitRoom,
|
||||
Map<string, Observable<ReceivedBytes>>
|
||||
>();
|
||||
|
||||
/**
|
||||
* The byte streams a room receives on a topic, in the order they started. A
|
||||
* room takes one handler per topic, so every observer of a topic shares it:
|
||||
* it is registered with the first subscription and released with the last.
|
||||
*/
|
||||
export function byteStreams$(
|
||||
room: LivekitRoom,
|
||||
topic: string,
|
||||
): Observable<ReceivedBytes> {
|
||||
let byTopic = byTopicByRoom.get(room);
|
||||
if (byTopic === undefined) {
|
||||
byTopic = new Map();
|
||||
byTopicByRoom.set(room, byTopic);
|
||||
}
|
||||
let streams$ = byTopic.get(topic);
|
||||
if (streams$ === undefined) {
|
||||
streams$ = new Observable<{ reader: ByteStreamReader; identity: string }>(
|
||||
(subscriber) => {
|
||||
room.registerByteStreamHandler(topic, (reader, { identity }) =>
|
||||
subscriber.next({ reader, identity }),
|
||||
);
|
||||
return (): void => room.unregisterByteStreamHandler(topic);
|
||||
},
|
||||
).pipe(
|
||||
concatMap(({ reader, identity }) =>
|
||||
from(readStream(reader, identity)).pipe(
|
||||
catchError((e: unknown) => {
|
||||
rootLogger
|
||||
.getChild("[ByteStreams]")
|
||||
.warn(`Dropping a stream on ${topic} from ${identity}`, e);
|
||||
return EMPTY;
|
||||
}),
|
||||
),
|
||||
),
|
||||
share(),
|
||||
);
|
||||
byTopic.set(topic, streams$);
|
||||
}
|
||||
return streams$;
|
||||
}
|
||||
|
||||
async function readStream(
|
||||
reader: ByteStreamReader,
|
||||
identity: string,
|
||||
): Promise<ReceivedBytes> {
|
||||
const chunks = await reader.readAll();
|
||||
const payload = new Uint8Array(
|
||||
chunks.reduce((length, chunk) => length + chunk.length, 0),
|
||||
);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
payload.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return { identity, payload };
|
||||
}
|
||||
@@ -77,6 +77,7 @@ describe("LivekitConnectionFactory", () => {
|
||||
resolution: expect.objectContaining({ height: 720 }),
|
||||
});
|
||||
expect(options.audioOutput).toEqual({ deviceId: undefined });
|
||||
expect(options.encryption).toBeUndefined();
|
||||
expect(options.e2ee).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/En
|
||||
import { BehaviorSubject, combineLatest, map } from "rxjs";
|
||||
|
||||
import { type ResolvedTransport } from "../../api";
|
||||
import { watchRemoteDataTracks } from "./RemoteDataTracks";
|
||||
import { type MediaQuality } from "../../config";
|
||||
import { type PublishRequest } from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
@@ -128,12 +129,14 @@ export class LivekitConnectionFactory implements ConnectionFactory {
|
||||
client: this.client,
|
||||
scope,
|
||||
livekitRoomFactory: () =>
|
||||
new LivekitRoom(
|
||||
roomOptions(
|
||||
this.publish,
|
||||
this.audioOutputDeviceId$.value,
|
||||
this.keyProvider,
|
||||
this.mediaQuality,
|
||||
watchRemoteDataTracks(
|
||||
new LivekitRoom(
|
||||
roomOptions(
|
||||
this.publish,
|
||||
this.audioOutputDeviceId$.value,
|
||||
this.keyProvider,
|
||||
this.mediaQuality,
|
||||
),
|
||||
),
|
||||
),
|
||||
ownMembershipIdentity,
|
||||
@@ -169,8 +172,10 @@ function roomOptions(
|
||||
},
|
||||
audioOutput: { deviceId: audioOutputDeviceId },
|
||||
// Every room needs a worker of its own: one gets confused by streams from
|
||||
// several rooms
|
||||
e2ee: keyProvider && { keyProvider, worker: new E2EEWorker() },
|
||||
// several rooms. `encryption` rather than `e2ee`, so that LiveKit encrypts
|
||||
// the data channel too, which carries the reliable data tracks; media is
|
||||
// encrypted the same either way
|
||||
encryption: keyProvider && { keyProvider, worker: new E2EEWorker() },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
type RemoteParticipant,
|
||||
RoomEvent,
|
||||
} from "livekit-client";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Epoch } from "../../reactive/ObservableScope";
|
||||
import { constant } from "../../reactive/Behavior";
|
||||
import { type DataPacket } from "../api";
|
||||
import { type Connection, type ConnectionState } from "./Connection";
|
||||
import { ConnectionMap } from "./ConnectionMap";
|
||||
import { createDataChannel$ } from "./DataChannel";
|
||||
import { testScope } from "../../utils/test";
|
||||
import { exampleTransport } from "./test";
|
||||
|
||||
const encode = (text: string): Uint8Array => new TextEncoder().encode(text);
|
||||
|
||||
const connected: ConnectionState = {
|
||||
state: "authenticated",
|
||||
livekitState: LivekitConnectionState.Connected,
|
||||
};
|
||||
|
||||
function fakeConnection(
|
||||
url: string,
|
||||
state: ConnectionState = connected,
|
||||
): {
|
||||
connection: Connection;
|
||||
room: EventEmitter;
|
||||
publishData: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const room = new EventEmitter();
|
||||
const publishData = vi.fn(async () => Promise.resolve());
|
||||
const connection = {
|
||||
transport: { ...exampleTransport, livekit_service_url: url },
|
||||
serverName: "example.org",
|
||||
state$: constant(state),
|
||||
livekitRoom: Object.assign(room, { localParticipant: { publishData } }),
|
||||
} as unknown as Connection;
|
||||
return { connection, room, publishData };
|
||||
}
|
||||
|
||||
function setup(connections: Connection[]): {
|
||||
received: DataPacket[];
|
||||
connections$: BehaviorSubject<Epoch<ConnectionMap>>;
|
||||
sendData: (topic: string, text: string) => Promise<void>;
|
||||
} {
|
||||
const data = new ConnectionMap();
|
||||
for (const connection of connections) data.add(connection, []);
|
||||
const connections$ = new BehaviorSubject(new Epoch(data));
|
||||
const { data$, sendData } = createDataChannel$({
|
||||
scope: testScope(),
|
||||
connectionMap$: connections$,
|
||||
connection$: constant(connections[0] ?? null),
|
||||
});
|
||||
const received: DataPacket[] = [];
|
||||
data$.subscribe((message) => received.push(message));
|
||||
return { received, connections$, sendData };
|
||||
}
|
||||
|
||||
describe("data$", () => {
|
||||
it("delivers a packet with its sender, topic and text", () => {
|
||||
const { connection, room } = fakeConnection("https://sfu");
|
||||
const { received } = setup([connection]);
|
||||
|
||||
room.emit(
|
||||
RoomEvent.DataReceived,
|
||||
encode("hello"),
|
||||
{ identity: "alice" } as RemoteParticipant,
|
||||
undefined,
|
||||
"chat",
|
||||
);
|
||||
|
||||
expect(received).toEqual([
|
||||
{ senderId: "alice", topic: "chat", text: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops packets from the server", () => {
|
||||
const { connection, room } = fakeConnection("https://sfu");
|
||||
const { received } = setup([connection]);
|
||||
|
||||
room.emit(RoomEvent.DataReceived, encode("server"), undefined);
|
||||
|
||||
expect(received).toEqual([]);
|
||||
});
|
||||
|
||||
it("listens on every connection, including ones added later", () => {
|
||||
const first = fakeConnection("https://sfu-1");
|
||||
const second = fakeConnection("https://sfu-2");
|
||||
const { received, connections$ } = setup([first.connection]);
|
||||
|
||||
const data = new ConnectionMap();
|
||||
data.add(first.connection, []);
|
||||
data.add(second.connection, []);
|
||||
connections$.next(new Epoch(data, 1));
|
||||
|
||||
first.room.emit(RoomEvent.DataReceived, encode("one"), {
|
||||
identity: "alice",
|
||||
} as RemoteParticipant);
|
||||
second.room.emit(RoomEvent.DataReceived, encode("two"), {
|
||||
identity: "bob",
|
||||
} as RemoteParticipant);
|
||||
|
||||
expect(received.map((m) => [m.senderId, m.text])).toEqual([
|
||||
["alice", "one"],
|
||||
["bob", "two"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendData", () => {
|
||||
it("publishes the text as a reliable packet on the local connection", async () => {
|
||||
const { connection, publishData } = fakeConnection("https://sfu");
|
||||
const { sendData } = setup([connection]);
|
||||
|
||||
await sendData("chat", "hello");
|
||||
|
||||
expect(publishData).toHaveBeenCalledWith(encode("hello"), {
|
||||
reliable: true,
|
||||
topic: "chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects while the local transport is not connected", async () => {
|
||||
const { connection, publishData } = fakeConnection("https://sfu", {
|
||||
state: "initialized",
|
||||
});
|
||||
const { sendData } = setup([connection]);
|
||||
|
||||
await expect(sendData("chat", "hello")).rejects.toThrow("not connected");
|
||||
expect(publishData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
type RemoteParticipant,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
share,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import {
|
||||
type Epoch,
|
||||
type ObservableScope,
|
||||
} from "../../reactive/ObservableScope";
|
||||
import { type DataPacket } from "../api";
|
||||
import { type Connection } from "./Connection";
|
||||
import { type ConnectionMap } from "./ConnectionMap";
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
connectionMap$: Behavior<Epoch<ConnectionMap>>;
|
||||
/** The local member's connection, which carries what we send. */
|
||||
connection$: Behavior<Connection | null>;
|
||||
}
|
||||
|
||||
export interface DataChannel {
|
||||
data$: Observable<DataPacket>;
|
||||
sendData: (topic: string, text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A text channel beside the media: LiveKit's reliable data packets, received
|
||||
* on every connection the client holds and sent on the local member's. The
|
||||
* sender is reported by its identity; the packet's own claim of who sent it
|
||||
* never reaches the host.
|
||||
*/
|
||||
export function createDataChannel$({
|
||||
scope,
|
||||
connectionMap$,
|
||||
connection$,
|
||||
}: Props): DataChannel {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const rooms$ = connectionMap$.pipe(
|
||||
map(({ value }) => value.getConnections().map((c) => c.livekitRoom)),
|
||||
distinctUntilChanged(
|
||||
(a, b) => a.length === b.length && a.every((room, i) => room === b[i]),
|
||||
),
|
||||
);
|
||||
|
||||
const data$ = rooms$.pipe(
|
||||
switchMap((rooms) => merge(...rooms.map(receivedOn))),
|
||||
map(({ payload, participant, topic }): DataPacket | null =>
|
||||
// Only the server sends without a participant; a host has no use for it
|
||||
participant === undefined
|
||||
? null
|
||||
: {
|
||||
senderId: participant.identity,
|
||||
topic: topic ?? "",
|
||||
text: decoder.decode(payload),
|
||||
},
|
||||
),
|
||||
filter((packet) => packet !== null),
|
||||
scope.bind(),
|
||||
share(),
|
||||
);
|
||||
|
||||
const sendData = async (topic: string, text: string): Promise<void> => {
|
||||
const connection = connection$.value;
|
||||
const state = connection?.state$.value;
|
||||
if (
|
||||
connection === null ||
|
||||
state?.state !== "authenticated" ||
|
||||
state.livekitState !== LivekitConnectionState.Connected
|
||||
)
|
||||
throw new Error("Cannot send data: the local transport is not connected");
|
||||
await connection.livekitRoom.localParticipant.publishData(
|
||||
encoder.encode(text),
|
||||
{ reliable: true, topic },
|
||||
);
|
||||
};
|
||||
|
||||
return { data$, sendData };
|
||||
}
|
||||
|
||||
interface Received {
|
||||
payload: Uint8Array;
|
||||
participant: RemoteParticipant | undefined;
|
||||
topic: string | undefined;
|
||||
}
|
||||
|
||||
function receivedOn(room: LivekitRoom): Observable<Received> {
|
||||
return fromEvent(
|
||||
room,
|
||||
RoomEvent.DataReceived,
|
||||
(
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant,
|
||||
_kind?: unknown,
|
||||
topic?: string,
|
||||
): Received => ({ payload, participant, topic }),
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
ResolvedConnection,
|
||||
} from "./ConnectionFactory";
|
||||
import { toMediaConnectionState } from "./connectionState";
|
||||
import { createDataChannel$ } from "./DataChannel";
|
||||
import { createKeyProvider } from "./KeyProvider";
|
||||
import {
|
||||
createLivekitMemberMedia,
|
||||
@@ -263,7 +262,12 @@ export function createLivekitBackend(
|
||||
`${member.rtcBackendIdentity}: LiveKit participant missing on ${transportString}`,
|
||||
);
|
||||
});
|
||||
return createLivekitMemberMedia(memberScope, source$, encryptionSystem);
|
||||
return createLivekitMemberMedia(
|
||||
memberScope,
|
||||
source$,
|
||||
encryptionSystem,
|
||||
logger,
|
||||
);
|
||||
};
|
||||
|
||||
const connections$ = scope.behavior<BackendConnection[]>(
|
||||
@@ -276,12 +280,6 @@ export function createLivekitBackend(
|
||||
),
|
||||
);
|
||||
|
||||
const { data$, sendData } = createDataChannel$({
|
||||
scope,
|
||||
connectionMap$,
|
||||
connection$: localMedia_.connection$,
|
||||
});
|
||||
|
||||
return {
|
||||
transportTypes: ["m.livekit", "livekit"],
|
||||
prepareLocalTransport,
|
||||
@@ -290,8 +288,6 @@ export function createLivekitBackend(
|
||||
mediaFor,
|
||||
connections$,
|
||||
setAudioOutputDeviceId: localMedia_.setAudioOutputDeviceId,
|
||||
sendData,
|
||||
data$,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type ByteStreamHandler,
|
||||
type ByteStreamReader,
|
||||
type DataTrackFrame,
|
||||
type RemoteDataTrack,
|
||||
type Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createLivekitDataTrack } from "./LivekitDataTrack";
|
||||
import { flushPromises } from "../../utils/test";
|
||||
|
||||
function remoteTrack(
|
||||
name: string,
|
||||
usesE2ee = true,
|
||||
): {
|
||||
track: RemoteDataTrack;
|
||||
frames: ReadableStreamDefaultController<DataTrackFrame>[];
|
||||
cancel: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const frames: ReadableStreamDefaultController<DataTrackFrame>[] = [];
|
||||
const cancel = vi.fn();
|
||||
const track = {
|
||||
info: { sid: "DT_1", name, usesE2ee },
|
||||
publisherIdentity: "alice",
|
||||
subscribe: vi.fn(
|
||||
() =>
|
||||
new ReadableStream<DataTrackFrame>({
|
||||
start: (controller): void => {
|
||||
frames.push(controller);
|
||||
},
|
||||
cancel,
|
||||
}),
|
||||
),
|
||||
} as unknown as RemoteDataTrack;
|
||||
return { track, frames, cancel };
|
||||
}
|
||||
|
||||
function roomWithStreams(): {
|
||||
room: LivekitRoom;
|
||||
send: (identity: string, bytes: number[]) => void;
|
||||
} {
|
||||
const handlers = new Map<string, ByteStreamHandler>();
|
||||
return {
|
||||
room: {
|
||||
registerByteStreamHandler: (topic: string, h: ByteStreamHandler) =>
|
||||
handlers.set(topic, h),
|
||||
unregisterByteStreamHandler: (topic: string) => handlers.delete(topic),
|
||||
} as unknown as LivekitRoom,
|
||||
send: (identity, bytes): void =>
|
||||
handlers.get("reliable:chat")!(
|
||||
{
|
||||
readAll: async () => Promise.resolve([new Uint8Array(bytes)]),
|
||||
} as unknown as ByteStreamReader,
|
||||
{ identity },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("a remote data track", () => {
|
||||
it("names itself without the LiveKit prefix", () => {
|
||||
const { track } = remoteTrack("reliable:chat", false);
|
||||
expect(
|
||||
createLivekitDataTrack(roomWithStreams().room, track, logger),
|
||||
).toMatchObject({
|
||||
source: "data",
|
||||
kind: "data",
|
||||
id: "DT_1",
|
||||
name: "chat",
|
||||
reliable: true,
|
||||
encrypted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("subscribes a lossy track while observed, once for all observers", async () => {
|
||||
const { track, frames, cancel } = remoteTrack("lossy:cursors");
|
||||
const dataTrack = createLivekitDataTrack(
|
||||
roomWithStreams().room,
|
||||
track,
|
||||
logger,
|
||||
);
|
||||
expect(track.subscribe).not.toHaveBeenCalled();
|
||||
|
||||
const received: Uint8Array[] = [];
|
||||
const first = dataTrack.data$.subscribe((p) => received.push(p));
|
||||
const second = dataTrack.data$.subscribe();
|
||||
expect(track.subscribe).toHaveBeenCalledTimes(1);
|
||||
frames[0].enqueue({ payload: new Uint8Array([7]) });
|
||||
await flushPromises();
|
||||
expect(received).toEqual([new Uint8Array([7])]);
|
||||
|
||||
first.unsubscribe();
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
second.unsubscribe();
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes a reliable track's streams from its publisher only", async () => {
|
||||
const { room, send } = roomWithStreams();
|
||||
const dataTrack = createLivekitDataTrack(
|
||||
room,
|
||||
remoteTrack("reliable:chat").track,
|
||||
logger,
|
||||
);
|
||||
const received: Uint8Array[] = [];
|
||||
const subscription = dataTrack.data$.subscribe((p) => received.push(p));
|
||||
|
||||
send("bob", [1]);
|
||||
send("alice", [2]);
|
||||
await flushPromises();
|
||||
|
||||
// Bob's is not this track's
|
||||
expect(received).toEqual([new Uint8Array([2])]);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
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 RemoteDataTrack, type Room as LivekitRoom } from "livekit-client";
|
||||
import { filter, map, Observable, share } from "rxjs";
|
||||
|
||||
import { type DataTrack } from "../../media-api";
|
||||
import { byteStreams$ } from "./ByteStreams";
|
||||
import { fromLivekitDataTrackName } from "./dataTrackName";
|
||||
|
||||
/**
|
||||
* A remote member's data track. Decryption is LiveKit's: its E2EE manager
|
||||
* reads the key index and IV off each frame or stream and decrypts with the
|
||||
* sender's key, so nothing here sees a key. A frame of an encrypted track that
|
||||
* does not decrypt is dropped by LiveKit. A stream is not checked: LiveKit
|
||||
* 2.23 sends an encrypted packet without its encryption type, so a received
|
||||
* stream reads as unencrypted either way, and a cleartext one would pass.
|
||||
*/
|
||||
export function createLivekitDataTrack(
|
||||
room: LivekitRoom,
|
||||
track: RemoteDataTrack,
|
||||
logger: Logger,
|
||||
): DataTrack {
|
||||
const { name, reliable } = fromLivekitDataTrackName(track.info.name);
|
||||
const encrypted = track.info.usesE2ee;
|
||||
const data$ = reliable
|
||||
? byteStreams$(room, track.info.name).pipe(
|
||||
filter(({ identity }) => identity === track.publisherIdentity),
|
||||
map(({ payload }) => payload),
|
||||
)
|
||||
: framesOf(track, logger);
|
||||
return {
|
||||
source: "data",
|
||||
kind: "data",
|
||||
id: track.info.sid,
|
||||
name,
|
||||
reliable,
|
||||
encrypted,
|
||||
data$,
|
||||
};
|
||||
}
|
||||
|
||||
/** The track's frames, subscribed at the SFU while observed. */
|
||||
function framesOf(
|
||||
track: RemoteDataTrack,
|
||||
logger: Logger,
|
||||
): Observable<Uint8Array> {
|
||||
return new Observable<Uint8Array>((subscriber) => {
|
||||
const reader = track.subscribe().getReader();
|
||||
let cancelled = false;
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
subscriber.next(value.payload);
|
||||
}
|
||||
} catch (e) {
|
||||
// A failed subscription ends the stream; a host's pipeline survives it
|
||||
if (!cancelled) logger.warn(`Data track ${track.info.name} ended`, e);
|
||||
}
|
||||
subscriber.complete();
|
||||
})();
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
reader.cancel().catch(() => {});
|
||||
};
|
||||
}).pipe(share());
|
||||
}
|
||||
@@ -225,51 +225,6 @@ describe("createLivekitMediaTrack", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// LiveKit's media stream track is the processed one while a processor runs,
|
||||
// and that one names no device; the camera behind it does
|
||||
it.each([
|
||||
{ deviceId: "cam-1", switches: false },
|
||||
{ deviceId: "cam-2", switches: true },
|
||||
])(
|
||||
"leaves a processed camera alone unless its device changes: $deviceId",
|
||||
async ({ deviceId, switches }) => {
|
||||
vi.stubGlobal("MediaStreamTrack", class {});
|
||||
onTestFinished(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
const camera = Object.assign(Object.create(LocalVideoTrack.prototype), {
|
||||
source: Track.Source.Camera,
|
||||
_mediaStreamTrack: {
|
||||
readyState: "live",
|
||||
label: "",
|
||||
getSettings: () => ({ deviceId: "cam-1" }),
|
||||
},
|
||||
// What LiveKit shows as the track, with no device of its own
|
||||
processor: {
|
||||
processedTrack: { label: "", getSettings: () => ({}) },
|
||||
},
|
||||
}) as LocalVideoTrack;
|
||||
const switchActiveDevice = vi.fn().mockResolvedValue(true);
|
||||
const { participant, publication, room } = fakes({
|
||||
track: camera as unknown as FakeTrack,
|
||||
});
|
||||
const track = createLocalLivekitMediaTrack(
|
||||
scope,
|
||||
Object.assign(participant, {
|
||||
isLocal: true,
|
||||
}) as unknown as LocalParticipant,
|
||||
publication as unknown as TrackPublication,
|
||||
Object.assign(room, { switchActiveDevice }),
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
await track.setDevice(deviceId);
|
||||
expect(switchActiveDevice.mock.calls).toEqual(
|
||||
switches ? [["videoinput", deviceId, false]] : [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// LiveKit stops a camera that is turned off; the processor is built when it
|
||||
// is turned on again
|
||||
it.each([
|
||||
|
||||
@@ -199,13 +199,11 @@ export function createLocalLivekitMediaTrack(
|
||||
throw new MatrixRTCError(`A ${base.source} track has no device`);
|
||||
// LiveKit decides whether to restart by the constraints it keeps, which
|
||||
// a browser that does not echo the device id back (Firefox) leaves
|
||||
// without one; a track already on the device is left alone here. Asked
|
||||
// of the camera track itself: with a processor on, LiveKit's media
|
||||
// stream track is the processed one, which names no device
|
||||
// without one; a track already on the device is left alone here
|
||||
const current = publication.track;
|
||||
if (
|
||||
current instanceof LocalTrack &&
|
||||
(await current.getDeviceId(false)) === deviceId
|
||||
current.mediaStreamTrack.getSettings().deviceId === deviceId
|
||||
)
|
||||
return;
|
||||
await room.switchActiveDevice(
|
||||
|
||||
@@ -6,9 +6,12 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
ParticipantEvent,
|
||||
type RemoteDataTrack,
|
||||
type RemoteParticipant,
|
||||
RoomEvent,
|
||||
type Room as LivekitRoom,
|
||||
Track,
|
||||
type TrackPublication,
|
||||
@@ -27,6 +30,7 @@ describe("createLivekitMemberMedia", () => {
|
||||
testScope(),
|
||||
constant({ participants: [participant], room }),
|
||||
{ kind: E2eeType.NONE },
|
||||
logger,
|
||||
);
|
||||
expect(media.tracks$.value).toEqual([]);
|
||||
|
||||
@@ -54,6 +58,7 @@ describe("createLivekitMemberMedia", () => {
|
||||
testScope(),
|
||||
constant({ participants: [participant], room }),
|
||||
{ kind: E2eeType.NONE },
|
||||
logger,
|
||||
);
|
||||
emitter.emit(
|
||||
ParticipantEvent.TrackPublished,
|
||||
@@ -85,6 +90,44 @@ function publish(
|
||||
return publication;
|
||||
}
|
||||
|
||||
describe("a member's data tracks", () => {
|
||||
it("follow the room's data track events, after the media", () => {
|
||||
const { participant, publications, room, emitter } = fakes();
|
||||
const media = createLivekitMemberMedia(
|
||||
testScope(),
|
||||
constant({ participants: [participant], room }),
|
||||
{ kind: E2eeType.NONE },
|
||||
logger,
|
||||
);
|
||||
const camera = publish(publications, "TR_1", Track.Source.Camera);
|
||||
emitter.emit(ParticipantEvent.TrackPublished, camera);
|
||||
|
||||
const chat = {
|
||||
info: { sid: "DT_1", name: "reliable:chat", usesE2ee: true },
|
||||
publisherIdentity: participant.identity,
|
||||
} as unknown as RemoteDataTrack;
|
||||
(room as unknown as EventEmitter).emit(RoomEvent.DataTrackPublished, chat);
|
||||
const [video, data] = media.tracks$.value!;
|
||||
expect(video.source).toBe("camera");
|
||||
expect(data).toMatchObject({
|
||||
kind: "data",
|
||||
name: "chat",
|
||||
reliable: true,
|
||||
encrypted: true,
|
||||
});
|
||||
|
||||
// An unrelated media event keeps the same object
|
||||
emitter.emit(ParticipantEvent.TrackMuted, camera);
|
||||
expect(media.tracks$.value![1]).toBe(data);
|
||||
|
||||
(room as unknown as EventEmitter).emit(
|
||||
RoomEvent.DataTrackUnpublished,
|
||||
"DT_1",
|
||||
);
|
||||
expect(media.tracks$.value).toEqual([video]);
|
||||
});
|
||||
});
|
||||
|
||||
function fakes(): {
|
||||
participant: RemoteParticipant;
|
||||
publications: Map<string, TrackPublication>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
observeParticipantMedia,
|
||||
roomEventSelector,
|
||||
} from "@livekit/components-core";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
type Participant,
|
||||
@@ -31,11 +32,14 @@ import { generateItems } from "../../reactive/observable";
|
||||
import { E2eeType, type EncryptionSystem } from "../../encryption";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import {
|
||||
type DataTrack,
|
||||
type EncryptionError,
|
||||
type LocalMemberMedia,
|
||||
type MediaSource,
|
||||
type MemberMedia,
|
||||
} from "../../media-api";
|
||||
import { createLivekitDataTrack } from "./LivekitDataTrack";
|
||||
import { remoteDataTracks$ } from "./RemoteDataTracks";
|
||||
import {
|
||||
createLivekitMediaTrack,
|
||||
createLocalLivekitMediaTrack,
|
||||
@@ -70,13 +74,21 @@ export function createLivekitMemberMedia(
|
||||
scope: ObservableScope,
|
||||
source$: Behavior<ParticipantSource>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
logger: Logger,
|
||||
): MemberMedia {
|
||||
const media$ = memberTracks$(
|
||||
scope,
|
||||
source$,
|
||||
(trackScope, participant, publication, room) =>
|
||||
createLivekitMediaTrack(trackScope, participant, publication, room),
|
||||
);
|
||||
const data$ = memberDataTracks$(scope, source$, logger);
|
||||
return {
|
||||
tracks$: memberTracks$(
|
||||
scope,
|
||||
source$,
|
||||
(trackScope, participant, publication, room) =>
|
||||
createLivekitMediaTrack(trackScope, participant, publication, room),
|
||||
// The data tracks after the media, both null while there is no participant
|
||||
tracks$: scope.behavior(
|
||||
combineLatest([media$, data$], (media, data) =>
|
||||
media === null ? null : [...media, ...data],
|
||||
),
|
||||
),
|
||||
encryptionError$: encryptionErrors$(source$, encryptionSystem),
|
||||
};
|
||||
@@ -145,6 +157,40 @@ function memberTracks$<P extends Participant, T>(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One data track per LiveKit data track of the member's participants, each
|
||||
* living as long as it is published.
|
||||
*/
|
||||
function memberDataTracks$(
|
||||
scope: ObservableScope,
|
||||
source$: Behavior<ParticipantSource>,
|
||||
logger: Logger,
|
||||
): Behavior<DataTrack[]> {
|
||||
return scope.behavior(
|
||||
source$.pipe(
|
||||
switchMap((source) => {
|
||||
if (source === null) return of([]);
|
||||
const { participants, room } = source;
|
||||
const identities = new Set(participants.map((p) => p.identity));
|
||||
return remoteDataTracks$(room).pipe(
|
||||
map((tracks) =>
|
||||
tracks.filter((t) => identities.has(t.publisherIdentity)),
|
||||
),
|
||||
generateItems(
|
||||
`${[...identities].join(",")} dataTracks$`,
|
||||
function* (tracks) {
|
||||
for (const track of tracks)
|
||||
yield { keys: [track.info.sid], data: track };
|
||||
},
|
||||
(_trackScope, track$) =>
|
||||
createLivekitDataTrack(room, track$.value, logger),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function encryptionErrors$(
|
||||
source$: Behavior<ParticipantSource>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
|
||||
@@ -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 {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
type LocalDataTrack as LivekitLocalDataTrack,
|
||||
type LocalParticipant,
|
||||
} from "livekit-client";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
first,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { type DataPublishRequest, type LocalDataTrack } from "../../media-api";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import { ObservableScope } from "../../reactive/ObservableScope";
|
||||
import { type Connection } from "./Connection";
|
||||
import { toLivekitDataTrackName } from "./dataTrackName";
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
/** The connection the local member publishes on. */
|
||||
connection$: Behavior<Connection | null>;
|
||||
/** Whether anything may leave this device: joined and the homeserver reachable. */
|
||||
publishing$: Observable<boolean>;
|
||||
/** Whether the rooms carry a key provider, so LiveKit encrypts what we send. */
|
||||
encrypted: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface LocalDataTracks {
|
||||
tracks$: Behavior<LocalDataTrack[]>;
|
||||
/**
|
||||
* Resolves once the track is published on LiveKit, which waits for the
|
||||
* join. Rejects where a track of that name is published already, or where
|
||||
* LiveKit refuses it.
|
||||
*/
|
||||
publish(request: DataPublishRequest): Promise<LocalDataTrack>;
|
||||
unpublish(track: LocalDataTrack): void;
|
||||
}
|
||||
|
||||
interface Published {
|
||||
track: LivekitLocalDataTrack;
|
||||
participant: LocalParticipant;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
track: LocalDataTrack;
|
||||
finish: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local member's data tracks. Each is one LiveKit data track, published
|
||||
* on every connection to the local transport while publishing is allowed, so
|
||||
* a track requested before the join goes out once we are joined. The object a
|
||||
* host holds outlives the connections. Encryption is LiveKit's: its E2EE
|
||||
* manager encrypts with our current key and writes the key index and IV into
|
||||
* each frame, or each stream for a reliable track.
|
||||
*/
|
||||
export function createLocalDataTracks({
|
||||
scope,
|
||||
connection$,
|
||||
publishing$,
|
||||
encrypted,
|
||||
logger,
|
||||
}: Props): LocalDataTracks {
|
||||
const entries = new Map<string, Entry>();
|
||||
const tracks$ = new BehaviorSubject<LocalDataTrack[]>([]);
|
||||
const update = (): void =>
|
||||
tracks$.next([...entries.values()].map(({ track }) => track));
|
||||
scope.onEnd(() => {
|
||||
for (const entry of entries.values()) entry.finish();
|
||||
});
|
||||
|
||||
// Where to publish: the local connection once it has connected, while
|
||||
// publishing is allowed
|
||||
const target$ = scope.behavior(
|
||||
combineLatest([connection$, publishing$]).pipe(
|
||||
switchMap(([connection, publishing]) =>
|
||||
connection === null || !publishing
|
||||
? of(null)
|
||||
: connection.state$.pipe(
|
||||
first(
|
||||
(s) =>
|
||||
s.state === "authenticated" &&
|
||||
s.livekitState === LivekitConnectionState.Connected,
|
||||
),
|
||||
map(() => connection),
|
||||
startWith(null),
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
);
|
||||
|
||||
const publish = async ({
|
||||
name,
|
||||
reliable = true,
|
||||
}: DataPublishRequest): Promise<LocalDataTrack> => {
|
||||
if (entries.has(name))
|
||||
throw new Error(`A data track named ${name} is published already`);
|
||||
const firstPublished = Promise.withResolvers<void>();
|
||||
const livekitName = toLivekitDataTrackName(name, reliable);
|
||||
const trackScope = new ObservableScope();
|
||||
const published$ = new BehaviorSubject<Published | null>(null);
|
||||
const sent$ = new Subject<Uint8Array>();
|
||||
let finished = false;
|
||||
|
||||
trackScope.reconcile(target$, async (connection) => {
|
||||
if (connection === null) return;
|
||||
const participant = connection.livekitRoom.localParticipant;
|
||||
let track: LivekitLocalDataTrack;
|
||||
try {
|
||||
track = await participant.publishDataTrack({ name: livekitName });
|
||||
} catch (e) {
|
||||
logger.error(`Failed to publish the data track ${name}`, e);
|
||||
firstPublished.reject(e);
|
||||
return;
|
||||
}
|
||||
published$.next({ track, participant });
|
||||
firstPublished.resolve();
|
||||
return async (): Promise<void> => {
|
||||
published$.next(null);
|
||||
await track.unpublish().catch((e: unknown) => {
|
||||
logger.warn(`Failed to unpublish the data track ${name}`, e);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const track: LocalDataTrack = {
|
||||
source: "data",
|
||||
kind: "data",
|
||||
id: `data:${name}`,
|
||||
name,
|
||||
reliable,
|
||||
encrypted,
|
||||
data$: sent$.asObservable(),
|
||||
send: async (payload): Promise<void> => {
|
||||
if (finished) throw new Error(`The data track ${name} is unpublished`);
|
||||
const published = published$.value;
|
||||
if (published === null)
|
||||
throw new Error(
|
||||
`Cannot send on ${name}: not published on the transport yet`,
|
||||
);
|
||||
if (reliable)
|
||||
await published.participant.sendBytes(payload, {
|
||||
topic: livekitName,
|
||||
});
|
||||
else
|
||||
await published.track.tryPush({ payload: new Uint8Array(payload) });
|
||||
sent$.next(payload);
|
||||
},
|
||||
};
|
||||
|
||||
entries.set(name, {
|
||||
track,
|
||||
finish: (): void => {
|
||||
finished = true;
|
||||
trackScope.end();
|
||||
sent$.complete();
|
||||
},
|
||||
});
|
||||
update();
|
||||
try {
|
||||
await firstPublished.promise;
|
||||
} catch (e) {
|
||||
unpublish(track);
|
||||
throw e;
|
||||
}
|
||||
return track;
|
||||
};
|
||||
|
||||
const unpublish = (track: LocalDataTrack): void => {
|
||||
const entry = entries.get(track.name);
|
||||
if (entry?.track !== track) return;
|
||||
entries.delete(track.name);
|
||||
entry.finish();
|
||||
update();
|
||||
};
|
||||
|
||||
return { tracks$: scope.behavior(tracks$), publish, unpublish };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
ConnectionState as LivekitConnectionState,
|
||||
type LocalDataTrack as LivekitLocalDataTrack,
|
||||
type LocalParticipant,
|
||||
type LocalTrack,
|
||||
type LocalTrackPublication,
|
||||
@@ -28,6 +29,10 @@ import { type Connection, type ConnectionState } from "./Connection";
|
||||
import { ConnectionMap } from "./ConnectionMap";
|
||||
import { createLivekitLocalMedia } from "./LocalMedia";
|
||||
import { exampleTransport } from "./test";
|
||||
import {
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalDataTrack,
|
||||
} from "../../media-api";
|
||||
|
||||
const localTransport: TransportLocator = {
|
||||
transport: exampleTransport,
|
||||
@@ -270,9 +275,8 @@ describe("createLivekitLocalMedia", () => {
|
||||
media.setPublishing(true);
|
||||
await flushPromises();
|
||||
|
||||
await expect(media.tracks$.value![0].setEnabled(false)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
const microphone = media.tracks$.value![0] as LocalAudioMediaTrack;
|
||||
await expect(microphone.setEnabled(false)).resolves.toBe(false);
|
||||
expect(first.participant.setMicrophoneEnabled).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
);
|
||||
@@ -293,10 +297,16 @@ describe("createLivekitLocalMedia", () => {
|
||||
await flushPromises();
|
||||
await media.publish({ source: "screenShare" });
|
||||
|
||||
await media.unpublish("screenShareAudio");
|
||||
await media.unpublish({
|
||||
kind: "audio",
|
||||
source: "screenShareAudio",
|
||||
} as LocalAudioMediaTrack);
|
||||
expect(participant.setScreenShareEnabled).toHaveBeenLastCalledWith(false);
|
||||
|
||||
await media.unpublish("microphone");
|
||||
await media.unpublish({
|
||||
kind: "audio",
|
||||
source: "microphone",
|
||||
} as LocalAudioMediaTrack);
|
||||
expect(participant.unpublishTrack).toHaveBeenCalledOnce();
|
||||
expect(media.tracks$.value).toEqual([]);
|
||||
});
|
||||
@@ -385,6 +395,116 @@ function fakeLocalTrack(source: Track.Source): LocalTrack {
|
||||
return t as unknown as LocalTrack;
|
||||
}
|
||||
|
||||
describe("local data tracks", () => {
|
||||
function joined(): ReturnType<typeof setup> & FakeConnection {
|
||||
const fixture = setup();
|
||||
const fake = fakeConnection();
|
||||
fixture.connectionMap$.next(mapOf(fake.connection));
|
||||
fixture.localTransport$.next(localTransport);
|
||||
return { ...fixture, ...fake };
|
||||
}
|
||||
|
||||
it("publishes on LiveKit only once publishing is allowed, and again on a new connection", async () => {
|
||||
const { media, participant, connectionMap$ } = joined();
|
||||
let published = false;
|
||||
const publishing = media
|
||||
.publish({ source: "data", name: "chat" })
|
||||
.then((track) => {
|
||||
published = true;
|
||||
return track;
|
||||
});
|
||||
await flushPromises();
|
||||
expect(participant.publishDataTrack).not.toHaveBeenCalled();
|
||||
expect(published).toBe(false);
|
||||
// Listed from the request on, like a media publication
|
||||
const listed = media.tracks$.value!.find((t) => t.kind === "data");
|
||||
expect(listed).toMatchObject({ name: "chat", reliable: true });
|
||||
|
||||
media.setPublishing(true);
|
||||
const chat = await publishing;
|
||||
expect(chat).toBe(listed);
|
||||
expect(participant.publishDataTrack).toHaveBeenCalledWith({
|
||||
name: "reliable:chat",
|
||||
});
|
||||
|
||||
const next = fakeConnection();
|
||||
connectionMap$.next(mapOf(next.connection));
|
||||
await flushPromises();
|
||||
expect(next.participant.publishDataTrack).toHaveBeenCalledWith({
|
||||
name: "reliable:chat",
|
||||
});
|
||||
// The host's object outlives the connection
|
||||
expect(media.tracks$.value).toContain(chat);
|
||||
});
|
||||
|
||||
it("sends reliable data as a byte stream and lossy data as frames, replaying what was sent", async () => {
|
||||
const { media, participant } = joined();
|
||||
media.setPublishing(true);
|
||||
const chat = await media.publish({ source: "data", name: "chat" });
|
||||
const cursors = await media.publish({
|
||||
source: "data",
|
||||
name: "cursors",
|
||||
reliable: false,
|
||||
});
|
||||
await flushPromises();
|
||||
const sent: Uint8Array[] = [];
|
||||
chat.data$.subscribe((p) => sent.push(p));
|
||||
|
||||
await chat.send(new Uint8Array([1]));
|
||||
expect(participant.sendBytes).toHaveBeenCalledWith(new Uint8Array([1]), {
|
||||
topic: "reliable:chat",
|
||||
});
|
||||
expect(sent).toEqual([new Uint8Array([1])]);
|
||||
|
||||
await cursors.send(new Uint8Array([2]));
|
||||
const livekitCursors = await vi.mocked(participant.publishDataTrack).mock
|
||||
.results[1].value;
|
||||
expect(livekitCursors.tryPush).toHaveBeenCalledWith({
|
||||
payload: new Uint8Array([2]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a second track of the same name, or one LiveKit refuses", async () => {
|
||||
const { media, participant } = joined();
|
||||
media.setPublishing(true);
|
||||
await media.publish({ source: "data", name: "chat" });
|
||||
await expect(
|
||||
media.publish({ source: "data", name: "chat", reliable: false }),
|
||||
).rejects.toThrow("published already");
|
||||
|
||||
vi.mocked(participant.publishDataTrack).mockRejectedValueOnce(
|
||||
new Error("DuplicateName"),
|
||||
);
|
||||
await expect(
|
||||
media.publish({ source: "data", name: "cursors" }),
|
||||
).rejects.toThrow("DuplicateName");
|
||||
expect(
|
||||
media.tracks$.value!.filter((t) => t.kind === "data").map((t) => t.name),
|
||||
).toEqual(["chat"]);
|
||||
});
|
||||
|
||||
it("finishes a track on unpublish", async () => {
|
||||
const { media, participant } = joined();
|
||||
media.setPublishing(true);
|
||||
const chat: LocalDataTrack = await media.publish({
|
||||
source: "data",
|
||||
name: "chat",
|
||||
});
|
||||
await flushPromises();
|
||||
let completed = false;
|
||||
chat.data$.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
await media.unpublish(chat);
|
||||
await flushPromises();
|
||||
const livekitChat = await vi.mocked(participant.publishDataTrack).mock
|
||||
.results[0].value;
|
||||
expect(livekitChat.unpublish).toHaveBeenCalled();
|
||||
expect(completed).toBe(true);
|
||||
expect(media.tracks$.value).not.toContain(chat);
|
||||
await expect(chat.send(new Uint8Array([1]))).rejects.toThrow("unpublished");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The slice of a local participant the publisher and the local tracks use:
|
||||
* enabling a source publishes a fake track at once, muting an existing one
|
||||
@@ -442,6 +562,14 @@ function fakeLocalParticipant(): LocalParticipant {
|
||||
|
||||
const participant = {
|
||||
...emitter,
|
||||
publishDataTrack: vi.fn(async ({ name }: { name: string }) =>
|
||||
Promise.resolve({
|
||||
name,
|
||||
tryPush: vi.fn(async () => Promise.resolve()),
|
||||
unpublish: vi.fn(async () => Promise.resolve()),
|
||||
} as unknown as LivekitLocalDataTrack),
|
||||
),
|
||||
sendBytes: vi.fn(async () => Promise.resolve({})),
|
||||
identity: "@alice:example.org:DEVICE",
|
||||
isLocal: true,
|
||||
isSpeaking: false,
|
||||
|
||||
@@ -26,13 +26,16 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
type DataPublishRequest,
|
||||
type LocalAudioMediaTrack,
|
||||
type LocalDataTrack,
|
||||
type LocalMedia,
|
||||
type LocalTrack,
|
||||
type LocalVideoMediaTrack,
|
||||
type MediaSource,
|
||||
type PublishRequest,
|
||||
} from "../../media-api";
|
||||
import { type EncryptionSystem } from "../../encryption";
|
||||
import { E2eeType, type EncryptionSystem } from "../../encryption";
|
||||
import { FailToStartLivekitConnection, toMatrixRTCError } from "../../errors";
|
||||
import { type Behavior } from "../../reactive/Behavior";
|
||||
import {
|
||||
@@ -50,6 +53,7 @@ import {
|
||||
} from "./LivekitMemberMedia";
|
||||
import { livekitSources } from "./LivekitMediaTrack";
|
||||
import { type DesiredMedia, Publisher } from "./Publisher";
|
||||
import { createLocalDataTracks } from "./LocalDataTracks";
|
||||
import { toMediaConnectionState } from "./connectionState";
|
||||
|
||||
interface Props {
|
||||
@@ -67,7 +71,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export interface LivekitLocalMedia extends LocalMediaBackend {
|
||||
/** The connection the local member publishes on; the data channel sends on it. */
|
||||
/** The connection the local member publishes on. */
|
||||
connection$: Behavior<Connection | null>;
|
||||
setAudioOutputDeviceId(deviceId: string): Promise<void>;
|
||||
}
|
||||
@@ -171,14 +175,42 @@ export function createLivekitLocalMedia({
|
||||
return result;
|
||||
};
|
||||
|
||||
const { tracks$, encryptionError$ } = createLocalLivekitMemberMedia(
|
||||
const { tracks$: mediaTracks$, encryptionError$ } =
|
||||
createLocalLivekitMemberMedia(
|
||||
scope,
|
||||
participantAndRoom$(scope, participant$, connection$),
|
||||
encryptionSystem,
|
||||
setEnabled,
|
||||
);
|
||||
const dataTracks = createLocalDataTracks({
|
||||
scope,
|
||||
participantAndRoom$(scope, participant$, connection$),
|
||||
encryptionSystem,
|
||||
setEnabled,
|
||||
connection$,
|
||||
publishing$,
|
||||
encrypted: encryptionSystem.kind !== E2eeType.NONE,
|
||||
logger,
|
||||
});
|
||||
// The data tracks after the media, both null while there is no participant
|
||||
const tracks$ = scope.behavior<LocalTrack[] | null>(
|
||||
combineLatest([mediaTracks$, dataTracks.tracks$], (media, data) =>
|
||||
media === null ? null : [...media, ...data],
|
||||
),
|
||||
);
|
||||
|
||||
const publish = async (
|
||||
function publish(request: DataPublishRequest): Promise<LocalDataTrack>;
|
||||
function publish(
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack>;
|
||||
async function publish(
|
||||
request: DataPublishRequest | PublishRequest,
|
||||
): Promise<LocalTrack> {
|
||||
if (request.source === "data")
|
||||
return dataTracks.publish(request).catch((e: unknown) => {
|
||||
throw toMatrixRTCError(e);
|
||||
});
|
||||
return publishMedia(request);
|
||||
}
|
||||
|
||||
const publishMedia = async (
|
||||
request: PublishRequest,
|
||||
): Promise<LocalAudioMediaTrack | LocalVideoMediaTrack> => {
|
||||
desired.set(request.source, { request, enabled: true });
|
||||
@@ -195,8 +227,9 @@ export function createLivekitLocalMedia({
|
||||
}
|
||||
};
|
||||
|
||||
const unpublish = async (source: MediaSource): Promise<void> => {
|
||||
const published = publishedSource(source);
|
||||
const unpublish = async (track: LocalTrack): Promise<void> => {
|
||||
if (track.kind === "data") return dataTracks.unpublish(track);
|
||||
const published = publishedSource(track.source);
|
||||
if (published === undefined) return;
|
||||
desired.delete(published);
|
||||
await publisher$.value?.unpublish(published);
|
||||
@@ -259,11 +292,16 @@ function publication$(
|
||||
}
|
||||
|
||||
function trackWithId$(
|
||||
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>,
|
||||
tracks$: Behavior<LocalTrack[] | null>,
|
||||
id: string,
|
||||
): Observable<LocalAudioMediaTrack | LocalVideoMediaTrack> {
|
||||
return tracks$.pipe(
|
||||
map((tracks) => tracks?.find((track) => track.id === id)),
|
||||
map((tracks) =>
|
||||
tracks?.find(
|
||||
(track): track is LocalAudioMediaTrack | LocalVideoMediaTrack =>
|
||||
track.kind !== "data" && track.id === id,
|
||||
),
|
||||
),
|
||||
filter((track) => track !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,9 +72,11 @@ export class Publisher {
|
||||
private readonly media?: LocalMedia,
|
||||
) {
|
||||
this.room = room;
|
||||
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
||||
this.logger.error("Failed to enable E2EE on the room", e);
|
||||
});
|
||||
room
|
||||
.setE2EEEnabled(room.options.encryption !== undefined)
|
||||
?.catch((e: Error) => {
|
||||
this.logger.error("Failed to enable E2EE on the room", e);
|
||||
});
|
||||
this.onLocalTrackPublished = this.onLocalTrackPublished.bind(this);
|
||||
room.localParticipant.on(
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
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 { EventEmitter } from "events";
|
||||
import {
|
||||
type RemoteDataTrack,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
} from "livekit-client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { remoteDataTracks$, watchRemoteDataTracks } from "./RemoteDataTracks";
|
||||
|
||||
const track = (sid: string): RemoteDataTrack =>
|
||||
({ info: { sid }, publisherIdentity: "alice" }) as RemoteDataTrack;
|
||||
|
||||
describe("remoteDataTracks$", () => {
|
||||
it("keeps what the room announced before anyone observed it", () => {
|
||||
const emitter = new EventEmitter();
|
||||
const room = watchRemoteDataTracks(emitter as unknown as LivekitRoom);
|
||||
// As in a join response: announced before any member asks
|
||||
emitter.emit(RoomEvent.DataTrackPublished, track("DT_1"));
|
||||
emitter.emit(RoomEvent.DataTrackPublished, track("DT_2"));
|
||||
emitter.emit(RoomEvent.DataTrackUnpublished, "DT_1");
|
||||
|
||||
const seen: string[][] = [];
|
||||
remoteDataTracks$(room).subscribe((tracks) =>
|
||||
seen.push(tracks.map((t) => t.info.sid)),
|
||||
);
|
||||
expect(seen).toEqual([["DT_2"]]);
|
||||
|
||||
emitter.emit(RoomEvent.Disconnected);
|
||||
expect(seen.at(-1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
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 RemoteDataTrack,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent,
|
||||
} from "livekit-client";
|
||||
import { BehaviorSubject, type Observable } from "rxjs";
|
||||
|
||||
const tracksByRoom = new WeakMap<
|
||||
LivekitRoom,
|
||||
BehaviorSubject<readonly RemoteDataTrack[]>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Starts keeping the room's remote data tracks, from its own events. Called
|
||||
* as the room is created: LiveKit announces the tracks in the join response
|
||||
* before the participants that publish them exist, so a participant's
|
||||
* `dataTracks` misses them, and it adds a track to that map only after
|
||||
* announcing it.
|
||||
*/
|
||||
export function watchRemoteDataTracks(room: LivekitRoom): LivekitRoom {
|
||||
remoteDataTracksOf(room);
|
||||
return room;
|
||||
}
|
||||
|
||||
/** Every remote data track the room knows of, as LiveKit announced them. */
|
||||
export function remoteDataTracks$(
|
||||
room: LivekitRoom,
|
||||
): Observable<readonly RemoteDataTrack[]> {
|
||||
return remoteDataTracksOf(room);
|
||||
}
|
||||
|
||||
function remoteDataTracksOf(
|
||||
room: LivekitRoom,
|
||||
): BehaviorSubject<readonly RemoteDataTrack[]> {
|
||||
let tracks$ = tracksByRoom.get(room);
|
||||
if (tracks$ === undefined) {
|
||||
const subject$ = new BehaviorSubject<readonly RemoteDataTrack[]>([]);
|
||||
room.on(RoomEvent.DataTrackPublished, (track: RemoteDataTrack) =>
|
||||
subject$.next([
|
||||
...subject$.value.filter((t) => t.info.sid !== track.info.sid),
|
||||
track,
|
||||
]),
|
||||
);
|
||||
room.on(RoomEvent.DataTrackUnpublished, (sid: string) =>
|
||||
subject$.next(subject$.value.filter((t) => t.info.sid !== sid)),
|
||||
);
|
||||
// A new session announces its tracks again
|
||||
room.on(RoomEvent.Disconnected, () => subject$.next([]));
|
||||
tracksByRoom.set(room, subject$);
|
||||
tracks$ = subject$;
|
||||
}
|
||||
return tracks$;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
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 {
|
||||
fromLivekitDataTrackName,
|
||||
toLivekitDataTrackName,
|
||||
} from "./dataTrackName";
|
||||
|
||||
describe("data track names on LiveKit", () => {
|
||||
it.each([true, false])("round-trips reliable=%s", (reliable) => {
|
||||
expect(
|
||||
fromLivekitDataTrackName(toLivekitDataTrackName("chat:room", reliable)),
|
||||
).toEqual({ name: "chat:room", reliable });
|
||||
});
|
||||
|
||||
it("reads a name without a prefix as lossy", () => {
|
||||
expect(fromLivekitDataTrackName("telemetry")).toEqual({
|
||||
name: "telemetry",
|
||||
reliable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How a data track is named on LiveKit. Every data track of a member is one
|
||||
* LiveKit data track, which announces it; the prefix says how its payloads
|
||||
* travel. A lossy track pushes them as frames on the data track itself. A
|
||||
* reliable one sends each as a byte stream on a topic of the same name,
|
||||
* because LiveKit's data tracks are lossy only.
|
||||
*/
|
||||
|
||||
const lossyPrefix = "lossy:";
|
||||
const reliablePrefix = "reliable:";
|
||||
|
||||
export function toLivekitDataTrackName(
|
||||
name: string,
|
||||
reliable: boolean,
|
||||
): string {
|
||||
return (reliable ? reliablePrefix : lossyPrefix) + name;
|
||||
}
|
||||
|
||||
/** A track without a prefix, from a plain LiveKit publisher, is lossy under its own name. */
|
||||
export function fromLivekitDataTrackName(livekitName: string): {
|
||||
name: string;
|
||||
reliable: boolean;
|
||||
} {
|
||||
if (livekitName.startsWith(reliablePrefix))
|
||||
return { name: livekitName.slice(reliablePrefix.length), reliable: true };
|
||||
if (livekitName.startsWith(lossyPrefix))
|
||||
return { name: livekitName.slice(lossyPrefix.length), reliable: false };
|
||||
return { name: livekitName, reliable: false };
|
||||
}
|
||||
@@ -8,9 +8,9 @@ Please see LICENSE in the repository root for full details.
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { type MediaTrack } from "../media-api";
|
||||
import { type DataTrack, type MediaTrack } from "../media-api";
|
||||
import { testScope } from "./test";
|
||||
import { trackBySource$ } from "./tracks";
|
||||
import { dataTrackByName$, trackBySource$ } from "./tracks";
|
||||
|
||||
const camera = { source: "camera", kind: "video", id: "1" } as MediaTrack;
|
||||
const microphone = {
|
||||
@@ -39,3 +39,40 @@ describe("trackBySource$", () => {
|
||||
expect(emissions).toEqual([camera]);
|
||||
});
|
||||
});
|
||||
|
||||
const chat = {
|
||||
source: "data",
|
||||
kind: "data",
|
||||
id: "3",
|
||||
name: "chat",
|
||||
} as DataTrack;
|
||||
const cursors = {
|
||||
source: "data",
|
||||
kind: "data",
|
||||
id: "4",
|
||||
name: "cursors",
|
||||
} as DataTrack;
|
||||
|
||||
describe("dataTrackByName$", () => {
|
||||
it("picks the data track of a name and follows the list", () => {
|
||||
const tracks$ = new BehaviorSubject<(MediaTrack | DataTrack)[] | null>(
|
||||
null,
|
||||
);
|
||||
const chat$ = dataTrackByName$(testScope(), tracks$, "chat");
|
||||
expect(chat$.value).toBeUndefined();
|
||||
tracks$.next([camera, cursors, chat]);
|
||||
expect(chat$.value).toBe(chat);
|
||||
tracks$.next([camera, cursors]);
|
||||
expect(chat$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not emit when the list changes elsewhere", () => {
|
||||
const tracks$ = new BehaviorSubject<(MediaTrack | DataTrack)[]>([chat]);
|
||||
const chat$ = dataTrackByName$(testScope(), tracks$, "chat");
|
||||
const emissions: unknown[] = [];
|
||||
chat$.subscribe((t) => emissions.push(t));
|
||||
tracks$.next([chat, camera]);
|
||||
tracks$.next([microphone, chat, cursors]);
|
||||
expect(emissions).toEqual([chat]);
|
||||
});
|
||||
});
|
||||
|
||||
+20
-1
@@ -11,6 +11,7 @@ import { type Behavior } from "../reactive/Behavior";
|
||||
import { type ObservableScope } from "../reactive/ObservableScope";
|
||||
import {
|
||||
type AudioMediaTrack,
|
||||
type DataTrack,
|
||||
type MediaSource,
|
||||
type MediaTrack,
|
||||
type VideoMediaTrack,
|
||||
@@ -27,7 +28,7 @@ export type TrackOfSource<S extends MediaSource> = S extends
|
||||
/** 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>,
|
||||
tracks$: Behavior<readonly (MediaTrack | DataTrack)[] | null>,
|
||||
source: S,
|
||||
): Behavior<TrackOfSource<S> | undefined> {
|
||||
return scope.behavior(
|
||||
@@ -42,3 +43,21 @@ export function trackBySource$<S extends MediaSource>(
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The member's data track of this name; undefined while there is none, or no media yet. */
|
||||
export function dataTrackByName$(
|
||||
scope: ObservableScope,
|
||||
tracks$: Behavior<readonly (MediaTrack | DataTrack)[] | null>,
|
||||
name: string,
|
||||
): Behavior<DataTrack | undefined> {
|
||||
return scope.behavior(
|
||||
tracks$.pipe(
|
||||
map((tracks) =>
|
||||
tracks?.find(
|
||||
(t): t is DataTrack => t.kind === "data" && t.name === name,
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+22
-9
@@ -23,13 +23,14 @@ import {
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { HomePage } from "./home/HomePage";
|
||||
import { LoginPage } from "./auth/LoginPage";
|
||||
import { RegisterPage } from "./auth/RegisterPage";
|
||||
import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
@@ -130,9 +131,22 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
.catch(logger.error);
|
||||
});
|
||||
|
||||
const content = loaded ? (
|
||||
<ClientProvider session={vm.clientSession}>
|
||||
<ClientManagerProvider value={vm.clientManager}>
|
||||
// As a widget, the client comes from the host over the widget API. Standalone,
|
||||
// Element Call finds one itself, so there is nothing to wait for here.
|
||||
const [widgetClient, setWidgetClient] = useState<MatrixClient | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (widget === null) return;
|
||||
widget.client
|
||||
.then(setWidgetClient)
|
||||
.catch((e) => logger.error("Failed to obtain the host's client", e));
|
||||
}, [widget]);
|
||||
const clientReady = widget === null || widgetClient !== undefined;
|
||||
|
||||
const content =
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
|
||||
<Sentry.ErrorBoundary
|
||||
@@ -147,11 +161,10 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
</Sentry.ErrorBoundary>
|
||||
</BackgroundEffectsProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientManagerProvider>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
|
||||
+16
-2
@@ -12,7 +12,6 @@ import { type FC, type PropsWithChildren } from "react";
|
||||
|
||||
import { ClientContextProvider } from "./ClientContext";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { hostClientSession } from "./state/ClientSession";
|
||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||
import {
|
||||
type HostBridge,
|
||||
@@ -27,7 +26,22 @@ const TestComponent: FC<
|
||||
}>
|
||||
> = ({ client, hostBridge = nullHostBridge, children }) => {
|
||||
return (
|
||||
<ClientContextProvider value={hostClientSession(client)}>
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
state: "valid",
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
reactions: true,
|
||||
},
|
||||
setClient: vi.fn(),
|
||||
authenticated: {
|
||||
client,
|
||||
isPasswordlessUser: true,
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
|
||||
</ClientContextProvider>
|
||||
);
|
||||
|
||||
+10
-6
@@ -15,7 +15,7 @@ import {
|
||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { useClient } from "./ClientContext";
|
||||
import { useClientState } from "./ClientContext";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
|
||||
export enum Size {
|
||||
@@ -74,7 +74,7 @@ export const Avatar: FC<Props> = ({
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const { client } = useClient();
|
||||
const clientState = useClientState();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const sizePx = useMemo(
|
||||
@@ -87,7 +87,7 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
// In theory, a change in `client` or `sizePx` could run extra media
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra media
|
||||
// downloads, but in practice they should be stable long before this code runs.
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
@@ -99,8 +99,12 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
if (hostBridge.downloadMedia) {
|
||||
blob = hostBridge.downloadMedia(src);
|
||||
} else if (client && sizePx) {
|
||||
blob = getAvatarFromServer(client, src, sizePx);
|
||||
} else if (
|
||||
clientState?.state === "valid" &&
|
||||
clientState.authenticated?.client &&
|
||||
sizePx
|
||||
) {
|
||||
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
|
||||
} else {
|
||||
setAvatarUrl(undefined);
|
||||
return;
|
||||
@@ -129,7 +133,7 @@ export const Avatar: FC<Props> = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [client, hostBridge, src, sizePx]);
|
||||
}, [clientState, hostBridge, src, sizePx]);
|
||||
|
||||
return (
|
||||
<CompoundAvatar
|
||||
|
||||
+60
-93
@@ -6,25 +6,12 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { act, render, renderHook } from "@testing-library/react";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC } from "react";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
ClientManagerProvider,
|
||||
ClientProvider,
|
||||
useClientManager,
|
||||
useClientManagerIfAny,
|
||||
useClientSessionState,
|
||||
} from "./ClientContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
hostClientSession,
|
||||
} from "./state/ClientSession";
|
||||
import { ClientProvider, useClientState } from "./ClientContext";
|
||||
|
||||
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
({
|
||||
@@ -37,47 +24,67 @@ const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
|
||||
/** Reports what the context says, so a test can assert on it. */
|
||||
const ShowClientState: FC = () => {
|
||||
const state = useClientSessionState();
|
||||
switch (state.state) {
|
||||
case "signedIn":
|
||||
return <span>{state.client.getUserId()}</span>;
|
||||
case "signedOut":
|
||||
return <span>unauthenticated</span>;
|
||||
default:
|
||||
return <span>{state.state}</span>;
|
||||
}
|
||||
const state = useClientState();
|
||||
if (state === undefined) return <span>loading</span>;
|
||||
if (state.state === "error") return <span>error</span>;
|
||||
return (
|
||||
<span>{state.authenticated?.client.getUserId() ?? "unauthenticated"}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProvider = (
|
||||
session: ClientSession,
|
||||
children = <ShowClientState />,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const client = mockClient();
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider session={session}>{children}</ClientProvider>
|
||||
<ClientProvider client={client}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const { container } = renderProvider(hostClientSession(mockClient()));
|
||||
|
||||
// Available on the very first render: a supplied client needs no session
|
||||
// restoring, so there is no loading state to pass through.
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
});
|
||||
|
||||
test("does not claim exclusive use of storage when given a client", () => {
|
||||
// The channel is created when the module loads, so spy on the prototype
|
||||
// rather than trying to replace the global.
|
||||
const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage");
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={mockClient()}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// The broadcast shuts down other instances to protect Element Call's own
|
||||
// stores. A host's client brings its own, so there is nothing to protect.
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
|
||||
postMessage.mockRestore();
|
||||
});
|
||||
|
||||
test("follows the client when the host swaps it", () => {
|
||||
const { container, rerender } = renderProvider(
|
||||
hostClientSession(mockClient()),
|
||||
const first = mockClient();
|
||||
const second = mockClient("@bob:example.org");
|
||||
|
||||
const { container, rerender } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={first}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
|
||||
// A host that re-authenticates hands us a new client on a mounted component
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<ClientProvider
|
||||
session={hostClientSession(mockClient("@bob:example.org"))}
|
||||
>
|
||||
<ClientProvider client={second}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
@@ -86,63 +93,23 @@ test("follows the client when the host swaps it", () => {
|
||||
expect(container.textContent).toBe("@bob:example.org");
|
||||
});
|
||||
|
||||
test("follows a session of its own as it is restored", () => {
|
||||
const state$ = new BehaviorSubject<ClientSessionState>({ state: "loading" });
|
||||
const { container } = renderProvider({ state$ });
|
||||
test("finds a client of its own when the host supplies none", async () => {
|
||||
const client = mockClient();
|
||||
vi.doMock("./utils/spa", () => ({
|
||||
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Nothing to show until a session has been restored or created
|
||||
expect(container.textContent).toBe("loading");
|
||||
act(() =>
|
||||
state$.next({
|
||||
state: "signedIn",
|
||||
client: mockClient(),
|
||||
passwordlessUser: true,
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
});
|
||||
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
|
||||
|
||||
test("has no one signed in when the session says so", () => {
|
||||
const { container } = renderProvider({
|
||||
state$: new BehaviorSubject<ClientSessionState>({ state: "signedOut" }),
|
||||
});
|
||||
expect(container.textContent).toBe("unauthenticated");
|
||||
});
|
||||
|
||||
test("shows the session's error in place of its children", () => {
|
||||
const { container } = renderProvider({
|
||||
state$: new BehaviorSubject<ClientSessionState>({
|
||||
state: "error",
|
||||
error: new Error("Opened elsewhere"),
|
||||
}),
|
||||
});
|
||||
// The error page, not the children
|
||||
expect(container.textContent).not.toContain("error");
|
||||
expect(container.textContent).toContain("Something went wrong");
|
||||
});
|
||||
|
||||
test("hands the session's manager to views that need one", () => {
|
||||
const manager: ClientManager = {
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
};
|
||||
const { result } = renderHook(
|
||||
() => [useClientManager(), useClientManagerIfAny()],
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<ClientManagerProvider value={manager}>
|
||||
<>{children}</>
|
||||
</ClientManagerProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
expect(result.current).toEqual([manager, manager]);
|
||||
});
|
||||
|
||||
test("has no manager for a host's session", () => {
|
||||
// The component and the widget provide none; a view that insists on one is
|
||||
// in the wrong build
|
||||
expect(renderHook(() => useClientManagerIfAny()).result.current).toBeNull();
|
||||
expect(() => renderHook(() => useClientManager())).toThrow();
|
||||
vi.doUnmock("./utils/spa");
|
||||
});
|
||||
|
||||
+353
-51
@@ -5,18 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useEffect, createContext, use, type JSX } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
createContext,
|
||||
use,
|
||||
useRef,
|
||||
useMemo,
|
||||
type JSX,
|
||||
} from "react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { ErrorPage } from "./FullScreenView";
|
||||
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
|
||||
import { useBehavior } from "./useBehavior";
|
||||
import { constant } from "./state/Behavior";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
} from "./state/ClientSession";
|
||||
PosthogAnalytics,
|
||||
RegistrationType,
|
||||
} from "./analytics/PosthogAnalytics";
|
||||
import { useEventTarget } from "./useEvents";
|
||||
import { OpenElsewhereError } from "./RichError";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -25,70 +37,360 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// Without a provider, no one is signed in and there is no session to act on
|
||||
const noClientSession: ClientSession = {
|
||||
state$: constant({ state: "signedOut" }),
|
||||
export type ClientState = ValidClientState | ErrorState;
|
||||
|
||||
export type ValidClientState = {
|
||||
state: "valid";
|
||||
authenticated?: AuthenticatedClient;
|
||||
// 'Disconnected' rather than 'connected' because it tracks specifically
|
||||
// whether the client is supposed to be connected but is not
|
||||
disconnected: boolean;
|
||||
supportedFeatures: {
|
||||
reactions: boolean;
|
||||
};
|
||||
setClient: (client: MatrixClient, session: Session) => void;
|
||||
};
|
||||
|
||||
const ClientContext = createContext<ClientSession>(noClientSession);
|
||||
export type AuthenticatedClient = {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
changePassword: (password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
export type ErrorState = {
|
||||
state: "error";
|
||||
error: Error;
|
||||
};
|
||||
|
||||
const ClientContext = createContext<ClientState | undefined>(undefined);
|
||||
|
||||
export const ClientContextProvider = ClientContext.Provider;
|
||||
|
||||
export const useClientSessionState = (): ClientSessionState =>
|
||||
useBehavior(use(ClientContext).state$);
|
||||
export const useClientState = (): ClientState | undefined => use(ClientContext);
|
||||
|
||||
export function useClient(): { client?: MatrixClient } {
|
||||
const state = useClientSessionState();
|
||||
return { client: state.state === "signedIn" ? state.client : undefined };
|
||||
export function useClient(): {
|
||||
client?: MatrixClient;
|
||||
setClient?: (client: MatrixClient, session: Session) => void;
|
||||
} {
|
||||
let client;
|
||||
let setClient;
|
||||
|
||||
const clientState = useClientState();
|
||||
if (clientState?.state === "valid") {
|
||||
client = clientState.authenticated?.client;
|
||||
setClient = clientState.setClient;
|
||||
}
|
||||
|
||||
return { client, setClient };
|
||||
}
|
||||
|
||||
// Null where the session is a host's: the component, and the widget
|
||||
const ClientManagerContext = createContext<ClientManager | null>(null);
|
||||
// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
|
||||
export function useClientLegacy(): {
|
||||
client?: MatrixClient;
|
||||
setClient?: (client: MatrixClient, session: Session) => void;
|
||||
passwordlessUser: boolean;
|
||||
loading: boolean;
|
||||
authenticated: boolean;
|
||||
logout?: () => void;
|
||||
error?: Error;
|
||||
} {
|
||||
const clientState = useClientState();
|
||||
|
||||
export const ClientManagerProvider = ClientManagerContext.Provider;
|
||||
let client;
|
||||
let setClient;
|
||||
let passwordlessUser = false;
|
||||
let loading = true;
|
||||
let error;
|
||||
let authenticated = false;
|
||||
let logout;
|
||||
|
||||
/**
|
||||
* The manager of a session that is Element Call's own, for views that only
|
||||
* the standalone app mounts: the login and registration pages. Throws
|
||||
* elsewhere, as those views have no business being there.
|
||||
*/
|
||||
export function useClientManager(): ClientManager {
|
||||
const manager = use(ClientManagerContext);
|
||||
if (manager === null)
|
||||
throw new Error("This view needs a session of Element Call's own");
|
||||
return manager;
|
||||
if (clientState?.state === "valid") {
|
||||
client = clientState.authenticated?.client;
|
||||
setClient = clientState.setClient;
|
||||
passwordlessUser = clientState.authenticated?.isPasswordlessUser ?? false;
|
||||
loading = false;
|
||||
authenticated = client !== undefined;
|
||||
logout = clientState.authenticated?.logout;
|
||||
} else if (clientState?.state === "error") {
|
||||
error = clientState.error;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
setClient,
|
||||
passwordlessUser,
|
||||
loading,
|
||||
authenticated,
|
||||
logout,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's manager, or null where the session is a host's. For views
|
||||
* shared with the widget, which can offer to sign out only when that is ours
|
||||
* to do.
|
||||
*/
|
||||
export const useClientManagerIfAny = (): ClientManager | null =>
|
||||
use(ClientManagerContext);
|
||||
const loadChannel =
|
||||
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
/** a session from Element Call SPA, from a widget or the host (EW) */
|
||||
session: ClientSession;
|
||||
/**
|
||||
* The client Element Call should use.
|
||||
*
|
||||
* An application hosting Element Call as a component already has a client,
|
||||
* and owns the user's session; supplying it here means Element Call neither
|
||||
* authenticates anyone nor manages their session. Left out, Element Call finds a client
|
||||
* itself — from the widget API, or by restoring or creating a session of its
|
||||
* own.
|
||||
*/
|
||||
client?: MatrixClient;
|
||||
}
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children, session }) => {
|
||||
const state = useBehavior(session.state$);
|
||||
export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
// null = signed out, undefined = loading
|
||||
const [initClientState, setInitClientState] = useState<
|
||||
InitResult | null | undefined
|
||||
>(
|
||||
client === undefined
|
||||
? undefined
|
||||
: // A supplied client belongs to the host, so there is no session of ours
|
||||
// to restore and nothing to wait for.
|
||||
{ client, passwordlessUser: false },
|
||||
);
|
||||
|
||||
const initializing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (client !== undefined) {
|
||||
// Nothing to load, but a host may hand us a different client later — on
|
||||
// re-authenticating, say — so follow whichever one it has given us.
|
||||
setInitClientState((current) =>
|
||||
current?.client === client
|
||||
? current
|
||||
: { client, passwordlessUser: false },
|
||||
);
|
||||
// Analytics still need to follow the user's choices.
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
return;
|
||||
}
|
||||
// In case the component is mounted, unmounted, and remounted quickly (as
|
||||
// React does in strict mode), we need to make sure not to doubly initialize
|
||||
// the client.
|
||||
if (initializing.current) return;
|
||||
initializing.current = true;
|
||||
|
||||
loadClient()
|
||||
.then((initResult) => {
|
||||
setInitClientState(initResult);
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
})
|
||||
.catch((err) => logger.error(err))
|
||||
.finally(() => (initializing.current = false));
|
||||
}, [client]);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
const session = loadSession();
|
||||
if (!initClientState?.client || !session) {
|
||||
return;
|
||||
}
|
||||
|
||||
await initClientState.client.setPassword(
|
||||
{
|
||||
type: "m.login.password",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: session.user_id,
|
||||
},
|
||||
user: session.user_id,
|
||||
password: session.tempPassword,
|
||||
},
|
||||
password,
|
||||
);
|
||||
|
||||
saveSession({ ...session, passwordlessUser: false });
|
||||
|
||||
setInitClientState({
|
||||
...initClientState,
|
||||
passwordlessUser: false,
|
||||
});
|
||||
},
|
||||
[initClientState],
|
||||
);
|
||||
|
||||
const setClient = useCallback(
|
||||
(client: MatrixClient, session: Session) => {
|
||||
const oldClient = initClientState?.client;
|
||||
if (oldClient && oldClient !== client) {
|
||||
oldClient.stopClient();
|
||||
}
|
||||
|
||||
saveSession(session);
|
||||
setInitClientState({
|
||||
client,
|
||||
passwordlessUser: session.passwordlessUser,
|
||||
});
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
},
|
||||
[initClientState?.client],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const client = initClientState?.client;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.logout(true);
|
||||
await client.clearStores();
|
||||
clearSession();
|
||||
setInitClientState(null);
|
||||
leaveToHome?.();
|
||||
PosthogAnalytics.instance.logout();
|
||||
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
|
||||
}, [leaveToHome, initClientState?.client]);
|
||||
|
||||
// To protect against multiple sessions writing to the same storage
|
||||
// simultaneously, we send a broadcast message that shuts down all other
|
||||
// running instances of the app. Element Call only has storage of its own to
|
||||
// protect when it created the session itself; given a client — by a host, or
|
||||
// over the widget API — it is mostly stateless.
|
||||
const ownsSession = client === undefined;
|
||||
useEffect(() => {
|
||||
if (ownsSession) loadChannel?.postMessage({});
|
||||
}, [ownsSession]);
|
||||
|
||||
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEventTarget(
|
||||
loadChannel,
|
||||
"message",
|
||||
useCallback(() => {
|
||||
initClientState?.client.stopClient();
|
||||
setAlreadyOpenedErr(new OpenElsewhereError());
|
||||
}, [initClientState?.client, setAlreadyOpenedErr]),
|
||||
);
|
||||
|
||||
const [isDisconnected, setIsDisconnected] = useState(false);
|
||||
const [supportsReactions, setSupportsReactions] = useState(false);
|
||||
|
||||
const state: ClientState | undefined = useMemo(() => {
|
||||
if (alreadyOpenedErr) {
|
||||
return { state: "error", error: alreadyOpenedErr };
|
||||
}
|
||||
|
||||
if (initClientState === undefined) return undefined;
|
||||
|
||||
const authenticated =
|
||||
initClientState === null
|
||||
? undefined
|
||||
: {
|
||||
client: initClientState.client,
|
||||
isPasswordlessUser: initClientState.passwordlessUser,
|
||||
changePassword,
|
||||
logout,
|
||||
};
|
||||
|
||||
return {
|
||||
state: "valid",
|
||||
authenticated,
|
||||
setClient,
|
||||
disconnected: isDisconnected,
|
||||
supportedFeatures: {
|
||||
reactions: supportsReactions,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
alreadyOpenedErr,
|
||||
changePassword,
|
||||
initClientState,
|
||||
logout,
|
||||
setClient,
|
||||
isDisconnected,
|
||||
supportsReactions,
|
||||
]);
|
||||
|
||||
const onSync = useCallback(
|
||||
(state: SyncState, _old: SyncState | null, data?: ISyncStateData) => {
|
||||
setIsDisconnected(clientIsDisconnected(state, data));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.state !== "signedIn") return;
|
||||
window.matrixclient = state.client;
|
||||
window.passwordlessUser = state.passwordlessUser;
|
||||
if (!initClientState) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.matrixclient = initClientState.client;
|
||||
window.passwordlessUser = initClientState.passwordlessUser;
|
||||
|
||||
if (PosthogAnalytics.hasInstance())
|
||||
PosthogAnalytics.instance.onLoginStatusChanged();
|
||||
// Analytics follow the user's choices whoever the client belongs to
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
}, [state]);
|
||||
|
||||
if (state.state === "error") return <ErrorPage error={state.error} />;
|
||||
if (initClientState.client) {
|
||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||
}
|
||||
|
||||
return <ClientContext value={session}>{children}</ClientContext>;
|
||||
if (!hostBridge.supportsReactions)
|
||||
logger.warn("The host does not permit reactions");
|
||||
setSupportsReactions(hostBridge.supportsReactions);
|
||||
|
||||
return (): void => {
|
||||
if (initClientState.client) {
|
||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||
}
|
||||
};
|
||||
}, [initClientState, onSync, hostBridge]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage error={alreadyOpenedErr} />;
|
||||
}
|
||||
|
||||
return <ClientContext value={state}>{children}</ClientContext>;
|
||||
};
|
||||
|
||||
export type InitResult = {
|
||||
client: MatrixClient;
|
||||
passwordlessUser: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own. Only reached when no
|
||||
* client was supplied for it to use.
|
||||
*/
|
||||
async function loadClient(): Promise<InitResult | null> {
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
access_token: string;
|
||||
passwordlessUser: boolean;
|
||||
tempPassword?: string;
|
||||
}
|
||||
|
||||
const clearSession = (): void => localStorage.removeItem("matrix-auth-store");
|
||||
const saveSession = (s: Session): void =>
|
||||
localStorage.setItem("matrix-auth-store", JSON.stringify(s));
|
||||
const loadSession = (): Session | undefined => {
|
||||
const data = localStorage.getItem("matrix-auth-store");
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return JSON.parse(data);
|
||||
};
|
||||
|
||||
const clientIsDisconnected = (
|
||||
syncState: SyncState,
|
||||
syncData?: ISyncStateData,
|
||||
): boolean =>
|
||||
syncState === "ERROR" && syncData?.error?.name === "ConnectionError";
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
import { hostClientSession } from "./state/ClientSession";
|
||||
|
||||
test("shows while the client's sync fails to connect", () => {
|
||||
const client = {
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => "@alice:example.org",
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
const { container } = render(
|
||||
<ClientProvider session={hostClientSession(client)}>
|
||||
<DisconnectedBanner />
|
||||
</ClientProvider>,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
|
||||
const [, onSync] = vi.mocked(client.on).mock.calls[0] as [
|
||||
unknown,
|
||||
(state: string, old: null, data?: { error?: Error }) => void,
|
||||
];
|
||||
const connectionError = Object.assign(new Error("offline"), {
|
||||
name: "ConnectionError",
|
||||
});
|
||||
act(() => onSync("ERROR", null, { error: connectionError }));
|
||||
expect(container.textContent).not.toBe("");
|
||||
// Any other failure is not a connection problem
|
||||
act(() => onSync("ERROR", null, { error: new Error("rate limited") }));
|
||||
expect(container.textContent).toBe("");
|
||||
act(() => onSync("SYNCING", null));
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
@@ -6,60 +6,30 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type FC, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
|
||||
import styles from "./DisconnectedBanner.module.css";
|
||||
import { useClient } from "./ClientContext";
|
||||
import { type ValidClientState, useClientState } from "./ClientContext";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLElement> {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown while the signed-in client is supposed to be connected but is not.
|
||||
*/
|
||||
export const DisconnectedBanner: FC<Props> = ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { client } = useClient();
|
||||
// Tagged with the client it describes, so that a swapped client does not
|
||||
// inherit the old one's verdict before its first sync
|
||||
const [disconnected, setDisconnected] = useState<
|
||||
[MatrixClient, boolean] | null
|
||||
>(null);
|
||||
const clientState = useClientState();
|
||||
let shouldShowBanner = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (client === undefined) return;
|
||||
const onSync = (
|
||||
syncState: SyncState,
|
||||
_old: SyncState | null,
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
const clientIsDisconnected =
|
||||
syncState === "ERROR" && data?.error?.name === "ConnectionError";
|
||||
setDisconnected([client, clientIsDisconnected]);
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
return (): void => {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const shouldShowBanner =
|
||||
disconnected !== null && disconnected[0] === client && disconnected[1];
|
||||
if (clientState?.state === "valid") {
|
||||
const validClientState = clientState as ValidClientState;
|
||||
shouldShowBanner = validClientState.disconnected;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+8
-10
@@ -18,8 +18,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -181,40 +179,40 @@ export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
|
||||
+6
-10
@@ -21,10 +21,6 @@ import {
|
||||
UserIntent,
|
||||
} from "../src/UrlParams";
|
||||
import { mockConfig } from "./utils/test";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
const ROOM_NAME = "roomNameHere";
|
||||
const ROOM_ID = "!d45f138fsd";
|
||||
@@ -279,7 +275,7 @@ describe("UrlParams", () => {
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
skipLobby: false,
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
callIntent: "video",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,7 +322,7 @@ describe("UrlParams", () => {
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
skipLobby: false,
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,7 +333,7 @@ describe("UrlParams", () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
...joinExistingCallDefaults("desktop"),
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -352,7 +348,7 @@ describe("UrlParams", () => {
|
||||
sendNotificationType: "ring",
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: true,
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,7 +363,7 @@ describe("UrlParams", () => {
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
callIntent: "video",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,7 +377,7 @@ describe("UrlParams", () => {
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
|
||||
import { LeaveToHomeProvider } from "./LeaveToHomeContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
hostClientSession,
|
||||
} from "./state/ClientSession";
|
||||
import { UserMenuContainer } from "./UserMenuContainer";
|
||||
import { flushPromises } from "./utils/test";
|
||||
|
||||
vi.mock("./settings/SettingsModal", () => ({
|
||||
defaultSettingsTab: "audio",
|
||||
SettingsModal: (): null => null,
|
||||
}));
|
||||
|
||||
const mockClient = (): MatrixClient =>
|
||||
({
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => "@alice:example.org",
|
||||
getUserIdLocalpart: () => "alice",
|
||||
getDeviceId: () => "AAAA",
|
||||
getUser: () => null,
|
||||
stopClient: vi.fn(),
|
||||
}) as Partial<MatrixClient> as MatrixClient;
|
||||
|
||||
test("leaves for home after logging out of the session", async () => {
|
||||
const logout = vi.fn().mockResolvedValue(undefined);
|
||||
const leaveToHome = vi.fn();
|
||||
renderMenu(
|
||||
{
|
||||
state$: new BehaviorSubject<ClientSessionState>({
|
||||
state: "signedIn",
|
||||
client: mockClient(),
|
||||
passwordlessUser: false,
|
||||
}),
|
||||
},
|
||||
{ login: vi.fn(), register: vi.fn(), logout },
|
||||
leaveToHome,
|
||||
);
|
||||
|
||||
await selectSignOut();
|
||||
expect(logout).toHaveBeenCalled();
|
||||
expect(leaveToHome).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot end a session that is not ours", async () => {
|
||||
const leaveToHome = vi.fn();
|
||||
renderMenu(hostClientSession(mockClient()), null, leaveToHome);
|
||||
|
||||
await selectSignOut();
|
||||
expect(leaveToHome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function selectSignOut(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("usermenu_open"));
|
||||
await user.click(await screen.findByTestId("usermenu_logout"));
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
function renderMenu(
|
||||
session: ClientSession,
|
||||
manager: ClientManager | null,
|
||||
leaveToHome: (() => void) | null = null,
|
||||
): void {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<LeaveToHomeProvider value={leaveToHome}>
|
||||
<ClientProvider session={session}>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<UserMenuContainer />
|
||||
</ClientManagerProvider>
|
||||
</ClientProvider>
|
||||
</LeaveToHomeProvider>
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,7 @@ import { type FC, useCallback, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
useClient,
|
||||
useClientManagerIfAny,
|
||||
useClientSessionState,
|
||||
} from "./ClientContext";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import { useClientLegacy } from "./ClientContext";
|
||||
import { useProfile } from "./profile/useProfile";
|
||||
import { defaultSettingsTab, SettingsModal } from "./settings/SettingsModal";
|
||||
import { UserMenu } from "./UserMenu";
|
||||
@@ -26,13 +21,7 @@ interface Props {
|
||||
export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { client } = useClient();
|
||||
const session = useClientSessionState();
|
||||
const passwordlessUser =
|
||||
session.state === "signedIn" && session.passwordlessUser;
|
||||
// Absent when the session is a host's, which Element Call cannot end
|
||||
const logout = useClientManagerIfAny()?.logout;
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const { client, logout, authenticated, passwordlessUser } = useClientLegacy();
|
||||
const { displayName, avatarUrl } = useProfile(client);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const onDismissSettingsModal = useCallback(
|
||||
@@ -54,9 +43,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
setSettingsModalOpen(true);
|
||||
break;
|
||||
case "logout":
|
||||
logout?.()
|
||||
.then(() => leaveToHome?.())
|
||||
.catch((e) => logger.error("Failed to log out", e));
|
||||
logout?.();
|
||||
break;
|
||||
case "login":
|
||||
navigate("/login", { state: { from: location } })?.catch((error) =>
|
||||
@@ -65,7 +52,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigate, location, logout, leaveToHome, setSettingsModalOpen],
|
||||
[navigate, location, logout, setSettingsModalOpen],
|
||||
);
|
||||
|
||||
const userName = client?.getUserIdLocalpart() ?? "";
|
||||
@@ -73,7 +60,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
<>
|
||||
<UserMenu
|
||||
preventNavigation={preventNavigation}
|
||||
isAuthenticated={client !== undefined}
|
||||
isAuthenticated={authenticated}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
avatarUrl={avatarUrl}
|
||||
onAction={onAction}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type FC } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { LoginPage } from "./LoginPage";
|
||||
import { ClientManagerProvider } from "../ClientContext";
|
||||
import { type ClientManager } from "../state/ClientSession";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("signs in with the session's manager and goes home", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.login).mockResolvedValue({} as MatrixClient);
|
||||
renderLogin(manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.login).toHaveBeenCalledWith(
|
||||
"https://hs.example.org",
|
||||
"alice",
|
||||
"hunter2",
|
||||
);
|
||||
expect(await screen.findByText("at /")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows why signing in failed", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.login).mockRejectedValue(new Error("Wrong password"));
|
||||
renderLogin(manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(await screen.findByText("Wrong password")).toBeVisible();
|
||||
expect(screen.getByText("at /login")).toBeVisible();
|
||||
});
|
||||
|
||||
async function submit(username: string, password: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("login_username"), username);
|
||||
await user.type(screen.getByTestId("login_password"), password);
|
||||
await user.click(screen.getByTestId("login_login"));
|
||||
}
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
const WhereAmI: FC = () => <span>at {useLocation().pathname}</span>;
|
||||
|
||||
function renderLogin(manager: ClientManager): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<LoginPage />
|
||||
<WhereAmI />
|
||||
</ClientManagerProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
+13
-4
@@ -11,9 +11,10 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
import Logo from "../icons/LogoLarge.svg?react";
|
||||
import { useClientManager } from "../ClientContext";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import styles from "./LoginPage.module.css";
|
||||
import { useInteractiveLogin } from "./useInteractiveLogin";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
@@ -23,7 +24,8 @@ export const LoginPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("login_title"));
|
||||
|
||||
const { login } = useClientManager();
|
||||
const { client, setClient } = useClient();
|
||||
const login = useInteractiveLogin(client);
|
||||
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
@@ -44,8 +46,15 @@ export const LoginPage: FC = () => {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
login(homeserver, usernameRef.current.value, passwordRef.current.value)
|
||||
.then(async () => {
|
||||
.then(async ([client, session]) => {
|
||||
if (!setClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClient(client, session);
|
||||
|
||||
const locationState = location.state;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
@@ -63,7 +72,7 @@ export const LoginPage: FC = () => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[login, location, navigate, homeserver],
|
||||
[login, location, navigate, homeserver, setClient],
|
||||
);
|
||||
// we need to limit the length of the homserver name to not cover the whole loginview input with the string.
|
||||
let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25);
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type FC } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { RegisterPage } from "./RegisterPage";
|
||||
import { ClientContextProvider, ClientManagerProvider } from "../ClientContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSessionState,
|
||||
} from "../state/ClientSession";
|
||||
|
||||
vi.mock("./useRecaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
execute: vi.fn().mockResolvedValue("captcha-response"),
|
||||
reset,
|
||||
recaptchaId: "recaptcha",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./useRegistrationTerms", () => ({
|
||||
useRegistrationTerms: () => ({ recaptchaKey: "site-key" }),
|
||||
}));
|
||||
|
||||
const reset = vi.fn();
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("waits for the session to be restored", () => {
|
||||
renderRegister({ state: "loading" }, mockManager());
|
||||
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("registers an account, signs in and goes home", async () => {
|
||||
const manager = mockManager();
|
||||
const setSignedIn = vi.fn();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn,
|
||||
});
|
||||
renderRegister({ state: "signedOut" }, manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"alice",
|
||||
"hunter2",
|
||||
"alice",
|
||||
"captcha-response",
|
||||
false,
|
||||
);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
expect(await screen.findByText("at /")).toBeVisible();
|
||||
});
|
||||
|
||||
test("upgrades a passwordless user to a full account", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn: vi.fn(),
|
||||
});
|
||||
renderRegister(
|
||||
{
|
||||
state: "signedIn",
|
||||
client: { getUserId: () => "@guest:example.org" } as MatrixClient,
|
||||
passwordlessUser: true,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"alice",
|
||||
"hunter2",
|
||||
"alice",
|
||||
"captcha-response",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shows why registering failed and resets the captcha", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockRejectedValue(new Error("Name taken"));
|
||||
renderRegister({ state: "signedOut" }, manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(await screen.findByText("Name taken")).toBeVisible();
|
||||
expect(reset).toHaveBeenCalled();
|
||||
expect(screen.getByText("at /register")).toBeVisible();
|
||||
});
|
||||
|
||||
async function submit(username: string, password: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("register_username"), username);
|
||||
await user.type(screen.getByTestId("register_password"), password);
|
||||
await user.type(screen.getByTestId("register_confirm_password"), password);
|
||||
await user.click(screen.getByTestId("register_register"));
|
||||
}
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
const WhereAmI: FC = () => <span>at {useLocation().pathname}</span>;
|
||||
|
||||
function renderRegister(
|
||||
state: ClientSessionState,
|
||||
manager: ClientManager,
|
||||
): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/register"]}>
|
||||
<ClientContextProvider
|
||||
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
|
||||
>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<RegisterPage />
|
||||
<WhereAmI />
|
||||
</ClientManagerProvider>
|
||||
</ClientContextProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
+17
-17
@@ -22,12 +22,8 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import {
|
||||
useClient,
|
||||
useClientManager,
|
||||
useClientSessionState,
|
||||
} from "../ClientContext";
|
||||
import { useRegistrationTerms } from "./useRegistrationTerms";
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "./useInteractiveRegistration";
|
||||
import styles from "./LoginPage.module.css";
|
||||
import Logo from "../icons/LogoLarge.svg?react";
|
||||
import { LoadingPage } from "../FullScreenView";
|
||||
@@ -41,13 +37,8 @@ export const RegisterPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("action.register"));
|
||||
|
||||
const session = useClientSessionState();
|
||||
const loading = session.state === "loading";
|
||||
const passwordlessUser =
|
||||
session.state === "signedIn" && session.passwordlessUser;
|
||||
const { client } = useClient();
|
||||
const authenticated = client !== undefined;
|
||||
const { register } = useClientManager();
|
||||
const { loading, authenticated, passwordlessUser, client, setClient } =
|
||||
useClientLegacy();
|
||||
|
||||
const confirmPasswordRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -56,7 +47,7 @@ export const RegisterPage: FC = () => {
|
||||
const [error, setError] = useState<Error>();
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
||||
const { recaptchaKey } = useRegistrationTerms();
|
||||
const { recaptchaKey, register } = useInteractiveRegistration(client);
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const onSubmitRegisterForm = useCallback(
|
||||
@@ -73,7 +64,7 @@ export const RegisterPage: FC = () => {
|
||||
setRegistering(true);
|
||||
|
||||
const recaptchaResponse = await execute();
|
||||
const { client: newClient, setSignedIn } = await register(
|
||||
const [newClient, session] = await register(
|
||||
userName,
|
||||
password,
|
||||
userName,
|
||||
@@ -104,7 +95,7 @@ export const RegisterPage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
setSignedIn();
|
||||
setClient?.(newClient, session);
|
||||
PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date());
|
||||
};
|
||||
|
||||
@@ -126,7 +117,16 @@ export const RegisterPage: FC = () => {
|
||||
reset();
|
||||
});
|
||||
},
|
||||
[register, location, navigate, passwordlessUser, reset, execute, client],
|
||||
[
|
||||
register,
|
||||
location,
|
||||
navigate,
|
||||
passwordlessUser,
|
||||
reset,
|
||||
execute,
|
||||
client,
|
||||
setClient,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { createClient, type MatrixClient, MatrixError } from "matrix-js-sdk";
|
||||
import type * as MatrixJsSdk from "matrix-js-sdk";
|
||||
|
||||
import { loginWithPassword, registerWithPassword } from "./interactiveAuth";
|
||||
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixJsSdk>()),
|
||||
createClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const credentials = {
|
||||
user_id: "@alice:example.org",
|
||||
device_id: "AAAA",
|
||||
access_token: "secret",
|
||||
};
|
||||
|
||||
// What interactive auth asks of the client besides the request itself
|
||||
const authClient = { generateClientSecret: () => "client-secret" };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("logs in with a password", async () => {
|
||||
const login = vi.fn().mockResolvedValue({ ...credentials, extra: "ignored" });
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
...authClient,
|
||||
login,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
|
||||
await expect(
|
||||
loginWithPassword("https://hs.example.org", "alice", "hunter2"),
|
||||
).resolves.toEqual(credentials);
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
baseUrl: "https://hs.example.org",
|
||||
});
|
||||
expect(login).toHaveBeenCalledWith("m.login.password", {
|
||||
identifier: { type: "m.id.user", user: "alice" },
|
||||
password: "hunter2",
|
||||
});
|
||||
});
|
||||
|
||||
test("registers, agreeing to the terms and answering the recaptcha", async () => {
|
||||
// The homeserver turns the first request away with the stages to complete
|
||||
// No error in it: that is the shape of a challenge rather than a refusal
|
||||
const challenge = new MatrixError(
|
||||
{
|
||||
session: "session",
|
||||
flows: [{ stages: ["m.login.terms", "m.login.recaptcha"] }],
|
||||
params: {},
|
||||
completed: [],
|
||||
},
|
||||
401,
|
||||
);
|
||||
const registerRequest = vi
|
||||
.fn()
|
||||
.mockImplementation(({ auth }: { auth?: { type: string } }) => {
|
||||
if (auth === undefined) throw challenge;
|
||||
challenge.data.completed = [
|
||||
...(challenge.data.completed as string[]),
|
||||
auth.type,
|
||||
];
|
||||
if (challenge.data.completed.length < 2) throw challenge;
|
||||
return credentials;
|
||||
});
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
...authClient,
|
||||
registerRequest,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
|
||||
await expect(
|
||||
registerWithPassword(
|
||||
"https://hs.example.org",
|
||||
"alice",
|
||||
"hunter2",
|
||||
"captcha",
|
||||
),
|
||||
).resolves.toEqual(credentials);
|
||||
|
||||
expect(registerRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: "alice",
|
||||
password: "hunter2",
|
||||
auth: expect.objectContaining({ type: "m.login.terms" }),
|
||||
}),
|
||||
);
|
||||
expect(registerRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({
|
||||
type: "m.login.recaptcha",
|
||||
response: "captcha",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
createClient,
|
||||
InteractiveAuth,
|
||||
type LoginResponse,
|
||||
type RegisterResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type StoredSession } from "../state/ClientSession";
|
||||
|
||||
/** What the homeserver hands out for a new session. */
|
||||
export type Credentials = Pick<
|
||||
StoredSession,
|
||||
"user_id" | "device_id" | "access_token"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Logs in to the given homeserver with a password.
|
||||
*/
|
||||
export async function loginWithPassword(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Credentials> {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (): Promise<LoginResponse> =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
}),
|
||||
stateUpdated: (): void => {},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "" }),
|
||||
});
|
||||
return credentials(await interactiveAuth.attemptAuth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an account on the given homeserver, accepting its terms and
|
||||
* answering its recaptcha challenge.
|
||||
*/
|
||||
export async function registerWithPassword(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
recaptchaResponse: string,
|
||||
): Promise<Credentials> {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (auth): Promise<RegisterResponse> =>
|
||||
authClient.registerRequest({
|
||||
username,
|
||||
password,
|
||||
auth: auth || undefined,
|
||||
}),
|
||||
stateUpdated: (nextStage, status): void => {
|
||||
if (status.error) {
|
||||
throw new Error(status.error);
|
||||
}
|
||||
|
||||
if (nextStage === "m.login.terms") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.terms",
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
} else if (nextStage === "m.login.recaptcha") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.recaptcha",
|
||||
response: recaptchaResponse,
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
}
|
||||
},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "dummy" }),
|
||||
});
|
||||
return credentials(await interactiveAuth.attemptAuth());
|
||||
}
|
||||
|
||||
// XXX: attemptAuth claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
function credentials(authData: unknown): Credentials {
|
||||
/* eslint-disable camelcase */
|
||||
const { user_id, access_token, device_id } = authData as Credentials;
|
||||
return { user_id, access_token, device_id };
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk";
|
||||
import {
|
||||
createClient,
|
||||
type LoginResponse,
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { type Session } from "../ClientContext";
|
||||
/**
|
||||
* This provides the login method to login using user credentials.
|
||||
* @param oldClient If there is an already authenticated client it should be passed to this hook
|
||||
* this allows the interactive login to sign out the client before logging in.
|
||||
* @returns A async method that can be called/awaited to log in with the provided credentials.
|
||||
*/
|
||||
export function useInteractiveLogin(
|
||||
oldClient?: MatrixClient,
|
||||
): (
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<[MatrixClient, Session]> {
|
||||
return useCallback<
|
||||
(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<[MatrixClient, Session]>
|
||||
>(
|
||||
async (homeserver: string, username: string, password: string) => {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (): Promise<LoginResponse> =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
}),
|
||||
stateUpdated: (): void => {},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "" }),
|
||||
});
|
||||
|
||||
// XXX: This claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||
const { user_id, access_token, device_id } =
|
||||
(await interactiveAuth.attemptAuth()) as any;
|
||||
const session = {
|
||||
user_id,
|
||||
access_token,
|
||||
device_id,
|
||||
passwordlessUser: false,
|
||||
};
|
||||
|
||||
// To not confuse the rust crypto sessions we need to logout the old client before initializing the new one.
|
||||
await oldClient?.logout(true);
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: homeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
/* eslint-enable camelcase */
|
||||
return [client, session];
|
||||
},
|
||||
[oldClient],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk";
|
||||
import {
|
||||
createClient,
|
||||
type MatrixClient,
|
||||
type RegisterResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { type Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export const useInteractiveRegistration = (
|
||||
oldClient?: MatrixClient,
|
||||
): {
|
||||
privacyPolicyUrl?: string;
|
||||
recaptchaKey?: string;
|
||||
register: (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser: boolean,
|
||||
) => Promise<[MatrixClient, Session]>;
|
||||
} => {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const authClient = useRef<MatrixClient | undefined>(undefined);
|
||||
if (!authClient.current) {
|
||||
authClient.current = createClient({
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
authClient.current!.registerRequest({}).catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url,
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, [isWidget]);
|
||||
|
||||
const register = useCallback(
|
||||
async (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser: boolean,
|
||||
): Promise<[MatrixClient, Session]> => {
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient.current!,
|
||||
doRequest: async (auth): Promise<RegisterResponse> =>
|
||||
authClient.current!.registerRequest({
|
||||
username,
|
||||
password,
|
||||
auth: auth || undefined,
|
||||
}),
|
||||
stateUpdated: (nextStage, status): void => {
|
||||
if (status.error) {
|
||||
throw new Error(status.error);
|
||||
}
|
||||
|
||||
if (nextStage === "m.login.terms") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.terms",
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
} else if (nextStage === "m.login.recaptcha") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.recaptcha",
|
||||
response: recaptchaResponse,
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
}
|
||||
},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "dummy" }),
|
||||
});
|
||||
|
||||
// XXX: This claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||
const { user_id, access_token, device_id } =
|
||||
(await interactiveAuth.attemptAuth()) as any;
|
||||
await oldClient?.logout(true);
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
await client.setDisplayName(displayName);
|
||||
|
||||
const session: Session = {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
passwordlessUser,
|
||||
};
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
if (passwordlessUser) {
|
||||
session.tempPassword = password;
|
||||
}
|
||||
|
||||
const user = client.getUser(client.getUserId()!)!;
|
||||
user.setRawDisplayName(displayName);
|
||||
user.setDisplayName(displayName);
|
||||
|
||||
return [client, session];
|
||||
},
|
||||
[oldClient],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, recaptchaKey, register };
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { useRegisterPasswordlessUser } from "./useRegisterPasswordlessUser";
|
||||
import { ClientManagerProvider } from "../ClientContext";
|
||||
import { type ClientManager } from "../state/ClientSession";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
|
||||
vi.mock("./useRecaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
execute: vi.fn().mockResolvedValue("captcha-response"),
|
||||
reset,
|
||||
recaptchaId: "recaptcha",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./useRegistrationTerms", () => ({
|
||||
useRegistrationTerms: () => ({
|
||||
privacyPolicyUrl: "https://hs.example.org/privacy",
|
||||
recaptchaKey: "site-key",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./generateRandomName", () => ({
|
||||
generateRandomName: () => "Random Name",
|
||||
}));
|
||||
|
||||
const reset = vi.fn();
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("registers a user under a random name and signs them in", async () => {
|
||||
const setSignedIn = vi.fn();
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, false),
|
||||
});
|
||||
await result.current.registerPasswordlessUser("Alice");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"Random Name",
|
||||
expect.stringMatching(/^.{16}$/),
|
||||
"Alice",
|
||||
"captcha-response",
|
||||
true,
|
||||
);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
expect(result.current.privacyPolicyUrl).toBe(
|
||||
"https://hs.example.org/privacy",
|
||||
);
|
||||
expect(result.current.recaptchaId).toBe("recaptcha");
|
||||
});
|
||||
|
||||
test("resets the captcha when registration fails", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockRejectedValue(new Error("Nope"));
|
||||
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, false),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow("Nope");
|
||||
expect(reset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot register on a session that is not ours", async () => {
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(null, false),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow("This session cannot register");
|
||||
});
|
||||
|
||||
test("never registers as a widget", async () => {
|
||||
const manager = mockManager();
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, true),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow(/embedded mode/);
|
||||
expect(manager.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
function wrapper(
|
||||
manager: ClientManager | null,
|
||||
isWidget: boolean,
|
||||
): FC<PropsWithChildren> {
|
||||
const params = { ...computeUrlParams(), isWidget };
|
||||
return ({ children }) => (
|
||||
<UrlParamsProvider value={params}>
|
||||
<ClientManagerProvider value={manager}>{children}</ClientManagerProvider>
|
||||
</UrlParamsProvider>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details.
|
||||
import { useCallback } from "react";
|
||||
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
|
||||
|
||||
import { useClientManagerIfAny } from "../ClientContext";
|
||||
import { useRegistrationTerms } from "../auth/useRegistrationTerms";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
@@ -21,16 +21,16 @@ interface UseRegisterPasswordlessUserType {
|
||||
}
|
||||
|
||||
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
// Null in the widget, which never registers anyone
|
||||
const register = useClientManagerIfAny()?.register;
|
||||
const { setClient } = useClient();
|
||||
const { isWidget } = useUrlParams();
|
||||
const { privacyPolicyUrl, recaptchaKey } = useRegistrationTerms();
|
||||
const { privacyPolicyUrl, recaptchaKey, register } =
|
||||
useInteractiveRegistration();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const registerPasswordlessUser = useCallback(
|
||||
async (displayName: string) => {
|
||||
if (!register) {
|
||||
throw new Error("This session cannot register");
|
||||
if (!setClient) {
|
||||
throw new Error("No client context");
|
||||
}
|
||||
if (isWidget) {
|
||||
throw new Error(
|
||||
@@ -41,20 +41,20 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
try {
|
||||
const recaptchaResponse = await execute();
|
||||
const userName = generateRandomName();
|
||||
const { setSignedIn } = await register(
|
||||
const [client, session] = await register(
|
||||
userName,
|
||||
secureRandomString(16),
|
||||
displayName,
|
||||
recaptchaResponse,
|
||||
true,
|
||||
);
|
||||
setSignedIn();
|
||||
setClient(client, session);
|
||||
} catch (e) {
|
||||
reset();
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[execute, reset, register, isWidget],
|
||||
[execute, reset, register, setClient, isWidget],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type * as MatrixJsSdk from "matrix-js-sdk";
|
||||
import { createClient, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
|
||||
import { useRegistrationTerms } from "./useRegistrationTerms";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixJsSdk>()),
|
||||
createClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const registerRequest = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
registerRequest,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("reads the terms the homeserver asks for from a registration attempt", async () => {
|
||||
registerRequest.mockRejectedValue({
|
||||
data: {
|
||||
params: {
|
||||
"m.login.terms": {
|
||||
policies: {
|
||||
privacy_policy: { en: { url: "https://hs.example.org/privacy" } },
|
||||
},
|
||||
},
|
||||
"m.login.recaptcha": { public_key: "site-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(false),
|
||||
});
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
baseUrl: "https://hs.example.org",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: "https://hs.example.org/privacy",
|
||||
recaptchaKey: "site-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("has nothing to agree to where the homeserver asks for nothing", async () => {
|
||||
registerRequest.mockRejectedValue({ data: { params: {} } });
|
||||
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(false),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(registerRequest).toHaveBeenCalled());
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: undefined,
|
||||
recaptchaKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("asks the homeserver nothing as a widget", () => {
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(true),
|
||||
});
|
||||
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: undefined,
|
||||
recaptchaKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
function wrapper(isWidget: boolean): FC<PropsWithChildren> {
|
||||
const params = { ...computeUrlParams(), isWidget };
|
||||
return ({ children }) => (
|
||||
<UrlParamsProvider value={params}>{children}</UrlParamsProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createClient } from "matrix-js-sdk";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
/**
|
||||
* What the homeserver asks of a registration: its privacy policy to agree to
|
||||
* and the recaptcha key to answer a challenge with.
|
||||
*/
|
||||
export function useRegistrationTerms(): {
|
||||
privacyPolicyUrl?: string;
|
||||
recaptchaKey?: string;
|
||||
} {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
createClient({ baseUrl: Config.defaultHomeserverUrl()! })
|
||||
.registerRequest({})
|
||||
.catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en
|
||||
?.url,
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, [isWidget]);
|
||||
|
||||
return { privacyPolicyUrl, recaptchaKey };
|
||||
}
|
||||
@@ -90,42 +90,3 @@ Please see LICENSE in the repository root for full details.
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.removable {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.removable > .tile {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Clear of the target for choosing the tile. */
|
||||
.remove {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
translate: 20% -20%;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 28px;
|
||||
block-size: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
color: var(--cpd-color-icon-critical-primary);
|
||||
box-shadow: 0 1.2px 2.4px 0 rgb(0 0 0 / 15%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
background: var(--cpd-color-bg-critical-subtle-hovered);
|
||||
box-shadow: 0 0 0 var(--cpd-border-width-1)
|
||||
var(--cpd-color-border-critical-subtle);
|
||||
}
|
||||
|
||||
.removable:hover .remove,
|
||||
.removable:focus-within .remove {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, type KeyboardEvent, type ReactElement } from "react";
|
||||
import { type FC, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { InlineSpinner, MenuItem, Tooltip } from "@vector-im/compound-web";
|
||||
import { InlineSpinner, MenuItem } from "@vector-im/compound-web";
|
||||
import {
|
||||
BlockIcon,
|
||||
BlurIcon,
|
||||
CheckCircleSolidIcon,
|
||||
CloseIcon,
|
||||
PlusIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./BackgroundEffectGrid.module.css";
|
||||
@@ -29,8 +27,6 @@ export interface BackgroundEffectGridItem {
|
||||
kind: "none" | "blur" | "image";
|
||||
/** For kind "image". */
|
||||
imageUrl?: string;
|
||||
/** Whether it is the user's to remove. */
|
||||
removable?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -42,12 +38,6 @@ interface Props {
|
||||
settling?: boolean;
|
||||
/** Id of the text saying what the user should know before choosing. */
|
||||
describedBy?: string;
|
||||
/** Offers to add a background of one's own. Omit to leave that tile out. */
|
||||
onAdd?: () => void;
|
||||
addLabel?: string;
|
||||
/** Removes a removable one, never the one in force. Omit to offer none. */
|
||||
onRemove?: (id: string) => void;
|
||||
removeLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,10 +51,6 @@ export const BackgroundEffectGrid: FC<Props> = ({
|
||||
onSelect,
|
||||
settling = false,
|
||||
describedBy,
|
||||
onAdd,
|
||||
addLabel,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const choose = (id: string): void => {
|
||||
@@ -80,30 +66,7 @@ export const BackgroundEffectGrid: FC<Props> = ({
|
||||
: t("background_effects.numbered", { n: ++pictures });
|
||||
const checked = effect.id === selected;
|
||||
const disabled = onSelect === undefined && effect.kind !== "none";
|
||||
const remove =
|
||||
effect.removable && !checked && onRemove !== undefined
|
||||
? (): void => onRemove(effect.id)
|
||||
: undefined;
|
||||
const removeProps =
|
||||
remove === undefined
|
||||
? {}
|
||||
: {
|
||||
"aria-keyshortcuts": "Delete",
|
||||
onKeyDown: (e: KeyboardEvent): void => {
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
e.preventDefault();
|
||||
remove();
|
||||
},
|
||||
};
|
||||
const content = (
|
||||
<TileContent
|
||||
effect={effect}
|
||||
label={label}
|
||||
checked={checked}
|
||||
settling={checked && settling}
|
||||
/>
|
||||
);
|
||||
const tile = (
|
||||
return (
|
||||
<MenuItem
|
||||
key={effect.id}
|
||||
role="menuitemradio"
|
||||
@@ -125,57 +88,21 @@ export const BackgroundEffectGrid: FC<Props> = ({
|
||||
choose(effect.id);
|
||||
}
|
||||
}
|
||||
{...removeProps}
|
||||
>
|
||||
{content}
|
||||
<TileContent
|
||||
effect={effect}
|
||||
label={label}
|
||||
checked={checked}
|
||||
settling={checked && settling}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
if (remove === undefined) return tile;
|
||||
// Beside the item, not in it: inside, the item takes the press first and
|
||||
// the tile is chosen instead.
|
||||
return (
|
||||
<div key={effect.id} className={styles.removable}>
|
||||
{tile}
|
||||
<Tooltip label={removeLabel ?? ""}>
|
||||
{/* Not a control of its own, so the menu keeps one keyboard model:
|
||||
the keyboard reaches the same action through Delete. */}
|
||||
<span aria-hidden className={styles.remove} onClick={remove}>
|
||||
<CloseIcon width={20} height={20} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// A command rather than a choice, so an item beside the radios.
|
||||
const addContent = (
|
||||
<>
|
||||
<span aria-hidden className={styles.swatch}>
|
||||
<PlusIcon width={24} height={24} />
|
||||
</span>
|
||||
<VisuallyHidden>{addLabel}</VisuallyHidden>
|
||||
</>
|
||||
);
|
||||
const addTile =
|
||||
onAdd === undefined ? null : (
|
||||
<MenuItem
|
||||
label={null}
|
||||
hideChevron
|
||||
className={styles.tile}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onAdd();
|
||||
}}
|
||||
>
|
||||
{addContent}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
// A group of its own, as the section's can't carry the description.
|
||||
<div role="group" aria-describedby={describedBy} className={styles.grid}>
|
||||
{tiles}
|
||||
{addTile}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -127,21 +127,16 @@ export const Default: Story = {
|
||||
selectBackgroundEffect: fn(),
|
||||
backgroundEffect: "blur",
|
||||
backgroundEffects: [
|
||||
{ id: "none", kind: "none", removable: false },
|
||||
{ id: "blur", kind: "blur", removable: false },
|
||||
{ id: "none", kind: "none" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
addBackgroundImage: fn(),
|
||||
removeBackgroundEffect: fn(),
|
||||
cameraTrack: null,
|
||||
hangup: fn(),
|
||||
buttonSize: "lg",
|
||||
showFooter: true,
|
||||
|
||||
@@ -33,13 +33,6 @@ import { useBehavior } from "../useBehavior";
|
||||
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
|
||||
import { LayoutSwitch } from "../room/LayoutSwitch";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import { type UnusableReason } from "../tracks/backgroundImages";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
export interface BackgroundImageRefusal {
|
||||
reason: UnusableReason | "not-kept";
|
||||
}
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
@@ -67,10 +60,6 @@ export interface FooterActions {
|
||||
toggleVideo: (() => void) | undefined;
|
||||
/** Undefined where background effects can't be chosen. */
|
||||
selectBackgroundEffect: ((id: string) => void) | undefined;
|
||||
/** Undefined where no background can be added. */
|
||||
addBackgroundImage: ((file: File) => void) | undefined;
|
||||
/** Removes an added background by its option id; never the one in force. */
|
||||
removeBackgroundEffect: ((id: string) => void) | undefined;
|
||||
toggleScreenSharing: (() => void) | undefined;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings: (() => void) | undefined;
|
||||
@@ -92,10 +81,6 @@ export interface FooterState {
|
||||
backgroundEffectNotice: "unavailable" | "slow" | undefined;
|
||||
/** Whether the first effect chosen is still being prepared. */
|
||||
backgroundEffectSettling: boolean;
|
||||
/** Why the last file offered couldn't be kept, if it couldn't. */
|
||||
backgroundImageRefusal: BackgroundImageRefusal | undefined;
|
||||
/** The camera to preview, with the effect in force; null while it is off. */
|
||||
cameraTrack: LocalVideoMediaTrack | null;
|
||||
showFooter: boolean;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
@@ -183,24 +168,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
|
||||
const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$);
|
||||
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
|
||||
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
|
||||
const cameraTrack = useBehavior(vm.cameraTrack$);
|
||||
const refusal = useBehavior(vm.backgroundImageRefusal$);
|
||||
const refusalMessage = useMemo(() => {
|
||||
switch (refusal?.reason) {
|
||||
case undefined:
|
||||
return undefined;
|
||||
case "not-an-image":
|
||||
return { text: t("error.background_not_an_image") };
|
||||
case "animated":
|
||||
return { text: t("error.background_animated") };
|
||||
case "undecodable":
|
||||
return { text: t("error.background_undecodable") };
|
||||
case "not-kept":
|
||||
return { text: t("error.background_not_kept") };
|
||||
}
|
||||
}, [refusal, t]);
|
||||
const backgroundEffects = useBehavior(vm.backgroundEffects$);
|
||||
const buttonSize = useBehavior(vm.buttonSize$);
|
||||
const showLogo = useBehavior(vm.showLogo$);
|
||||
@@ -267,10 +234,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
selectedBackgroundEffect={backgroundEffect}
|
||||
onSelectBackgroundEffect={selectBackgroundEffect}
|
||||
backgroundEffectSettling={backgroundEffectSettling}
|
||||
onAddBackgroundImage={addBackgroundImage}
|
||||
onRemoveBackgroundEffect={removeBackgroundEffect}
|
||||
selfPreview={<SelfPreview track={cameraTrack} />}
|
||||
backgroundImageRefusal={refusalMessage}
|
||||
backgroundEffectNotice={
|
||||
backgroundEffectNotice === "unavailable"
|
||||
? t("background_effects.unavailable")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user