Compare commits

..
Author SHA1 Message Date
Timo K. 80ca4d7e0f Add basic tests for refactored files 2026-11-12 17:47:27 +01:00
Timo K. a2e3a2c13f review 2026-11-12 13:25:45 +01:00
Timo K. 8d5d482771 Remove the derived hooks from ClientContext
Those were hooks just used in one place. We move them directly into
their component.
This is acceptable because the disconnect banner needs a new appraoch
when not having a syncing clinet but just a matrixBackend.
2026-11-12 13:25:45 +01:00
Timo K.andClaude Fable 5.1 87f2726dd8 Hand the client provider a session
ClientProvider had a `client` prop and a `loadClient` prop and ran the
session in an effect: restoring, exposing setClient, changePassword and
logout, and holding the "load" broadcast. It takes one `ClientSession`
now and only renders what the session says, so the context carries the
session itself rather than a parallel `ClientState` shape.

- main.tsx builds a `StandaloneClientSession` for the SPA and a
  `pendingHostClientSession` for the widget; App.tsx no longer waits for
  the widget client itself. The component wraps the host's client in a
  `hostClientSession`.
- The login and registration pages call `session.login` and
  `session.register`, and sign a registration in with `setSignedIn` once
  they are done with the account, in place of `setClient`. The two
  wrapper hooks and `utils/spa` go; their logic lives in the holder.
- `useClient` and `useClientLegacy` keep their shapes minus `setClient`;
  `useClientSession` and `useClientSessionState` are the new way in.
  Logging out leaves for home and resets analytics from `useClientLegacy`,
  as before, around the session's own logout.
- `useClientDisconnected` reads the session state directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 13:25:45 +01:00
Timo K.andClaude Fable 5.1 5fb9d6a81e Add a client session holder for the standalone app
Where the client comes from, and what may be done with the session
behind it, is state that outlives any React tree: main.tsx, the widget
and the component each need to construct a different one and hand it
down. `ClientSession` names that: a Behavior of the session state plus
the actions a session offers, which are absent when the session is not
Element Call's to manage. `hostClientSession` wraps a client a host
already has, `pendingHostClientSession` one still arriving over the
widget API.

`StandaloneClientSession` is the one for the SPA. Nothing in it is new
logic: `restore` is `initSPA` from `utils/spa`; `login`, `register` and
`createClient` are the tails of `useInteractiveLogin` and
`useInteractiveRegistration` after the auth split; `changePassword`,
`logout` and the "load" broadcast that shuts other instances down are
lifted out of `ClientProvider`. Registering and signing in are separate
steps, as a caller may have more to do with the account before the
session changes hands. `StoredSession` is `Session` from the context,
which goes once the provider takes a session.

Nothing consumes the holder yet; the next commit switches the provider
over. Both are tested through every state here, while the code they
replace is still around to compare against.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 13:25:45 +01:00
Timo K.andClaude Fable 5.1 ba6bcdc4eb Separate interactive auth from client creation
The login and registration hooks each did three things: talk the
user-interactive auth flow through with the homeserver, start a client
on the credentials it handed out, and, for registration, fetch the terms
and recaptcha key. Only the first is auth.

`interactiveAuth.ts` keeps that part as two plain functions, with the UIA
bodies moved verbatim, so they can be unit tested without React or a
client store: `loginWithPassword` and `registerWithPassword` resolve to
the credentials the homeserver issued and nothing else. Fetching the
terms is `useRegistrationTerms` now. The two hooks are left as thin
wrappers that compose the pieces, with the same signatures, so their
callers do not change in this commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 13:25:45 +01:00
Timo K.andClaude Fable 5.1 56d73d1dfc Track the client's connection in a hook of its own
Whether the client is disconnected is a fact about the client's sync, not
about the session, yet the provider owned the sync listener and put the
flag on the context state, so the banner had to reach through the whole
`ClientState` to read it. `useClientDisconnected` listens on whichever
client is signed in, tagging its verdict with that client so a swapped
client does not inherit the old one's before its first sync.

The avatar, which only needs the client, takes it from `useClient` in the
same spirit: consumers read the narrowest hook that answers them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 13:25:45 +01:00
Timo K.andClaude Fable 5.1 89ad1ec936 Read reaction support from the host bridge
The client context copied `hostBridge.supportsReactions` into a
`supportedFeatures` field, and the one consumer, the reactions sender,
read it from there. It reads the host bridge itself now, which is where
the fact comes from; the context carries one thing less that has nothing
to do with the client.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 13:25:45 +01:00
Timo K.andClaude Fable 5.1 b9a3cc6678 Reword the store worker comments
Shorter notes on why `initClient` has a module of its own and why
`utils/matrix` must not import a client store; the reasoning is the same.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 13:25:45 +01:00
Johannes Marbach 99f48ff987 Merge pull request #4454 from element-hq/johannes/lk-jwt-service-0.10.0
Bump lk-jwt-service to 0.10.0
2026-11-12 13:01:09 +01:00
Timo 6846c8dd3e Merge pull request #4431 from element-hq/toger5/component-no-store-worker
Keep the store worker out of the component package
2026-11-12 12:27:47 +01:00
Johannes Marbach 28ee707ace Bump lk-jwt-service to 0.10.0 2026-11-12 08:29:26 +01:00
Timo K.andClaude Fable 5.1 3100c1c410 Cover the standalone client's initialisation and session restore
Neither had a test before the move, so the moved lines showed up as
uncovered. The client initialisation is tested through a fake client
and mocked stores: which store it syncs into with and without
IndexedDB, with and without the worker, the fallback to memory when
the IndexedDB store cannot start, clearing the stores only when not
restoring, carrying on without crypto, and counting as ready only
after a sync from the network rather than the one restored from the
store. The session restore is tested for no stored session, a
restored one, one the homeserver no longer knows, and any other
failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 19:24:21 +01:00
Timo K.andClaude Fable 5.1 fc1e91f54c Keep the store worker out of the component package
The package carried the standalone app's IndexedDB store worker: 11 MB
with a copy of the Matrix SDK and its crypto wasm, referenced by an
absolute path no host could serve, and never used by the component,
which takes the host's client. The bundler emits the worker file as
soon as it loads the `?worker` import, used or not, and the import sat
in the Matrix utilities the component reaches for the room URLs.

The client initialisation moves into a module of its own, with the
worker import, which only the standalone login, registration and
session restore paths import. ClientProvider takes the session loader
through a `loadClient` prop instead of importing it itself, since even
a dynamic import keeps a module in the package: the standalone app
passes a lazy `initSPA` from App.tsx, the component passes nothing,
and given neither a client nor a loader no one is signed in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 19:24:21 +01:00
renovate[bot] 96949a57da Update ghcr.io/element-hq/element-web:develop Docker digest to 8dc3767 (#4447)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-11 14:47:22 +00:00
Johannes Marbach 2e05ef77a0 Merge pull request #4441 from element-hq/johannes/flakes
Try to fix flaky background tests
2026-11-11 15:37:31 +01:00
Johannes Marbach 73826ec4f9 Reformat 2026-11-11 15:29:03 +01:00
Johannes Marbach 1135c02e6f Merge branch 'main' into johannes/flakes 2026-11-11 13:53:30 +01:00
Johannes MarbachandTimo 9dc425b5f8 Apply batched suggestions from code review
Improve comments

Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2026-11-11 13:52:05 +01:00
Johannes Marbach c1b94b62dd Merge pull request #4434 from element-hq/johannes/intent
Stabilise m.call.intent
2026-11-11 11:02:22 +01:00
Johannes Marbach fa6efd9dad Merge branch 'main' into johannes/intent 2026-11-11 10:45:51 +01:00
Johannes Marbach 6906404a64 Pin ubuntu image to 24.04 because 26.04 breaks Playwright, renovate should update it 2026-11-11 10:20:44 +01:00
Johannes Marbach 98e8343d0b Stabilise m.call.intent 2026-11-11 10:10:33 +01:00
Timo 88659d6498 Merge pull request #4444 from element-hq/toger5/better-component-readme
Shorten component README
2026-11-10 16:56:40 +01:00
Timo K. 704cbd65a6 Shorten component readme
- At the end there were not that many unique facts in there. It now
   only lists the relevant bits without much text around it.
2026-11-10 14:31:12 +01:00
Timo 5f59283b4a Merge pull request #4428 from element-hq/toger5/component-assets-as-files
Ship the component's assets as files beside the module
2026-11-10 14:18:30 +01:00
Johannes Marbach 07dc08cd1c Merge pull request #4440 from element-hq/johannes/embedded
Make one site use the embedded build in playwright tests
2026-11-10 07:46:51 +01:00
Johannes Marbach 60d6029184 Delint 2026-11-10 07:38:23 +01:00
Johannes Marbach f41bc2589a Merge branch 'main' into johannes/embedded 2026-11-10 07:36:15 +01:00
Johannes Marbach ceeb26b271 Remove test.slow() 2026-11-10 07:30:57 +01:00
Johannes Marbach e822f967c1 Merge pull request #4442 from element-hq/johannes/shard
Shard playwright tests
2026-11-10 07:22:10 +01:00
renovate[bot] ec46b04b71 Update ghcr.io/element-hq/element-web:develop Docker digest to 37d88b4 (#4443)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 03:24:30 +00:00
Johannes Marbach 4f3dc9af75 Shard playwright tests 2026-11-09 13:00:52 +01:00
Johannes Marbach cfd8312e46 Try to fix flaky background tests 2026-11-09 12:20:00 +01:00
renovate[bot] 38edc47f64 Update ghcr.io/element-hq/element-web:develop Docker digest to 0c39f7b (#4439)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-09 09:29:45 +00:00
Johannes Marbach ab4f0f0f77 Make one site use the embedded build in playwright tests 2026-11-09 10:05:04 +01:00
renovate[bot] 9411a56afc Update ghcr.io/element-hq/element-web:develop Docker digest to f30be05 (#4438)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-08 01:46:48 +00:00
Timo K.andClaude Fable 5.1 a5374791e4 Resolve asset references from the chunk that holds them
The runtime path was `./` plus the asset's path from the package root,
which is right for every chunk the build emits today, all of which sit
at that root, and wrong for one emitted under a directory. Vite names
the referencing chunk, so the path is now computed relative to it.

The sound loader kept a branch for sounds inlined as data URLs, with a
comment saying the component build produces them. No build does any
more, and no host would inline a sound of this size, so the branch and
its tests go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 16:06:05 +01:00
renovate[bot] a0601876d8 Update ghcr.io/element-hq/element-web:develop Docker digest to eada111 (#4435)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-07 01:07:59 +00:00
Timo K.andClaude Fable 5.1 797a86815b Ship the component's assets as files beside the module
The component module was 18 MB, of which 15 MB was MediaPipe's runtime
as a base64 data URL, with the segmentation model, every sound and the
background pictures inlined the same way: a library build inlines every
asset unless its import says `no-inline`. Every host downloaded all of
it before the first call, whether or not a background effect was ever
switched on, and a host with a Content Security Policy without
`script-src data:` could not run effects at all, since MediaPipe
injects its loader as a script tag.

The imports now say `no-inline`, and the build renders each reference
as `new URL("./assets/…", import.meta.url)`, which webpack 5, Vite and
Rollup follow as an asset of the host's own; the `./` is spelled out
because a bare `assets/…` is read as a package name. The module is
2.3 MB. The runtime is fetched when an effect is first switched on and
cached on its own. MediaPipe's files come through the package's
`exports` rather than a path into node_modules.

MediaPipe's JavaScript (160 kB) stays in the module: LiveKit's track
processors import it statically, so a dynamic import moves nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 14:39:25 +01:00
126 changed files with 4258 additions and 2622 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ permissions:
jobs:
prevent-blocked:
name: Prevent blocked
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
pull-requests: read
steps:
@@ -17,7 +17,7 @@ env:
jobs:
build_and_deploy:
name: Build & publish docker
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: write # required to upload release asset
packages: write # needed for publishing packages to GHCR
+1 -1
View File
@@ -29,7 +29,7 @@ on:
jobs:
build:
name: Build Element Call
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+2 -2
View File
@@ -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-latest
runs-on: ubuntu-24.04
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-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+1 -1
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
pr-changelog-label:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
with:
+1 -1
View File
@@ -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-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+1 -1
View File
@@ -32,7 +32,7 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
deployments: write
environment: Netlify
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
lint:
name: Lint, format & type check
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+1 -1
View File
@@ -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-latest
runs-on: ubuntu-24.04
outputs:
pr_number: ${{ steps.prdetails.outputs.pr_id }}
pr_data_json: ${{ steps.prdetails.outputs.data }}
+3 -3
View File
@@ -17,7 +17,7 @@ permissions: {}
jobs:
versioning:
name: Versioning
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
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-latest
runs-on: ubuntu-24.04
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-latest
runs-on: ubuntu-24.04
# 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-latest
runs-on: ubuntu-24.04
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-latest
runs-on: ubuntu-24.04
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-latest
runs-on: ubuntu-24.04
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-latest
runs-on: ubuntu-24.04
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-latest
runs-on: ubuntu-24.04
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-latest
runs-on: ubuntu-24.04
# 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:
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
needs: build_element_call
if: always()
name: Publish tarball
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
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-latest
runs-on: ubuntu-24.04
steps:
- name: Add release note
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
+68 -4
View File
@@ -25,7 +25,7 @@ on:
jobs:
vitest:
name: Run unit tests
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -71,10 +71,27 @@ jobs:
flags: unittests
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
name: Run end-to-end tests (${{ matrix.part }})
if: ${{ !inputs.disable_e2e_tests }}
timeout-minutes: 60
runs-on: ubuntu-latest
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
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -107,17 +124,64 @@ 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
run: pnpm exec playwright test
ARGS: ${{ matrix.args }}
# Unquoted on purpose: the part's arguments are several words
run: pnpm exec playwright test $ARGS
- 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
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
download:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: read
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs:
upload:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
contents: read
+1 -1
View File
@@ -10,7 +10,7 @@ permissions: {}
jobs:
zizmor:
name: Run zizmor 🌈
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
+48 -99
View File
@@ -221,119 +221,68 @@ See also:
- [Developing with linked packages](./docs/linking.md)
#### Element Call as a component (experimental)
#### Element Call as a component
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
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.
```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:
##### Install
```sh
npm install @element-hq/element-call-component
```
To use an unreleased commit, a host can instead install it as a git dependency
on the `component` directory of this repository,
Releases publish the package with the same version as Element Call. For an
unreleased commit, depend on this repository directly:
```json
"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component"
```
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)
This builds on install, so the host needs pnpm (via Corepack) and must allow
the build (`allowBuilds` in its `pnpm-workspace.yaml`).
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).
##### Use
- 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.
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.
### Backend
+13 -7
View File
@@ -73,6 +73,7 @@ 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";
@@ -189,20 +190,25 @@ 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 }));
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,
),
);
return (): void => {
setVm(null);
scope.end();
};
}, [controlledAudioDevices, callIntent]);
}, [controlledAudioDevices, callIntent, client]);
const room = client.getRoom(roomId);
const rtcSession = useMemo(
@@ -250,7 +256,7 @@ export const ElementCall: FC<ElementCallProps> = ({
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<ClientProvider session={vm.clientSession}>
<MediaDevicesContext value={vm.mediaDevices}>
<BackgroundEffectsProvider
effects={vm.backgroundEffects}
+2 -2
View File
@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
image: ghcr.io/element-hq/lk-jwt-service:0.10.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.8.0
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
pull_policy: always
hostname: auth-server-1
environment:
+7 -2
View File
@@ -19,7 +19,12 @@ 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:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
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:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
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
-5
View File
@@ -10,11 +10,6 @@
- Owning a resource: a class taking the scope in its constructor — `MediaDevices`,
`MuteStates`, `TileStore`, `Publisher`, `Connection`. Both styles are current;
don't convert one to the other in passing.
- A factory's positional parameters are what the thing is made of: required, no
sensible default. Its trailing `options` bag is how the host wants it to
behave: every field optional, each defaulting to standalone behaviour, so `{}`
works. A value with no default is never an option; a test seam is never an
option either — mock the dependency it comes from. See `createCallViewModel$`.
- `foo$` is an observable. `Behavior<T>`, an observable with a current value, is
the default for anything a view reads.
+4
View File
@@ -34,6 +34,10 @@ 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
+1 -1
View File
@@ -107,7 +107,7 @@
"livekit-client": "^2.18.1",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a",
"matrix-widget-api": "^1.18.0",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
+33 -21
View File
@@ -26,6 +26,19 @@ 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.
*/
@@ -43,11 +56,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; `html` is uploaded as
* an artifact and must never try to open a browser there.
* 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.
*/
reporter: process.env.CI
? [["list"], ["github"], ["html", { open: "never" }]]
? [["list"], ["github"], ["blob"]]
: [["list"], ["html"]],
/* Stop a broken run early on CI instead of waiting out every retry. */
maxFailures: process.env.CI ? 5 : undefined,
@@ -63,29 +76,28 @@ 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/**",
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",
],
},
},
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,
},
{
name: "firefox",
dependencies: ["build-embedded"],
testIgnore: "**/mobile/**",
use: {
...devices["Desktop Firefox"],
+3 -2
View File
@@ -127,13 +127,14 @@ 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) => {
addUser: async ({ browser }, use, testInfo) => {
await use(
async (
username: string,
host: string = HOST1,
): Promise<UserBaseFixture> => {
const uniqueSuffix = Date.now();
// Tests in parallel workers can reach this in the same millisecond.
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
const { page, clientHandle, mxId } = await TestHelpers.registerUser(
browser,
`${username.toLowerCase()}_${uniqueSuffix}`,
+20
View File
@@ -0,0 +1,20 @@
/*
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",
});
});
@@ -0,0 +1,56 @@
/*
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);
},
);
}
+5 -1
View File
@@ -144,7 +144,11 @@ export class TestHelpers {
await this.dismissStartupToasts(page);
await TestHelpers.setDevToolElementCallDevUrl(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);
}
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
+16 -9
View File
@@ -191,8 +191,8 @@ importers:
specifier: ^1.9.1
version: 1.9.2
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
specifier: github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a
matrix-widget-api:
specifier: ^1.18.0
version: 1.20.0
@@ -4734,8 +4734,8 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
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}
version: 45.0.0
engines: {node: '>=22.0.0'}
@@ -4946,8 +4946,8 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-retry@8.0.1:
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
p-retry@9.0.0:
resolution: {integrity: sha512-HuEats8gYn/UUYa5uyveyQGv75mQmwdFtuMgnZlS12dj/7bdnJSO6keaTjgcZUhvkANTj5EO3lACUmV3xmgj0Q==}
engines: {node: '>=22'}
p-try@2.2.0:
@@ -5318,6 +5318,10 @@ 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:
@@ -10409,7 +10413,7 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
dependencies:
'@babel/runtime': 8.0.8
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
@@ -10419,7 +10423,8 @@ snapshots:
loglevel: 1.9.2
matrix-events-sdk: 0.0.1
matrix-widget-api: 1.20.0
p-retry: 8.0.1
p-retry: 9.0.0
re2js: 2.9.1
sdp-transform: 3.0.0
unhomoglyph: 1.0.6
@@ -10741,7 +10746,7 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-retry@8.0.1:
p-retry@9.0.0:
dependencies:
is-network-error: 1.3.2
@@ -11165,6 +11170,8 @@ 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
+5 -5
View File
@@ -132,16 +132,16 @@ export async function createMatrixRTCSdk(
room,
mediaDevices,
muteStates,
encryptionSystem,
// The bundle owns its page, so the body is the space it has
scope.behavior(observeElementSize$(document.body)),
of({}),
of({}),
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem,
capture,
hostBridge,
// The bundle owns its page, so the body is the space it has
windowSize$: scope.behavior(observeElementSize$(document.body)),
},
of({}),
of({}),
);
// Once the hang-up has gone through, unstick and ask the host to close us
vm.leave$
+1
View File
@@ -162,6 +162,7 @@ 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();
+1
View File
@@ -107,6 +107,7 @@ export function createLocalMedia(
if (source !== "microphone" && source !== "camera") return;
capturedTracks.get(source)?.stop();
capturedTracks.delete(source);
requests.delete(source);
publishCapturedTracks();
};
+1
View File
@@ -184,6 +184,7 @@ export function createRTCParticipation(
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
matrixRTCMode,
sendNotificationType: options.sendNotificationType,
callIntent: options.callIntent,
applicationData: options.applicationData,
timings,
delayedLeave,
+8 -4
View File
@@ -12,6 +12,7 @@ Please see LICENSE in the repository root for full details.
*/
import {
type RTCCallIntent,
type RTCNotificationType,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -103,11 +104,14 @@ 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, apart from `m.call.intent`
* (`"audio"` or `"video"`), which the membership format carries as a
* field of its own.
* under namespaced keys. Opaque to the SDK.
*/
applicationData?: Record<string, unknown>;
/** Timings the host has configured; the defaults otherwise. */
@@ -287,7 +291,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. `m.call.intent`. */
/** What the member's application says about itself, e.g. `intent`. */
applicationData$: Behavior<Record<string, unknown>>;
displayName$: Behavior<string>;
+8 -6
View File
@@ -60,7 +60,7 @@ describe("joinJsSdkSession", () => {
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transportWithAlias], {
notificationType: "ring",
callIntent: undefined,
applicationData: {},
applicationData: undefined,
manageMediaKeys: true,
delayedLeaveEventRestartMs: 300_000,
delayedLeaveEventDelayMs: 3_600_000,
@@ -95,13 +95,14 @@ describe("joinJsSdkSession", () => {
);
});
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
it("passes the call intent and application data through 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,
applicationData: { "m.call.intent": "audio", custom: 1 },
callIntent: "m.audio",
applicationData: { custom: 1 },
timings: defaultSessionTimings,
delayedLeave: defaultSessionTimings.delayedLeave,
});
@@ -110,7 +111,7 @@ describe("joinJsSdkSession", () => {
ownMemberMock,
[transportWithAlias],
expect.objectContaining({
callIntent: "audio",
callIntent: "m.audio",
applicationData: { custom: 1 },
unstableSendStickyEvents: true,
}),
@@ -124,7 +125,8 @@ describe("joinJsSdkSession", () => {
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
encryptMedia: true,
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
applicationData: { "m.call.intent": "audio", custom: 1 },
callIntent: "m.audio",
applicationData: { custom: 1 },
timings: defaultSessionTimings,
delayedLeave: defaultSessionTimings.delayedLeave,
});
@@ -133,7 +135,7 @@ describe("joinJsSdkSession", () => {
ownMemberMock,
[transport],
expect.objectContaining({
callIntent: "audio",
callIntent: "m.audio",
applicationData: { custom: 1 },
unstableSendStickyEvents: true,
}),
+3 -5
View File
@@ -23,6 +23,7 @@ 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. */
@@ -43,15 +44,12 @@ 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
@@ -78,7 +76,7 @@ export function joinJsSdkSession(
{
notificationType: sendNotificationType,
callIntent,
applicationData: rest,
applicationData,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
+9 -22
View File
@@ -23,14 +23,13 @@ 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 { ClientProvider } from "./ClientContext";
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { Initializer } from "./initializer";
import { type WidgetHelpers } from "./widget";
@@ -131,22 +130,9 @@ export const App: FC<Props> = ({ vm, widget }) => {
.catch(logger.error);
});
// 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}>
const content = loaded ? (
<ClientProvider session={vm.clientSession}>
<ClientManagerProvider value={vm.clientManager}>
<MediaDevicesContext value={vm.mediaDevices}>
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
<Sentry.ErrorBoundary
@@ -161,10 +147,11 @@ export const App: FC<Props> = ({ vm, widget }) => {
</Sentry.ErrorBoundary>
</BackgroundEffectsProvider>
</MediaDevicesContext>
</ClientProvider>
) : (
<LoadingPage />
);
</ClientManagerProvider>
</ClientProvider>
) : (
<LoadingPage />
);
return (
<I18nextProvider i18n={i18n}>
+2 -16
View File
@@ -12,6 +12,7 @@ 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,
@@ -26,22 +27,7 @@ const TestComponent: FC<
}>
> = ({ client, hostBridge = nullHostBridge, children }) => {
return (
<ClientContextProvider
value={{
state: "valid",
disconnected: false,
supportedFeatures: {
reactions: true,
},
setClient: vi.fn(),
authenticated: {
client,
isPasswordlessUser: true,
changePassword: vi.fn(),
logout: vi.fn(),
},
}}
>
<ClientContextProvider value={hostClientSession(client)}>
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
</ClientContextProvider>
);
+6 -10
View File
@@ -15,7 +15,7 @@ import {
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { useClientState } from "./ClientContext";
import { useClient } from "./ClientContext";
import { useHostBridge } from "./HostBridge";
export enum Size {
@@ -74,7 +74,7 @@ export const Avatar: FC<Props> = ({
style,
...props
}) => {
const clientState = useClientState();
const { client } = useClient();
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 `clientState` or `sizePx` could run extra media
// In theory, a change in `client` or `sizePx` could run extra media
// downloads, but in practice they should be stable long before this code runs.
useEffect(() => {
if (!src) {
@@ -99,12 +99,8 @@ export const Avatar: FC<Props> = ({
if (hostBridge.downloadMedia) {
blob = hostBridge.downloadMedia(src);
} else if (
clientState?.state === "valid" &&
clientState.authenticated?.client &&
sizePx
) {
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
} else if (client && sizePx) {
blob = getAvatarFromServer(client, src, sizePx);
} else {
setAvatarUrl(undefined);
return;
@@ -133,7 +129,7 @@ export const Avatar: FC<Props> = ({
URL.revokeObjectURL(objectUrl);
}
};
}, [clientState, hostBridge, src, sizePx]);
}, [client, hostBridge, src, sizePx]);
return (
<CompoundAvatar
+93 -60
View File
@@ -6,12 +6,25 @@ Please see LICENSE in the repository root for full details.
*/
import { expect, test, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { act, render, renderHook } 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 { ClientProvider, useClientState } from "./ClientContext";
import {
ClientManagerProvider,
ClientProvider,
useClientManager,
useClientManagerIfAny,
useClientSessionState,
} from "./ClientContext";
import {
type ClientManager,
type ClientSession,
type ClientSessionState,
hostClientSession,
} from "./state/ClientSession";
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
({
@@ -24,67 +37,47 @@ const mockClient = (userId = "@alice:example.org"): MatrixClient =>
/** Reports what the context says, so a test can assert on it. */
const ShowClientState: FC = () => {
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 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>;
}
};
test("uses a client supplied by the host without waiting", () => {
const client = mockClient();
const { container } = render(
const renderProvider = (
session: ClientSession,
children = <ShowClientState />,
): ReturnType<typeof render> =>
render(
<BrowserRouter>
<ClientProvider client={client}>
<ShowClientState />
</ClientProvider>
<ClientProvider session={session}>{children}</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 first = mockClient();
const second = mockClient("@bob:example.org");
const { container, rerender } = render(
<BrowserRouter>
<ClientProvider client={first}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
const { container, rerender } = renderProvider(
hostClientSession(mockClient()),
);
expect(container.textContent).toBe("@alice:example.org");
// A host that re-authenticates hands us a new client on a mounted component
rerender(
<BrowserRouter>
<ClientProvider client={second}>
<ClientProvider
session={hostClientSession(mockClient("@bob:example.org"))}
>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
@@ -93,23 +86,63 @@ test("follows the client when the host swaps it", () => {
expect(container.textContent).toBe("@bob:example.org");
});
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>,
);
test("follows a session of its own as it is restored", () => {
const state$ = new BehaviorSubject<ClientSessionState>({ state: "loading" });
const { container } = renderProvider({ state$ });
// Nothing to show until a session has been restored or created
expect(container.textContent).toBe("loading");
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
act(() =>
state$.next({
state: "signedIn",
client: mockClient(),
passwordlessUser: true,
}),
);
expect(container.textContent).toBe("@alice:example.org");
});
vi.doUnmock("./utils/spa");
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();
});
+51 -353
View File
@@ -5,30 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
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 { type FC, useEffect, createContext, use, type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { ErrorPage } from "./FullScreenView";
import { useHostBridge } from "./HostBridge";
import { useLeaveToHome } from "./LeaveToHomeContext";
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
import { useBehavior } from "./useBehavior";
import { constant } from "./state/Behavior";
import {
PosthogAnalytics,
RegistrationType,
} from "./analytics/PosthogAnalytics";
import { useEventTarget } from "./useEvents";
import { OpenElsewhereError } from "./RichError";
type ClientManager,
type ClientSession,
type ClientSessionState,
} from "./state/ClientSession";
declare global {
interface Window {
@@ -37,360 +25,70 @@ declare global {
}
}
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;
// 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 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);
const ClientContext = createContext<ClientSession>(noClientSession);
export const ClientContextProvider = ClientContext.Provider;
export const useClientState = (): ClientState | undefined => use(ClientContext);
export const useClientSessionState = (): ClientSessionState =>
useBehavior(use(ClientContext).state$);
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 };
export function useClient(): { client?: MatrixClient } {
const state = useClientSessionState();
return { client: state.state === "signedIn" ? state.client : undefined };
}
// 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();
// Null where the session is a host's: the component, and the widget
const ClientManagerContext = createContext<ClientManager | null>(null);
let client;
let setClient;
let passwordlessUser = false;
let loading = true;
let error;
let authenticated = false;
let logout;
export const ClientManagerProvider = ClientManagerContext.Provider;
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 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;
}
const loadChannel =
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
/**
* 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);
interface Props {
children: JSX.Element;
/**
* 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;
/** a session from Element Call SPA, from a widget or the host (EW) */
session: ClientSession;
}
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));
},
[],
);
export const ClientProvider: FC<Props> = ({ children, session }) => {
const state = useBehavior(session.state$);
useEffect(() => {
if (!initClientState) {
return;
}
window.matrixclient = initClientState.client;
window.passwordlessUser = initClientState.passwordlessUser;
if (state.state !== "signedIn") return;
window.matrixclient = state.client;
window.passwordlessUser = state.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 (initClientState.client) {
initClientState.client.on(ClientEvent.Sync, onSync);
}
if (state.state === "error") return <ErrorPage error={state.error} />;
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>;
return <ClientContext value={session}>{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";
+43
View File
@@ -0,0 +1,43 @@
/*
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("");
});
+38 -8
View File
@@ -6,30 +6,60 @@ Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import { type FC, type HTMLAttributes, type ReactNode } from "react";
import {
type FC,
type HTMLAttributes,
type ReactNode,
useEffect,
useState,
} 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 { type ValidClientState, useClientState } from "./ClientContext";
import { useClient } 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 clientState = useClientState();
let shouldShowBanner = false;
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);
if (clientState?.state === "valid") {
const validClientState = clientState as ValidClientState;
shouldShowBanner = validClientState.disconnected;
}
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];
return (
<>
+10 -8
View File
@@ -18,6 +18,8 @@ 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";
@@ -179,40 +181,40 @@ export function configurationForIntent(intent: UserIntent): UrlConfiguration {
switch (intent) {
case UserIntent.StartNewCall:
preset.skipLobby = false;
preset.callIntent = "video";
preset.callIntent = RTC_CALL_INTENT_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 = "video";
preset.callIntent = RTC_CALL_INTENT_VIDEO;
break;
case UserIntent.StartNewCallVoice:
preset.skipLobby = false;
preset.callIntent = "audio";
preset.callIntent = RTC_CALL_INTENT_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 = "audio";
preset.callIntent = RTC_CALL_INTENT_AUDIO;
break;
case UserIntent.StartNewCallDMVoice:
preset.callIntent = "audio";
preset.callIntent = RTC_CALL_INTENT_AUDIO;
// Fall through
case UserIntent.StartNewCallDM:
preset.skipLobby = true;
preset.sendNotificationType = "ring";
preset.autoLeaveWhenOthersLeft = true;
preset.waitForCallPickup = true;
preset.callIntent = preset.callIntent ?? "video";
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
break;
case UserIntent.JoinExistingCallDMVoice:
preset.callIntent = "audio";
preset.callIntent = RTC_CALL_INTENT_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 ?? "video";
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
break;
// Non widget usecase defaults
default:
+10 -6
View File
@@ -21,6 +21,10 @@ 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";
@@ -275,7 +279,7 @@ describe("UrlParams", () => {
).toMatchObject({
...startNewCallDefaults("desktop"),
skipLobby: false,
callIntent: "video",
callIntent: RTC_CALL_INTENT_VIDEO,
});
});
@@ -322,7 +326,7 @@ describe("UrlParams", () => {
).toMatchObject({
...startNewCallDefaults("desktop"),
skipLobby: false,
callIntent: "audio",
callIntent: RTC_CALL_INTENT_AUDIO,
});
});
@@ -333,7 +337,7 @@ describe("UrlParams", () => {
),
).toMatchObject({
...joinExistingCallDefaults("desktop"),
callIntent: "audio",
callIntent: RTC_CALL_INTENT_AUDIO,
});
});
@@ -348,7 +352,7 @@ describe("UrlParams", () => {
sendNotificationType: "ring",
autoLeaveWhenOthersLeft: true,
waitForCallPickup: true,
callIntent: "audio",
callIntent: RTC_CALL_INTENT_AUDIO,
});
});
@@ -363,7 +367,7 @@ describe("UrlParams", () => {
skipLobby: true,
autoLeaveWhenOthersLeft: true,
waitForCallPickup: false,
callIntent: "video",
callIntent: RTC_CALL_INTENT_VIDEO,
});
});
@@ -377,7 +381,7 @@ describe("UrlParams", () => {
skipLobby: true,
autoLeaveWhenOthersLeft: true,
waitForCallPickup: false,
callIntent: "audio",
callIntent: RTC_CALL_INTENT_AUDIO,
});
});
});
+96
View File
@@ -0,0 +1,96 @@
/*
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>,
);
}
+18 -5
View File
@@ -9,7 +9,12 @@ import { type FC, useCallback, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import { useClientLegacy } from "./ClientContext";
import {
useClient,
useClientManagerIfAny,
useClientSessionState,
} from "./ClientContext";
import { useLeaveToHome } from "./LeaveToHomeContext";
import { useProfile } from "./profile/useProfile";
import { defaultSettingsTab, SettingsModal } from "./settings/SettingsModal";
import { UserMenu } from "./UserMenu";
@@ -21,7 +26,13 @@ interface Props {
export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
const location = useLocation();
const navigate = useNavigate();
const { client, logout, authenticated, passwordlessUser } = useClientLegacy();
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 { displayName, avatarUrl } = useProfile(client);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const onDismissSettingsModal = useCallback(
@@ -43,7 +54,9 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
setSettingsModalOpen(true);
break;
case "logout":
logout?.();
logout?.()
.then(() => leaveToHome?.())
.catch((e) => logger.error("Failed to log out", e));
break;
case "login":
navigate("/login", { state: { from: location } })?.catch((error) =>
@@ -52,7 +65,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
break;
}
},
[navigate, location, logout, setSettingsModalOpen],
[navigate, location, logout, leaveToHome, setSettingsModalOpen],
);
const userName = client?.getUserIdLocalpart() ?? "";
@@ -60,7 +73,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
<>
<UserMenu
preventNavigation={preventNavigation}
isAuthenticated={authenticated}
isAuthenticated={client !== undefined}
isPasswordlessUser={passwordlessUser}
avatarUrl={avatarUrl}
onAction={onAction}
+78
View File
@@ -0,0 +1,78 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { 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>,
);
}
+4 -13
View File
@@ -11,10 +11,9 @@ import { Trans, useTranslation } from "react-i18next";
import { Button } from "@vector-im/compound-web";
import Logo from "../icons/LogoLarge.svg?react";
import { useClient } from "../ClientContext";
import { useClientManager } 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";
@@ -24,8 +23,7 @@ export const LoginPage: FC = () => {
const { t } = useTranslation();
usePageTitle(t("login_title"));
const { client, setClient } = useClient();
const login = useInteractiveLogin(client);
const { login } = useClientManager();
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
@@ -46,15 +44,8 @@ export const LoginPage: FC = () => {
setLoading(false);
return;
}
login(homeserver, usernameRef.current.value, passwordRef.current.value)
.then(async ([client, session]) => {
if (!setClient) {
return;
}
setClient(client, session);
.then(async () => {
const locationState = location.state;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -72,7 +63,7 @@ export const LoginPage: FC = () => {
setLoading(false);
});
},
[login, location, navigate, homeserver, setClient],
[login, location, navigate, homeserver],
);
// 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);
+133
View File
@@ -0,0 +1,133 @@
/*
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
View File
@@ -22,8 +22,12 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { Button, Text } from "@vector-im/compound-web";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { useClientLegacy } from "../ClientContext";
import { useInteractiveRegistration } from "./useInteractiveRegistration";
import {
useClient,
useClientManager,
useClientSessionState,
} from "../ClientContext";
import { useRegistrationTerms } from "./useRegistrationTerms";
import styles from "./LoginPage.module.css";
import Logo from "../icons/LogoLarge.svg?react";
import { LoadingPage } from "../FullScreenView";
@@ -37,8 +41,13 @@ export const RegisterPage: FC = () => {
const { t } = useTranslation();
usePageTitle(t("action.register"));
const { loading, authenticated, passwordlessUser, client, setClient } =
useClientLegacy();
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 confirmPasswordRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
@@ -47,7 +56,7 @@ export const RegisterPage: FC = () => {
const [error, setError] = useState<Error>();
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const { recaptchaKey, register } = useInteractiveRegistration(client);
const { recaptchaKey } = useRegistrationTerms();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback(
@@ -64,7 +73,7 @@ export const RegisterPage: FC = () => {
setRegistering(true);
const recaptchaResponse = await execute();
const [newClient, session] = await register(
const { client: newClient, setSignedIn } = await register(
userName,
password,
userName,
@@ -95,7 +104,7 @@ export const RegisterPage: FC = () => {
}
}
setClient?.(newClient, session);
setSignedIn();
PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date());
};
@@ -117,16 +126,7 @@ export const RegisterPage: FC = () => {
reset();
});
},
[
register,
location,
navigate,
passwordlessUser,
reset,
execute,
client,
setClient,
],
[register, location, navigate, passwordlessUser, reset, execute, client],
);
useEffect(() => {
+104
View File
@@ -0,0 +1,104 @@
/*
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",
}),
}),
);
});
+106
View File
@@ -0,0 +1,106 @@
/*
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 */
}
-84
View File
@@ -1,84 +0,0 @@
/*
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],
);
}
-145
View File
@@ -1,145 +0,0 @@
/*
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 };
};
@@ -0,0 +1,116 @@
/*
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>
);
}
+10 -10
View File
@@ -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 { useClient } from "../ClientContext";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useClientManagerIfAny } from "../ClientContext";
import { useRegistrationTerms } from "../auth/useRegistrationTerms";
import { generateRandomName } from "../auth/generateRandomName";
import { useRecaptcha } from "../auth/useRecaptcha";
import { useUrlParams } from "../UrlParams";
@@ -21,16 +21,16 @@ interface UseRegisterPasswordlessUserType {
}
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
const { setClient } = useClient();
// Null in the widget, which never registers anyone
const register = useClientManagerIfAny()?.register;
const { isWidget } = useUrlParams();
const { privacyPolicyUrl, recaptchaKey, register } =
useInteractiveRegistration();
const { privacyPolicyUrl, recaptchaKey } = useRegistrationTerms();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const registerPasswordlessUser = useCallback(
async (displayName: string) => {
if (!setClient) {
throw new Error("No client context");
if (!register) {
throw new Error("This session cannot register");
}
if (isWidget) {
throw new Error(
@@ -41,20 +41,20 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
try {
const recaptchaResponse = await execute();
const userName = generateRandomName();
const [client, session] = await register(
const { setSignedIn } = await register(
userName,
secureRandomString(16),
displayName,
recaptchaResponse,
true,
);
setClient(client, session);
setSignedIn();
} catch (e) {
reset();
throw e;
}
},
[execute, reset, register, setClient, isWidget],
[execute, reset, register, isWidget],
);
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
+98
View File
@@ -0,0 +1,98 @@
/*
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>
);
}
+46
View File
@@ -0,0 +1,46 @@
/*
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 };
}
+1 -1
View File
@@ -45,7 +45,7 @@ interface Props {
/** Offers to add a background of one's own. Omit to leave that tile out. */
onAdd?: () => void;
addLabel?: string;
/** Removes a removable one, unless it is the selected one. Omit to offer none. */
/** Removes a removable one, never the one in force. Omit to offer none. */
onRemove?: (id: string) => void;
removeLabel?: string;
}
+193 -68
View File
@@ -22,7 +22,7 @@ import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
import { constant } from "../state/Behavior";
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
import { type MediaMenuSnapshot } from "./MediaMenuViewModel";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
// consts for tests
const reactionIdentifier = "@user:example.com:DEVICE";
@@ -35,57 +35,10 @@ const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false,
});
function menu(
kind: "audio" | "video",
withDevices: boolean,
): MediaMenuSnapshot {
const device = kind === "audio" ? "Microphone" : "Camera";
return {
enabled: true,
busy: false,
toggle: fn(),
options: withDevices
? [
{ id: "1", label: { type: "name", name: `${device} 1` } },
{ id: "2", label: { type: "name", name: `${device} 2` } },
]
: [],
selected: "1",
select: fn(),
outputOptions:
withDevices && kind === "audio"
? [
{
id: "default",
label: { type: "default", name: "Built-in Output" },
},
]
: [],
selectedOutput: "default",
selectOutput: fn(),
backgroundEffect: "none",
backgroundEffects:
kind === "video"
? [
{ id: "none", kind: "none" },
{ id: "blur", kind: "blur" },
]
: [],
backgroundEffectNotice: undefined,
backgroundEffectSettling: false,
backgroundImageRefusal: undefined,
selectBackgroundEffect: fn(),
addBackgroundImage: undefined,
removeBackgroundEffect: undefined,
cameraTrack: null,
};
}
/**
* A wrapper component that is used for:
* - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm)
* - constructing the layout switch and the menus' view models; the menus'
* own states are in the MediaMenuButton stories
* - constructing the layout switch view model
* - Add additional react context
* The paraeters are all params from the FooterSnapshot,
* the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter.
@@ -96,22 +49,15 @@ function CallFooterStoryWrapper({
children,
layout,
setLayout,
withDevices,
...vmSnapshot
}: Omit<FooterSnapshot, "layoutSwitchVm" | "audioMenuVm" | "cameraMenuVm"> & {
}: Omit<FooterSnapshot, "layoutSwitchVm"> & {
children?: false | JSX.Element | JSX.Element[] | undefined;
layout: LayoutMode | null;
setLayout: (value: LayoutMode) => void;
/** Whether the menus have devices to pick, or are plain mute buttons. */
withDevices: boolean;
}): ReactNode {
const audioMenuVm = useStaticViewModel(menu("audio", withDevices));
const cameraMenuVm = useStaticViewModel(menu("video", withDevices));
const vm = useStaticViewModel({
...vmSnapshot,
layoutSwitchVm: layout && { layout$: constant(layout), setLayout },
audioMenuVm,
cameraMenuVm,
});
return (
<MediaDevicesContext value={mediaDevices}>
@@ -156,6 +102,8 @@ const meta = {
},
toggleScreenSharing: fnArgType,
openSettings: fnArgType,
toggleAudio: fnArgType,
toggleVideo: fnArgType,
hangup: fnArgType,
},
} satisfies Meta<typeof CallFooterStoryWrapper>;
@@ -168,9 +116,32 @@ export const Default: Story = {
showLogo: false,
layout: "grid",
setLayout: fn(),
withDevices: false,
audioEnabled: true,
audioBusy: false,
videoEnabled: true,
videoBusy: false,
openSettings: fn(),
toggleAudio: fn(),
toggleVideo: fn(),
toggleScreenSharing: fn(),
selectBackgroundEffect: fn(),
backgroundEffect: "blur",
backgroundEffects: [
{ id: "none", kind: "none", removable: false },
{ id: "blur", kind: "blur", removable: false },
...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,
@@ -183,17 +154,49 @@ export const Default: Story = {
reactionData: undefined,
debugTileLayout: false,
tileStoreGeneration: undefined,
audioOptions: [],
audioOutputOptions: [],
videoOptions: [],
selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined,
},
parameters: {
layout: "fullscreen",
},
};
export const StaysWhileAMenuIsOpen: Story = {
export const WithAudioAndVideoOptions: Story = {
...Default,
args: {
...Default.args,
withDevices: true,
audioEnabled: false,
videoEnabled: true,
audioOptions: [
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
audioOutputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "2" },
],
videoOptions: [
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedAudio: "2",
selectedAudioOutput: "default",
selectedVideo: "1",
},
};
export const StaysWhileAMenuIsOpen: Story = {
...Default,
args: {
...WithAudioAndVideoOptions.args,
// In a short window the footer overlays the call and hides itself.
asOverlay: true,
showFooter: true,
@@ -216,6 +219,25 @@ export const StaysWhileAMenuIsOpen: Story = {
},
};
export const AudioBusy: Story = {
...Default,
args: {
...Default.args,
audioEnabled: true,
audioBusy: true,
videoEnabled: true,
},
};
export const VideoBusy: Story = {
...Default,
args: {
...Default.args,
audioEnabled: true,
videoEnabled: true,
videoBusy: true,
},
};
export const WithLogo: Story = {
...Default,
args: {
@@ -224,7 +246,45 @@ export const WithLogo: Story = {
},
};
/** Switching to grid, and the footer's own buttons, act. */
export const AudioVideoEnabled: Story = {
...Default,
args: {
...Default.args,
audioEnabled: true,
videoEnabled: true,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
await userEvent.click(spotlightRadio);
await expect(args.setLayout).toHaveBeenCalledWith("spotlight");
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
/** used to test switching to grid mode */
export const SpotlightMode: Story = {
...Default,
args: {
@@ -233,12 +293,10 @@ export const SpotlightMode: Story = {
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("radio", { name: "Grid" }));
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
await userEvent.click(spotlightRadio);
await expect(args.setLayout).toHaveBeenCalledWith("grid");
await userEvent.click(canvas.getByRole("switch", { name: "Share screen" }));
await expect(args.toggleScreenSharing).toHaveBeenCalled();
await userEvent.click(canvas.getByRole("button", { name: "End call" }));
await expect(args.hangup).toHaveBeenCalled();
},
};
@@ -272,10 +330,34 @@ export const Pip: Story = {
buttonSize: "md",
layout: null,
},
play: async ({ canvasElement }) => {
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await expect(
within(canvasElement).queryByRole("radio", { name: "Spotlight" }),
canvas.queryByRole("radio", { name: "Spotlight" }),
).not.toBeInTheDocument();
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
export const NoControlsWithLogo: Story = {
@@ -296,6 +378,18 @@ export const DebugData: Story = {
},
};
export const UnavailableMediaDevices: Story = {
...Default,
args: {
...Default.args,
audioEnabled: false,
videoEnabled: false,
toggleAudio: undefined,
toggleVideo: undefined,
audioOutputSwitcher: undefined,
},
};
export const MobileLayout: Story = {
...Default,
args: {
@@ -326,6 +420,37 @@ export const Lobby: Story = {
},
};
export const LobbyMobile: Story = {
...Default,
args: {
...Default.args,
showLogo: false,
layout: null,
toggleScreenSharing: undefined,
},
globals: {
viewport: { value: "mobile2", isRotated: false },
},
parameters: {
...Default.parameters,
},
};
export const LobbyRecentButton: Story = {
...Default,
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
showLogo: false,
layout: null,
toggleScreenSharing: undefined,
},
parameters: {
...Default.parameters,
},
};
export const LobbyRecentButtonMobile: Story = {
...Default,
args: {
+165 -20
View File
@@ -7,11 +7,14 @@ Please see LICENSE in the repository root for full details.
import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
import {
EndCallButton,
MicButton,
VideoButton,
ShareScreenButton,
SettingsButton,
ReactionToggleButton,
@@ -20,13 +23,23 @@ import {
type ReactionData,
} from "../button";
import styles from "./CallFooter.module.css";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type Behavior } from "../state/Behavior";
import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
import { LayoutSwitch } from "../room/LayoutSwitch";
import { type MediaMenuViewModel } from "./MediaMenuViewModel";
import { MediaMenuButton } from "./MediaMenuButton";
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;
@@ -48,6 +61,16 @@ export interface AudioOutputSwitcher {
*/
export type FooterSnapshot = FooterActions & FooterState;
export interface FooterActions {
/** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined;
/** Also controls if the videoMute button is disabled */
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;
@@ -57,8 +80,22 @@ export interface FooterActions {
// we do not use any ? optional properties so that the vm type is including all fields.
export interface FooterState {
audioMenuVm: MediaMenuViewModel;
cameraMenuVm: MediaMenuViewModel;
audioEnabled: boolean;
audioBusy: boolean;
videoEnabled: boolean;
videoBusy: boolean;
/** The background effect in force, as its option id. */
backgroundEffect: string;
/** Every effect on offer, in the order they are shown. */
backgroundEffects: BackgroundEffectGridItem[];
/** What the user is told about effects here, if anything. */
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" */
@@ -85,6 +122,19 @@ export interface FooterState {
// debug stuff
debugTileLayout: boolean;
tileStoreGeneration: number | undefined;
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions: MenuOptions[];
/** Output (speaker) devices. */
audioOutputOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions: MenuOptions[];
selectedAudio: string | undefined;
selectedAudioOutput: string | undefined;
selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined;
}
export interface FooterProps {
@@ -100,14 +150,19 @@ export const CallFooter: FC<FooterProps> = ({
children,
vm,
}) => {
const { t } = useTranslation();
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
const showModals = useBehavior(vm.showModals$);
const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
const audioMenuVm = useBehavior(vm.audioMenuVm$);
const cameraMenuVm = useBehavior(vm.cameraMenuVm$);
const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$);
const audioBusy = useBehavior(vm.audioBusy$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const videoBusy = useBehavior(vm.videoBusy$);
const toggleAudio = useBehavior(vm.toggleAudio$);
const toggleVideo = useBehavior(vm.toggleVideo$);
const sharingScreen = useBehavior(vm.sharingScreen$);
const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
@@ -115,6 +170,38 @@ export const CallFooter: FC<FooterProps> = ({
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const hangup = useBehavior(vm.hangup$);
const debugTileLayout = useBehavior(vm.debugTileLayout$);
const videoOptions = useBehavior(vm.videoOptions$);
const selectedVideo = useBehavior(vm.selectedVideo$);
const audioOptions = useBehavior(vm.audioOptions$);
const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const backgroundEffect = useBehavior(vm.backgroundEffect$);
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$);
@@ -134,20 +221,78 @@ export const CallFooter: FC<FooterProps> = ({
);
}
buttons.push(
<MediaMenuButton
key="audio"
kind="audio"
vm={audioMenuVm}
size={buttonSize}
/>,
<MediaMenuButton
key="video"
kind="video"
vm={cameraMenuVm}
size={buttonSize}
/>,
);
if ((audioOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
busy={audioBusy ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioButtonOption}
outputOptions={audioOutputOptions}
selectedOutputOption={selectedAudioOutput}
onSelectOutput={selectAudioOutputOption}
/>,
);
} else {
buttons.push(
<MicButton
size={buttonSize}
key="audio"
enabled={audioEnabled ?? false}
busy={audioBusy ?? false}
onClick={toggleAudio}
disabled={(audioBusy ?? false) || toggleAudio === undefined}
data-testid="incall_mute"
/>,
);
}
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
busy={videoBusy ?? false}
onMuteClick={toggleVideo}
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoButtonOption}
backgroundEffects={backgroundEffects}
selectedBackgroundEffect={backgroundEffect}
onSelectBackgroundEffect={selectBackgroundEffect}
backgroundEffectSettling={backgroundEffectSettling}
onAddBackgroundImage={addBackgroundImage}
onRemoveBackgroundEffect={removeBackgroundEffect}
selfPreview={<SelfPreview track={cameraTrack} />}
backgroundImageRefusal={refusalMessage}
backgroundEffectNotice={
backgroundEffectNotice === "unavailable"
? t("background_effects.unavailable")
: backgroundEffectNotice === "slow"
? t("background_effects.slow")
: undefined
}
/>,
);
} else {
buttons.push(
<VideoButton
size={buttonSize}
key="video"
enabled={videoEnabled ?? false}
busy={videoBusy ?? false}
onClick={toggleVideo}
disabled={(videoBusy ?? false) || toggleVideo === undefined}
data-testid="incall_videomute"
/>,
);
}
if (toggleScreenSharing !== undefined) {
buttons.push(
+446 -63
View File
@@ -5,15 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import {
flushPromises,
testScope,
mockMuteStates,
mockMediaDevices,
mockVideoTrack,
} from "../utils/test";
import { constant } from "../state/Behavior";
import { type Behavior, constant } from "../state/Behavior";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Layout } from "../state/layout-types";
@@ -24,6 +27,15 @@ import {
createLobbyFooterViewModel,
} from "./CallFooterViewModel";
import { HeaderStyle } from "../UrlParams";
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
import type * as BackgroundImages from "../tracks/backgroundImages";
import {
type AddedBackground,
addedBackgrounds,
maxAddedBackgrounds,
UnusableImage,
} from "../tracks/backgroundImages";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
@@ -32,6 +44,35 @@ vi.mock("../Platform", () => ({
},
}));
// The SDK's own check needs WebGL and canvas APIs jsdom does not have.
const sdkSupportMock = vi.hoisted(() => vi.fn(() => false));
const modernRouteMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("@livekit/track-processors", () => ({
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
}));
const store = vi.hoisted(() => ({
add: vi.fn<(file: Blob) => Promise<string>>(),
remove: vi.fn<(id: string) => Promise<void>>(),
}));
vi.mock("../tracks/backgroundImages", async (original) => {
const { BehaviorSubject } = await import("rxjs");
return {
...(await original<typeof BackgroundImages>()),
addedBackgrounds: {
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
add: store.add,
remove: store.remove,
},
};
});
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("livekit-client", () => ({
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
}));
/**
* Returns the minimum set of CallViewModel fields required by
* createCallFooterViewModel, with all other properties stubbed to
@@ -72,82 +113,424 @@ const pipLayout: Layout = {
spotlight: {} as SpotlightTileViewModel,
};
const pipeline = constant<BackgroundEffectsState>({
supported: true,
const noPipeline = constant<BackgroundEffectsState>({
supported: false,
processor: undefined,
});
const oneOfEach = {
available$: constant(
new Map<string, DeviceLabel>([["1", { type: "number", number: 1 }]]),
),
selected$: constant(undefined),
select: vi.fn(),
};
const devices = mockMediaDevices({
audioInput: oneOfEach,
videoInput: oneOfEach,
/** The provider's verdict, which the SDK's check stands for here. */
function pipelineAsTheSdkSays(): Behavior<BackgroundEffectsState> {
return constant({ supported: sdkSupportMock(), processor: undefined });
}
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
audioInput: {
available$: constant(
new Map<string, DeviceLabel>([
["mic1", { type: "number", number: 1 }],
["mic2", { type: "name", name: "Microphone 2" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
videoInput: {
available$: constant(
new Map<string, DeviceLabel>([
["cam1", { type: "name", name: "Camera 1" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
});
describe("createCallFooterViewModel", () => {
it.each([
["desktop", "grid", true, gridLayout],
["ios", "grid", false, gridLayout],
["desktop", "pip", false, pipLayout],
] as const)(
"on %s in a %s layout, offers the device menus: %s",
(platform, _, offered, layout) => {
describe("selectAudioOutputOption", () => {
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
platformMock.mockReturnValue("desktop");
return createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
}
it("is withheld where the platform cannot route audio to a chosen device", () => {
outputSelectionMock.mockReturnValue(false);
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
});
it("is offered where the platform can route audio to a chosen device", () => {
outputSelectionMock.mockReturnValue(true);
expect(buildFooterVm().selectAudioOutputOption$.value).toBeDefined();
});
});
describe("audioOutputOptions", () => {
it("is an empty list, not absent, where the platform enumerates no outputs", () => {
platformMock.mockReturnValue("desktop");
outputSelectionMock.mockReturnValue(true);
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
mockMediaDevices({
audioInput: {
available$: constant(
new Map<string, DeviceLabel>([
["mic1", { type: "name", name: "Microphone 1" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
// As Safari: no outputs listed.
audioOutput: {
available$: constant(new Map<string, DeviceLabel>()),
selected$: constant(undefined),
select: vi.fn(),
},
}),
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
// Empty, not undefined: undefined would hide the section.
expect(vm.audioOutputOptions$.value).toEqual([]);
});
});
describe("audioOptions and videoOptions", () => {
function checkEmptyFor(platform: string, layout: Layout): void {
platformMock.mockReturnValue(platform);
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(layout),
mockMuteStates(),
devices,
pipeline,
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
const audio = vm.audioMenuVm$.value;
const camera = vm.cameraMenuVm$.value;
expect(audio.options$.value.length > 0).toBe(offered);
expect(camera.options$.value.length > 0).toBe(offered);
expect(camera.selectBackgroundEffect$.value !== undefined).toBe(offered);
},
);
it("previews the call's camera", () => {
const track = mockVideoTrack({});
const vm = createCallFooterViewModel(
testScope(),
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
mockMuteStates(),
devices,
pipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.cameraMenuVm$.value.cameraTrack$.value).toBe(track);
expect(vm.audioOptions$.value).toEqual([]);
expect(vm.videoOptions$.value).toEqual([]);
}
it("are both empty when the platform is iOS", () => {
checkEmptyFor("ios", gridLayout);
});
it("are both empty when the layout is pip", () => {
checkEmptyFor("desktop", pipLayout);
});
it("are populated when the platform is desktop and the layout is not PiP", () => {
platformMock.mockReturnValue("desktop");
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.audioOptions$?.value).toEqual([
{
id: "mic1",
label: {
number: 1,
type: "number",
},
},
{
id: "mic2",
label: {
name: "Microphone 2",
type: "name",
},
},
]);
expect(vm.videoOptions$?.value).toEqual([
{
id: "cam1",
label: {
name: "Camera 1",
type: "name",
},
},
]);
});
});
});
describe("createLobbyFooterViewModel", () => {
it("offers the device menus even on a phone, previewing the lobby's camera", () => {
platformMock.mockReturnValue("ios");
const track = mockVideoTrack({});
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
devices,
pipeline,
constant(track),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
const camera = vm.cameraMenuVm$.value;
expect(vm.audioMenuVm$.value.options$.value).not.toEqual([]);
expect(camera.options$.value).not.toEqual([]);
expect(camera.selectBackgroundEffect$.value).toBeDefined();
expect(camera.cameraTrack$.value).toBe(track);
describe("background effects", () => {
afterEach(() => backgroundEffectSetting.setValue("none"));
// The lobby, because in a call the whole device switcher is already
// withheld on a phone, whatever the verdict says.
function lobbyOn(
platform: string,
): ReturnType<typeof createLobbyFooterViewModel> {
platformMock.mockReturnValue(platform);
return createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipelineAsTheSdkSays(),
/* camera$ */ constant(null),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
}
it("offers them on a phone whose browser can run them", () => {
sdkSupportMock.mockReturnValue(true);
expect(lobbyOn("ios").selectBackgroundEffect$.value).toBeDefined();
});
it("offers them on a desktop whose browser can run them", () => {
sdkSupportMock.mockReturnValue(true);
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeDefined();
});
it("offers nothing where the browser itself cannot run them", () => {
sdkSupportMock.mockReturnValue(false);
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeUndefined();
expect(lobbyOn("desktop").addBackgroundImage$.value).toBeUndefined();
});
it("puts no effect in force where the browser cannot run them", () => {
backgroundEffectSetting.setValue("blur");
sdkSupportMock.mockReturnValue(false);
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("none");
sdkSupportMock.mockReturnValue(true);
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("blur");
});
it("stores the effect chosen", () => {
sdkSupportMock.mockReturnValue(true);
const vm = lobbyOn("desktop");
vm.selectBackgroundEffect$.value?.("image:arc");
expect(vm.backgroundEffect$.value).toBe("image:arc");
// What isn't an effect on offer is stored as none.
vm.selectBackgroundEffect$.value?.("image:gone");
expect(backgroundEffectSetting.getValue()).toBe("none");
});
it("says they run slowly where only the slower route exists", () => {
sdkSupportMock.mockReturnValue(true);
modernRouteMock.mockReturnValue(false);
const slow = lobbyOn("desktop");
expect(slow.backgroundEffectNotice$.value).toBe("slow");
expect(slow.selectBackgroundEffect$.value).toBeDefined();
modernRouteMock.mockReturnValue(true);
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBeUndefined();
});
it("offers every effect in order, the added ones removable", () => {
sdkSupportMock.mockReturnValue(true);
const added$ = addedBackgrounds.added$ as BehaviorSubject<
AddedBackground[] | undefined
>;
added$.next([{ id: "mine", url: "blob:mine" }]);
expect(lobbyOn("desktop").backgroundEffects$.value).toEqual([
{ id: "none", kind: "none", removable: false },
{ id: "blur", kind: "blur", removable: false },
...shippedBackgrounds.map((background) => ({
id: `image:${background.id}`,
kind: "image",
imageUrl: background.imagePath,
removable: false,
})),
{
id: "added:mine",
kind: "image",
imageUrl: "blob:mine",
removable: true,
},
]);
added$.next([]);
});
it("says they are unavailable where they cannot be chosen", () => {
sdkSupportMock.mockReturnValue(false);
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBe(
"unavailable",
);
});
it("passes on the wait for the first effect", () => {
platformMock.mockReturnValue("desktop");
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "settling",
});
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipeline$,
/* camera$ */ constant(null),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.backgroundEffectSettling$.value).toBe(true);
pipeline$.next({ ...pipeline$.value, wait: undefined });
expect(vm.backgroundEffectSettling$.value).toBe(false);
});
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
platformMock.mockReturnValue("desktop");
backgroundEffectSetting.setValue("blur");
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
});
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipeline$,
/* camera$ */ constant(null),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.backgroundEffect$.value).toBe("blur");
pipeline$.next({ supported: false, processor: undefined });
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
expect(vm.addBackgroundImage$.value).toBeUndefined();
expect(vm.backgroundEffect$.value).toBe("none");
expect(backgroundEffectSetting.getValue()).toBe("blur");
});
it("adding is unavailable at the limit", () => {
sdkSupportMock.mockReturnValue(true);
const added$ = addedBackgrounds.added$ as BehaviorSubject<
AddedBackground[] | undefined
>;
const vm = lobbyOn("desktop");
expect(vm.addBackgroundImage$.value).toBeDefined();
added$.next(
Array.from({ length: maxAddedBackgrounds }, (_, i) => ({
id: `${i}`,
url: `blob:${i}`,
})),
);
expect(vm.addBackgroundImage$.value).toBeUndefined();
added$.next(added$.value!.slice(1));
expect(vm.addBackgroundImage$.value).toBeDefined();
added$.next([]);
});
it("says why each refused file was refused", async () => {
sdkSupportMock.mockReturnValue(true);
const vm = lobbyOn("desktop");
store.add.mockRejectedValue(new UnusableImage("animated"));
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
await flushPromises();
const first = vm.backgroundImageRefusal$.value;
expect(first).toEqual({ reason: "animated" });
// The same again is a refusal of its own, so it is shown again.
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "animated" });
expect(vm.backgroundImageRefusal$.value).not.toBe(first);
store.add.mockRejectedValue(new Error("quota"));
vm.addBackgroundImage$.value!(new File(["x"], "b.png"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "not-kept" });
store.add.mockResolvedValue("kept");
vm.addBackgroundImage$.value!(new File(["x"], "c.png"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toBeUndefined();
});
it("never removes the background in force", () => {
sdkSupportMock.mockReturnValue(true);
store.remove.mockResolvedValue();
const vm = lobbyOn("desktop");
backgroundEffectSetting.setValue("added:worn");
vm.removeBackgroundEffect$.value!("added:worn");
expect(store.remove).not.toHaveBeenCalled();
vm.removeBackgroundEffect$.value!("added:spare");
expect(store.remove).toHaveBeenCalledWith("spare");
});
it("previews the lobby's camera while there is one", () => {
const track = mockVideoTrack({});
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
camera$,
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.cameraTrack$.value).toBe(track);
// The lobby releases the camera when it is turned off
camera$.next(null);
expect(vm.cameraTrack$.value).toBeNull();
});
it("previews the call's camera only while it is not muted", () => {
const muted$ = new BehaviorSubject(false);
const track = mockVideoTrack({ muted$ });
const vm = createCallFooterViewModel(
testScope(),
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.cameraTrack$.value).toBe(track);
muted$.next(true);
expect(vm.cameraTrack$.value).toBeNull();
muted$.next(false);
expect(vm.cameraTrack$.value).toBe(track);
});
it("availability is the same before and during a call", () => {
for (const supported of [true, false]) {
sdkSupportMock.mockReturnValue(supported);
const lobby = lobbyOn("desktop");
const inCall = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipelineAsTheSdkSays(),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
const offeredInLobby =
lobby.selectBackgroundEffect$.value !== undefined;
expect(inCall.selectBackgroundEffect$.value !== undefined).toBe(
offeredInLobby,
);
expect(offeredInLobby).toBe(supported);
}
});
});
});
+305 -54
View File
@@ -5,24 +5,253 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { combineLatest, map, type Observable } from "rxjs";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
type Observable,
of,
switchMap,
} from "rxjs";
import { supportsAudioOutputSelection } from "livekit-client";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import {
parseEffect,
serializeEffect,
shippedBackgrounds,
} from "../tracks/backgroundEffectCatalog";
import {
type AddedBackground,
addedBackgrounds,
maxAddedBackgrounds,
UnusableImage,
} from "../tracks/backgroundImages";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import { debugTileLayout as debugTileLayoutSetting } from "../settings/settings";
import {
backgroundEffect as backgroundEffectSetting,
debugTileLayout as debugTileLayoutSetting,
} from "../settings/settings";
import { type Behavior, constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates";
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
import { HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter";
import {
createAudioMenuViewModel,
createCameraMenuViewModel,
} from "./MediaMenuViewModel";
import { type BackgroundImageRefusal, type FooterSnapshot } from "./CallFooter";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
/**
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
* needed by FooterSnapshot.
*/
function buildMuteBehaviors(
scope: ObservableScope,
muteStates: MuteStates,
): Pick<
ViewModel<FooterSnapshot>,
| "audioEnabled$"
| "audioBusy$"
| "toggleAudio$"
| "videoEnabled$"
| "videoBusy$"
| "toggleVideo$"
> {
return {
audioEnabled$: muteStates.audio.enabled$,
audioBusy$: muteStates.audio.syncing$,
toggleAudio$: scope.behavior(
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
),
videoEnabled$: muteStates.video.enabled$,
videoBusy$: muteStates.video.syncing$,
toggleVideo$: scope.behavior(
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
),
};
}
/**
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
* needed by FooterSnapshot (options, selection, callbacks, background effect).
*/
function buildDeviceBehaviors(
scope: ObservableScope,
mediaDevices: MediaDevices,
/** return empty arrays for audioOptions and videoOptions*/
disableSwitcher$: Behavior<boolean>,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
/** The camera as the pipeline has it, for the menu's preview. */
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions$"
| "selectedAudio$"
| "selectAudioButtonOption$"
| "audioOutputOptions$"
| "selectedAudioOutput$"
| "selectAudioOutputOption$"
| "videoOptions$"
| "selectedVideo$"
| "selectVideoButtonOption$"
| "backgroundEffect$"
| "selectBackgroundEffect$"
| "backgroundEffects$"
| "backgroundEffectNotice$"
| "backgroundEffectSettling$"
| "addBackgroundImage$"
| "backgroundImageRefusal$"
| "removeBackgroundEffect$"
| "cameraTrack$"
> {
const options$ = (
available$: Behavior<Map<string, MenuOptions["label"]>>,
): Observable<MenuOptions[]> =>
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({ id, label })),
),
),
),
);
// Also false once the pipeline has failed to build this session.
const supported$ = backgroundEffectsState$.pipe(
map(({ supported }) => supported === true),
distinctUntilChanged(),
);
const slow = !supportsModernBackgroundProcessors();
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
);
// A new object for each refusal, so the same one twice is shown twice.
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
undefined,
);
return {
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
selectedAudio$: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
),
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
audioOutputOptions$: scope.behavior(
options$(mediaDevices.audioOutput.available$),
),
selectedAudioOutput$: scope.behavior(
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
),
// Withheld where the platform can't route audio to a chosen device, which
// disables the speaker section.
selectAudioOutputOption$: constant(
supportsAudioOutputSelection()
? mediaDevices.audioOutput.select
: undefined,
),
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
selectedVideo$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
backgroundEffect$: scope.behavior(
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
map(([raw, supported]) =>
supported ? serializeEffect(parseEffect(raw)) : "none",
),
),
),
selectBackgroundEffect$: scope.behavior(
offered$.pipe(
map((offered) =>
offered
? (id: string): void =>
backgroundEffectSetting.setValue(
serializeEffect(parseEffect(id)),
)
: undefined,
),
),
),
backgroundEffects$: scope.behavior(
addedBackgrounds.added$.pipe(
map((added) => backgroundEffectChoices(added ?? [])),
),
),
backgroundEffectNotice$: scope.behavior(
offered$.pipe(
map((offered) =>
!offered ? "unavailable" : slow ? "slow" : undefined,
),
),
),
backgroundEffectSettling$: scope.behavior(
backgroundEffectsState$.pipe(
map(({ wait }) => wait !== undefined),
distinctUntilChanged(),
),
false,
),
// Kept and offered, not put on: that waits for the user to choose it.
addBackgroundImage$: scope.behavior(
combineLatest([offered$, addedBackgrounds.added$]).pipe(
map(([offered, added]) =>
offered && (added?.length ?? 0) < maxAddedBackgrounds
? (file: File): void => {
refusal$.next(undefined);
addedBackgrounds.add(file).catch((e) => {
logger.warn("Could not keep that background", e);
refusal$.next({
reason: e instanceof UnusableImage ? e.reason : "not-kept",
});
});
}
: undefined,
),
),
),
backgroundImageRefusal$: refusal$,
// None while muted: the call mutes its track rather than releasing it.
cameraTrack$: scope.behavior(
camera$.pipe(
switchMap((camera) =>
camera
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
: of(null),
),
),
null,
),
// Never the one in force: taking it away would leave nothing chosen.
removeBackgroundEffect$: scope.behavior(
offered$.pipe(
map((offered) =>
offered
? (optionId: string): void => {
const effect = parseEffect(optionId);
if (effect.kind !== "added") return;
if (backgroundEffectSetting.getValue() === optionId) return;
addedBackgrounds
.remove(effect.id)
.catch((e) =>
logger.warn("Could not remove that background", e),
);
}
: undefined,
),
),
),
};
}
/**
* Creates the ViewModel for the CallFooter.
@@ -57,23 +286,13 @@ export function createCallFooterViewModel(
isPip$.pipe(map((isPip) => isPip || platform !== "desktop")),
);
return {
audioMenuVm$: constant(
createAudioMenuViewModel(
scope,
muteStates,
mediaDevices,
disableDeviceSwitcher$,
),
),
cameraMenuVm$: constant(
createCameraMenuViewModel(
scope,
muteStates,
mediaDevices,
disableDeviceSwitcher$,
backgroundEffectsState$,
callModel.camera$,
),
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(
scope,
mediaDevices,
disableDeviceSwitcher$,
backgroundEffectsState$,
callModel.camera$,
),
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
@@ -153,40 +372,72 @@ export function createLobbyFooterViewModel(
hangup: (() => void) | undefined,
showLogo: boolean,
): ViewModel<FooterSnapshot> {
return createStaticViewModel({
// we can safly skip any props that we do not need.
// The view model will then have less keys.
// But as soon as we call `useViewModel` and convert back to a snapshot the missing props will
// be correcty matching the snapshot type.
showLogo,
hideControls: false,
asOverlay: false,
showModals: true,
buttonSize: "lg",
openSettings,
hangup,
debugTileLayout: false,
showFooter: true,
toggleScreenSharing: undefined,
layoutSwitchVm: null,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
reactionData: undefined,
tileStoreGeneration: undefined,
audioMenuVm: createAudioMenuViewModel(
return {
...createStaticViewModel({
// we can safly skip any props that we do not need.
// The view model will then have less keys.
// But as soon as we call `useViewModel` and convert back to a snapshot the missing props will
// be correcty matching the snapshot type.
showLogo,
hideControls: false,
asOverlay: false,
showModals: true,
buttonSize: "lg",
openSettings,
hangup,
debugTileLayout: false,
showFooter: true,
toggleAudio: undefined,
toggleVideo: undefined,
toggleScreenSharing: undefined,
audioEnabled: undefined,
audioBusy: false,
videoEnabled: undefined,
videoBusy: false,
layoutSwitchVm: null,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
reactionData: undefined,
tileStoreGeneration: undefined,
audioOptions: undefined,
audioOutputOptions: undefined,
videoOptions: undefined,
selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined,
}),
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(
scope,
muteStates,
mediaDevices,
constant(false),
),
cameraMenuVm: createCameraMenuViewModel(
scope,
muteStates,
mediaDevices,
constant(false),
backgroundEffectsState$,
camera$,
),
});
};
}
function backgroundEffectChoices(
added: AddedBackground[],
): BackgroundEffectGridItem[] {
return [
{ id: serializeEffect({ kind: "none" }), kind: "none", removable: false },
{ id: serializeEffect({ kind: "blur" }), kind: "blur", removable: false },
...shippedBackgrounds.map((background) => ({
id: serializeEffect({ kind: "shipped", id: background.id }),
kind: "image" as const,
imageUrl: background.imagePath,
removable: false,
})),
...added.map((background) => ({
id: serializeEffect({ kind: "added", id: background.id }),
kind: "image" as const,
imageUrl: background.url,
removable: true,
})),
];
}
-193
View File
@@ -1,193 +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, fn, userEvent, within } from "storybook/test";
import { type JSX, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMenuButton } from "./MediaMenuButton";
import { type MediaMenuSnapshot } from "./MediaMenuViewModel";
import { useStaticViewModel } from "../state/ViewModel";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false,
});
/** Builds the view model from the story's args, so each field is a control. */
function MediaMenuButtonStoryWrapper({
kind,
...snapshot
}: MediaMenuSnapshot & { kind: "audio" | "video" }): ReactNode {
const vm = useStaticViewModel(snapshot);
return <MediaMenuButton kind={kind} vm={vm} size="lg" />;
}
const meta = {
component: MediaMenuButtonStoryWrapper,
decorators: [
(Story): JSX.Element => (
<MediaDevicesContext value={mediaDevices}>
<Story />
</MediaDevicesContext>
),
],
} satisfies Meta<typeof MediaMenuButtonStoryWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
const empty = {
outputOptions: [],
selectedOutput: undefined,
selectOutput: undefined,
backgroundEffect: undefined,
backgroundEffects: [],
backgroundEffectNotice: undefined,
backgroundEffectSettling: false,
backgroundImageRefusal: undefined,
selectBackgroundEffect: undefined,
addBackgroundImage: undefined,
removeBackgroundEffect: undefined,
cameraTrack: null,
} satisfies Partial<MediaMenuSnapshot>;
export const Microphone: Story = {
args: {
...empty,
kind: "audio",
enabled: true,
busy: false,
toggle: fn(),
options: [
{ id: "mic1", label: { type: "name", name: "Microphone 1" } },
{ id: "mic2", label: { type: "name", name: "Microphone 2" } },
],
selected: "mic1",
select: fn(),
outputOptions: [
{ id: "default", label: { type: "default", name: "Built-in Output" } },
],
selectedOutput: "default",
selectOutput: fn(),
},
play: async ({ args, canvasElement }) => {
await userEvent.click(
within(canvasElement).getByRole("switch", { name: "Mute microphone" }),
);
await expect(args.toggle).toHaveBeenCalled();
},
};
export const Camera: Story = {
args: {
...empty,
kind: "video",
enabled: true,
busy: false,
toggle: fn(),
options: [{ id: "cam1", label: { type: "name", name: "Camera 1" } }],
selected: "cam1",
select: fn(),
backgroundEffect: "none",
backgroundEffects: [
{ id: "none", kind: "none" },
{ id: "blur", kind: "blur" },
],
selectBackgroundEffect: fn(),
},
play: async ({ args, canvasElement }) => {
await userEvent.click(
within(canvasElement).getByRole("switch", { name: "Stop video" }),
);
await expect(args.toggle).toHaveBeenCalled();
},
};
export const CameraBusy: Story = {
args: { ...Camera.args, busy: true },
};
/** Opens the effects, for the stories that check what they say. */
async function openEffects(canvasElement: HTMLElement): Promise<HTMLElement> {
await userEvent.click(
within(canvasElement).getByRole("button", { name: "Camera" }),
);
return within(document.body).findByRole("group", {
name: "Background effects",
});
}
/** The view model says why; the view says it in words. */
export const CameraEffectsUnavailable: Story = {
args: {
...Camera.args,
selectBackgroundEffect: undefined,
backgroundEffectNotice: "unavailable",
},
play: async ({ canvasElement }) => {
const section = await openEffects(canvasElement);
await expect(
within(section).getByRole("group"),
).toHaveAccessibleDescription(
"Background effects are not supported on this platform.",
);
},
};
export const CameraEffectsSlow: Story = {
args: {
...Camera.args,
backgroundEffectNotice: "slow",
},
play: async ({ canvasElement }) => {
const section = await openEffects(canvasElement);
await expect(
within(section).getByRole("group"),
).toHaveAccessibleDescription(
"Background effects run slowly on this platform, which may cause your video to stutter.",
);
},
};
export const CameraFileRefused: Story = {
args: {
...Camera.args,
addBackgroundImage: fn(),
backgroundImageRefusal: { reason: "not-an-image" },
},
play: async ({ canvasElement }) => {
await openEffects(canvasElement);
await expect(
await within(document.body).findByText(
"That file is not a supported image",
),
).toBeInTheDocument();
},
};
/** No devices to pick and none to mute: a plain, disabled mute button. */
export const WithoutDevices: Story = {
args: {
...Camera.args,
enabled: false,
toggle: undefined,
options: [],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole("button", { name: "Camera" }),
).not.toBeInTheDocument();
await expect(canvas.getByTestId("incall_videomute")).toHaveAttribute(
"aria-disabled",
"true",
);
},
};
-124
View File
@@ -1,124 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MicButton, VideoButton } from "../button";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import { type MediaMenuViewModel } from "./MediaMenuViewModel";
import { SelfPreview } from "./SelfPreview";
import { useBehavior } from "../useBehavior";
interface Props {
vm: MediaMenuViewModel;
kind: "audio" | "video";
size: "md" | "lg";
}
/**
* The microphone's or the camera's mute button: with a menu to pick the
* device while there are devices to offer, a plain mute button otherwise.
*/
export const MediaMenuButton: FC<Props> = ({ vm, kind, size }) => {
const { t } = useTranslation();
const enabled = useBehavior(vm.enabled$);
const busy = useBehavior(vm.busy$);
const toggle = useBehavior(vm.toggle$);
const options = useBehavior(vm.options$);
const selected = useBehavior(vm.selected$);
const select = useBehavior(vm.select$);
const outputOptions = useBehavior(vm.outputOptions$);
const selectedOutput = useBehavior(vm.selectedOutput$);
const selectOutput = useBehavior(vm.selectOutput$);
const backgroundEffect = useBehavior(vm.backgroundEffect$);
const backgroundEffects = useBehavior(vm.backgroundEffects$);
const notice = useBehavior(vm.backgroundEffectNotice$);
const settling = useBehavior(vm.backgroundEffectSettling$);
const refusal = useBehavior(vm.backgroundImageRefusal$);
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
const cameraTrack = useBehavior(vm.cameraTrack$);
const testId = kind === "audio" ? "incall_mute" : "incall_videomute";
const noticeText = useMemo(() => {
switch (notice) {
case undefined:
return undefined;
case "unavailable":
return t("background_effects.unavailable");
case "slow":
return t("background_effects.slow");
}
}, [notice, t]);
// A new object for each refusal, so the same one twice is shown twice.
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]);
if (options.length === 0) {
const PlainButton = kind === "audio" ? MicButton : VideoButton;
return (
<PlainButton
size={size}
enabled={enabled}
busy={busy}
onClick={toggle}
disabled={busy || toggle === undefined}
data-testid={testId}
/>
);
}
return kind === "audio" ? (
<MediaMuteAndSwitchButton
iconsAndLabels="audio"
enabled={enabled}
busy={busy}
onMuteClick={toggle}
data-testid={testId}
options={options}
selectedOption={selected}
onSelect={select}
outputOptions={outputOptions}
selectedOutputOption={selectedOutput}
onSelectOutput={selectOutput}
/>
) : (
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={enabled}
busy={busy}
onMuteClick={toggle}
options={options}
selectedOption={selected}
onSelect={select}
backgroundEffects={backgroundEffects}
selectedBackgroundEffect={backgroundEffect}
onSelectBackgroundEffect={selectBackgroundEffect}
backgroundEffectSettling={settling}
onAddBackgroundImage={addBackgroundImage}
onRemoveBackgroundEffect={removeBackgroundEffect}
selfPreview={<SelfPreview track={cameraTrack} />}
backgroundImageRefusal={refusalMessage}
backgroundEffectNotice={noticeText}
/>
);
};
-284
View File
@@ -1,284 +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, describe, expect, it, vi } from "vitest";
import { BehaviorSubject, type Observable } from "rxjs";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import {
flushPromises,
testScope,
mockMuteStates,
mockMediaDevices,
mockVideoTrack,
} from "../utils/test";
import { constant } from "../state/Behavior";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import type { DeviceLabel } from "../state/MediaDevices";
import {
createAudioMenuViewModel,
createCameraMenuViewModel,
type MediaMenuViewModel,
} from "./MediaMenuViewModel";
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
import type * as BackgroundImages from "../tracks/backgroundImages";
import {
type AddedBackground,
addedBackgrounds,
maxAddedBackgrounds,
UnusableImage,
} from "../tracks/backgroundImages";
const modernRouteMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("@livekit/track-processors", () => ({
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
}));
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("livekit-client", () => ({
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
}));
const store = vi.hoisted(() => ({
add: vi.fn<(file: Blob) => Promise<string>>(),
remove: vi.fn<(id: string) => Promise<void>>(),
}));
vi.mock("../tracks/backgroundImages", async (original) => {
const { BehaviorSubject } = await import("rxjs");
return {
...(await original<typeof BackgroundImages>()),
addedBackgrounds: {
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
add: store.add,
remove: store.remove,
},
};
});
const added$ = addedBackgrounds.added$ as BehaviorSubject<
AddedBackground[] | undefined
>;
const devices = mockMediaDevices({
audioInput: {
available$: constant(
new Map<string, DeviceLabel>([
["mic1", { type: "number", number: 1 }],
["mic2", { type: "name", name: "Microphone 2" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
// As Safari: no outputs listed.
audioOutput: {
available$: constant(new Map<string, DeviceLabel>()),
selected$: constant(undefined),
select: vi.fn(),
},
});
function audioMenu(disableSwitcher = false): MediaMenuViewModel {
return createAudioMenuViewModel(
testScope(),
mockMuteStates(),
devices,
constant(disableSwitcher),
);
}
function cameraMenu({
supported = true,
disableSwitcher = false,
pipeline$ = constant({ supported, processor: undefined }),
camera$ = constant(null),
}: {
supported?: boolean;
disableSwitcher?: boolean;
pipeline$?: Observable<BackgroundEffectsState>;
camera$?: Observable<LocalVideoMediaTrack | null>;
} = {}): MediaMenuViewModel {
return createCameraMenuViewModel(
testScope(),
mockMuteStates(),
devices,
constant(disableSwitcher),
pipeline$,
camera$,
);
}
describe("createAudioMenuViewModel", () => {
it("lists the devices, unless the switcher is withheld", () => {
expect(audioMenu().options$.value).toEqual([
{ id: "mic1", label: { type: "number", number: 1 } },
{ id: "mic2", label: { type: "name", name: "Microphone 2" } },
]);
expect(audioMenu(true).options$.value).toEqual([]);
});
it("lists no outputs as an empty list, which keeps the section", () => {
expect(audioMenu().outputOptions$.value).toEqual([]);
});
it.each([true, false])(
"offers to pick an output only where the platform can route to it (%s)",
(canRoute) => {
outputSelectionMock.mockReturnValue(canRoute);
expect(audioMenu().selectOutput$.value !== undefined).toBe(canRoute);
},
);
});
describe("createCameraMenuViewModel", () => {
afterEach(() => {
backgroundEffectSetting.setValue("none");
added$.next([]);
});
it("offers effects where they run, saying where they run slowly", () => {
modernRouteMock.mockReturnValue(true);
const vm = cameraMenu();
expect(vm.selectBackgroundEffect$.value).toBeDefined();
expect(vm.addBackgroundImage$.value).toBeDefined();
expect(vm.backgroundEffectNotice$.value).toBeUndefined();
modernRouteMock.mockReturnValue(false);
const slow = cameraMenu();
expect(slow.selectBackgroundEffect$.value).toBeDefined();
expect(slow.backgroundEffectNotice$.value).toBe("slow");
modernRouteMock.mockReturnValue(true);
});
it("offers nothing while the switcher is withheld", () => {
const vm = cameraMenu({ disableSwitcher: true });
expect(vm.options$.value).toEqual([]);
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
expect(vm.backgroundEffectNotice$.value).toBe("unavailable");
});
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
backgroundEffectSetting.setValue("blur");
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
});
const vm = cameraMenu({ pipeline$ });
expect(vm.backgroundEffect$.value).toBe("blur");
pipeline$.next({ supported: false, processor: undefined });
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
expect(vm.addBackgroundImage$.value).toBeUndefined();
expect(vm.removeBackgroundEffect$.value).toBeUndefined();
expect(vm.backgroundEffectNotice$.value).toBe("unavailable");
expect(vm.backgroundEffect$.value).toBe("none");
expect(backgroundEffectSetting.getValue()).toBe("blur");
});
it("saves the effect chosen, and none for one not on offer", () => {
const vm = cameraMenu();
vm.selectBackgroundEffect$.value!("image:arc");
expect(vm.backgroundEffect$.value).toBe("image:arc");
vm.selectBackgroundEffect$.value!("image:gone");
expect(backgroundEffectSetting.getValue()).toBe("none");
});
it("offers every effect in order, the added ones removable", () => {
added$.next([{ id: "mine", url: "blob:mine" }]);
expect(cameraMenu().backgroundEffects$.value).toEqual([
{ id: "none", kind: "none", removable: false },
{ id: "blur", kind: "blur", removable: false },
...shippedBackgrounds.map((background) => ({
id: `image:${background.id}`,
kind: "image",
imageUrl: background.imagePath,
removable: false,
})),
{
id: "added:mine",
kind: "image",
imageUrl: "blob:mine",
removable: true,
},
]);
});
it("passes on the wait for the first effect", () => {
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "settling",
});
const vm = cameraMenu({ pipeline$ });
expect(vm.backgroundEffectSettling$.value).toBe(true);
pipeline$.next({ ...pipeline$.value, wait: undefined });
expect(vm.backgroundEffectSettling$.value).toBe(false);
});
it("offers adding only below the limit", () => {
const vm = cameraMenu();
added$.next(
Array.from({ length: maxAddedBackgrounds }, (_, i) => ({
id: `${i}`,
url: `blob:${i}`,
})),
);
expect(vm.addBackgroundImage$.value).toBeUndefined();
added$.next(added$.value!.slice(1));
expect(vm.addBackgroundImage$.value).toBeDefined();
});
it("says why each refused file was refused", async () => {
const vm = cameraMenu();
store.add.mockRejectedValue(new UnusableImage("animated"));
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
await flushPromises();
const first = vm.backgroundImageRefusal$.value;
expect(first).toEqual({ reason: "animated" });
// The same again is a refusal of its own, so it is shown again.
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "animated" });
expect(vm.backgroundImageRefusal$.value).not.toBe(first);
store.add.mockRejectedValue(new Error("quota"));
vm.addBackgroundImage$.value!(new File(["x"], "b.png"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "not-kept" });
store.add.mockResolvedValue("kept");
vm.addBackgroundImage$.value!(new File(["x"], "c.png"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toBeUndefined();
});
it("never removes the selected background", () => {
store.remove.mockResolvedValue();
const vm = cameraMenu();
backgroundEffectSetting.setValue("added:worn");
vm.removeBackgroundEffect$.value!("added:worn");
expect(store.remove).not.toHaveBeenCalled();
vm.removeBackgroundEffect$.value!("added:spare");
expect(store.remove).toHaveBeenCalledWith("spare");
});
it("previews the camera only while there is one and it is not muted", () => {
const muted$ = new BehaviorSubject(false);
const track = mockVideoTrack({ muted$ });
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
const vm = cameraMenu({ camera$ });
expect(vm.cameraTrack$.value).toBe(track);
muted$.next(true);
expect(vm.cameraTrack$.value).toBeNull();
muted$.next(false);
expect(vm.cameraTrack$.value).toBe(track);
camera$.next(null);
expect(vm.cameraTrack$.value).toBeNull();
});
});
-316
View File
@@ -1,316 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
type Observable,
of,
switchMap,
} from "rxjs";
import { supportsAudioOutputSelection } from "livekit-client";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import {
parseEffect,
serializeEffect,
shippedBackgrounds,
} from "../tracks/backgroundEffectCatalog";
import {
type AddedBackground,
addedBackgrounds,
maxAddedBackgrounds,
type UnusableReason,
UnusableImage,
} from "../tracks/backgroundImages";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
import { type Behavior, constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates";
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
export interface BackgroundImageRefusal {
reason: UnusableReason | "not-kept";
}
/**
* A mute button with a menu to pick the device, shared by the microphone and
* the camera. Fields that belong to the other device are left empty.
*/
export interface MediaMenuSnapshot {
enabled: boolean;
busy: boolean;
/** Also controls if the button is disabled */
toggle: (() => void) | undefined;
/** No options leave out the menu, which is then a plain mute button. */
options: MenuOptions[];
selected: string | undefined;
select: ((id: string) => void) | undefined;
/** Output (speaker) devices. Microphone only. */
outputOptions: MenuOptions[];
selectedOutput: string | undefined;
/** Undefined where the platform can't route audio to a chosen device. */
selectOutput: ((id: string) => void) | undefined;
/** The selected background effect, as its option id. Camera only. */
backgroundEffect: string | undefined;
/** Every effect on offer, in the order they are shown. */
backgroundEffects: BackgroundEffectGridItem[];
/** What the user is told about effects here, if anything. */
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;
/** 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, unless it is the selected one. */
removeBackgroundEffect: ((id: string) => void) | undefined;
/** The camera to preview, with the selected effect; null while it is off. */
cameraTrack: LocalVideoMediaTrack | null;
}
export type MediaMenuViewModel = ViewModel<MediaMenuSnapshot>;
const noBackgroundEffects = {
backgroundEffect: undefined,
backgroundEffects: [],
backgroundEffectNotice: undefined,
backgroundEffectSettling: false,
backgroundImageRefusal: undefined,
selectBackgroundEffect: undefined,
addBackgroundImage: undefined,
removeBackgroundEffect: undefined,
cameraTrack: null,
} satisfies Partial<MediaMenuSnapshot>;
function options$(
disableSwitcher$: Behavior<boolean>,
available$: Behavior<Map<string, MenuOptions["label"]>>,
): Observable<MenuOptions[]> {
return disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({ id, label })),
),
),
),
);
}
/**
* The microphone's mute button and menu, with the speaker choice.
*
* @param disableSwitcher$ - True to leave out the menu.
*/
export function createAudioMenuViewModel(
scope: ObservableScope,
muteStates: MuteStates,
mediaDevices: MediaDevices,
disableSwitcher$: Behavior<boolean>,
): MediaMenuViewModel {
const { audio } = muteStates;
return {
...createStaticViewModel(noBackgroundEffects),
enabled$: audio.enabled$,
busy$: audio.syncing$,
toggle$: scope.behavior(audio.toggle$.pipe(map((t) => t ?? undefined))),
options$: scope.behavior(
options$(disableSwitcher$, mediaDevices.audioInput.available$),
),
selected$: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
),
select$: constant(mediaDevices.audioInput.select),
outputOptions$: scope.behavior(
options$(disableSwitcher$, mediaDevices.audioOutput.available$),
),
selectedOutput$: scope.behavior(
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
),
// Withheld where the platform can't route audio to a chosen device, which
// disables the speaker section.
selectOutput$: constant(
supportsAudioOutputSelection()
? mediaDevices.audioOutput.select
: undefined,
),
};
}
/**
* The camera's mute button and menu, with the background effects and a
* preview of the camera.
*
* @param disableSwitcher$ - True to leave out the menu.
* @param backgroundEffectsState$ - The background pipeline's state: whether
* effects run here, and the wait for the first.
* @param camera$ - The camera as the pipeline has it, for the preview.
*/
export function createCameraMenuViewModel(
scope: ObservableScope,
muteStates: MuteStates,
mediaDevices: MediaDevices,
disableSwitcher$: Behavior<boolean>,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
): MediaMenuViewModel {
const { video } = muteStates;
// Also false once the pipeline has failed to build this session.
const supported$ = backgroundEffectsState$.pipe(
map(({ supported }) => supported === true),
distinctUntilChanged(),
);
const slow = !supportsModernBackgroundProcessors();
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
);
// A new object for each refusal, so the same one twice is shown twice.
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
undefined,
);
return {
...createStaticViewModel({
outputOptions: [],
selectedOutput: undefined,
selectOutput: undefined,
}),
enabled$: video.enabled$,
busy$: video.syncing$,
toggle$: scope.behavior(video.toggle$.pipe(map((t) => t ?? undefined))),
options$: scope.behavior(
options$(disableSwitcher$, mediaDevices.videoInput.available$),
),
selected$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
select$: constant(mediaDevices.videoInput.select),
backgroundEffect$: scope.behavior(
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
map(([raw, supported]) =>
supported ? serializeEffect(parseEffect(raw)) : "none",
),
),
),
selectBackgroundEffect$: scope.behavior(
offered$.pipe(
map((offered) =>
offered
? (id: string): void =>
backgroundEffectSetting.setValue(
serializeEffect(parseEffect(id)),
)
: undefined,
),
),
),
backgroundEffects$: scope.behavior(
addedBackgrounds.added$.pipe(
map((added) => backgroundEffectChoices(added ?? [])),
),
),
backgroundEffectNotice$: scope.behavior(
offered$.pipe(
map((offered) =>
!offered ? "unavailable" : slow ? "slow" : undefined,
),
),
),
backgroundEffectSettling$: scope.behavior(
backgroundEffectsState$.pipe(
map(({ wait }) => wait !== undefined),
distinctUntilChanged(),
),
false,
),
// Kept and offered, not put on: that waits for the user to choose it.
addBackgroundImage$: scope.behavior(
combineLatest([offered$, addedBackgrounds.added$]).pipe(
map(([offered, added]) =>
offered && (added?.length ?? 0) < maxAddedBackgrounds
? (file: File): void => {
refusal$.next(undefined);
addedBackgrounds.add(file).catch((e) => {
logger.warn("Could not keep that background", e);
refusal$.next({
reason: e instanceof UnusableImage ? e.reason : "not-kept",
});
});
}
: undefined,
),
),
),
backgroundImageRefusal$: refusal$,
// None while muted: the call mutes its track rather than releasing it.
cameraTrack$: scope.behavior(
camera$.pipe(
switchMap((camera) =>
camera
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
: of(null),
),
),
null,
),
removeBackgroundEffect$: scope.behavior(
offered$.pipe(
map((offered) =>
offered
? (optionId: string): void => {
const effect = parseEffect(optionId);
if (effect.kind !== "added") return;
// The selected effect can't be removed: that would leave no
// effect selected.
if (backgroundEffectSetting.getValue() === optionId) return;
addedBackgrounds
.remove(effect.id)
.catch((e) =>
logger.warn("Could not remove that background", e),
);
}
: undefined,
),
),
),
};
}
function backgroundEffectChoices(
added: AddedBackground[],
): BackgroundEffectGridItem[] {
return [
{ id: serializeEffect({ kind: "none" }), kind: "none", removable: false },
{ id: serializeEffect({ kind: "blur" }), kind: "blur", removable: false },
...shippedBackgrounds.map((background) => ({
id: serializeEffect({ kind: "shipped", id: background.id }),
kind: "image" as const,
imageUrl: background.imagePath,
removable: false,
})),
...added.map((background) => ({
id: serializeEffect({ kind: "added", id: background.id }),
kind: "image" as const,
imageUrl: background.url,
removable: true,
})),
];
}
@@ -440,7 +440,7 @@ function swatch(colour: string): string {
)}`;
}
/** One's own backgrounds are removable, unless one is the selected one. */
/** One's own backgrounds are removable, never the one in force. */
export const AddedBackgroundsCanBeRemoved: Story = {
args: {
...VideoUnmute.args,
+1 -1
View File
@@ -85,7 +85,7 @@ export interface MediaMuteAndSwitchButtonProps {
backgroundEffectSettling?: boolean;
/** Called with the file chosen from the add tile. Omit to leave it out. */
onAddBackgroundImage?: (file: File) => void;
/** Removes a removable effect, unless it is the selected one. */
/** Removes a removable effect, never the one in force. */
onRemoveBackgroundEffect?: (id: string) => void;
/** Why the last file offered couldn't be used; a new object each time. */
backgroundImageRefusal?: { text: string };
+2 -7
View File
@@ -28,10 +28,8 @@ describe("SelfPreview", () => {
expect(track.detach).toHaveBeenCalledExactlyOnceWith(a);
});
it("mirrors the camera unless it faces away", () => {
const facingMode$ = new BehaviorSubject<"user" | "environment" | undefined>(
"user",
);
it("mirrors the camera while it faces the user, and only then", () => {
const facingMode$ = new BehaviorSubject<"user" | "environment">("user");
const { container } = render(
<SelfPreview track={mockVideoTrack({ facingMode$ })} />,
);
@@ -39,9 +37,6 @@ describe("SelfPreview", () => {
expect(video().classList.contains(styles.mirrored)).toBe(true);
act(() => facingMode$.next("environment"));
expect(video().classList.contains(styles.mirrored)).toBe(false);
// As the built-in camera of a Mac, which doesn't say
act(() => facingMode$.next(undefined));
expect(video().classList.contains(styles.mirrored)).toBe(true);
});
it("says the camera is off while there is none", () => {
+1 -2
View File
@@ -11,7 +11,6 @@ import { VideoCallOffIcon } from "@vector-im/compound-design-tokens/assets/web/i
import styles from "./SelfPreview.module.css";
import { useBehavior } from "../useBehavior";
import { mirrors } from "../utils/media";
const noFacingMode = constant(undefined);
@@ -43,7 +42,7 @@ export const SelfPreview: FC<Props> = ({ track }) => {
ref={videoRef}
muted
playsInline
className={mirrors(facingMode) ? styles.mirrored : undefined}
className={facingMode === "user" ? styles.mirrored : undefined}
/>
);
};
+16
View File
@@ -108,6 +108,22 @@ describe("Config.initWith", () => {
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
});
it("falls back to the defaults when config.json is not JSON", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("<!doctype html><title>Element</title>", {
status: 200,
headers: { "content-type": "text/html" },
}),
);
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
Reflect.set(Config, "internalInstance", undefined);
await expect(Config.init()).resolves.toBeUndefined();
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it("replaces a config initialized earlier", () => {
Config.initWith({ ssla: "https://first.invalid/ssla" });
Config.initWith({ ssla: "https://second.invalid/ssla" });
+6 -1
View File
@@ -156,5 +156,10 @@ async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
return {};
}
return response.json();
try {
return await response.json();
} catch (e) {
logger.warn(`Config at ${fetchTarget} is not JSON, using defaults`, e);
return {};
}
}
+62
View File
@@ -0,0 +1,62 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { BehaviorSubject } from "rxjs";
import { HomePage } from "./HomePage";
import { ClientContextProvider } from "../ClientContext";
import { type ClientSessionState } from "../state/ClientSession";
vi.mock("./RegisteredView", () => ({
RegisteredView: ({ client }: { client: MatrixClient }) => (
<span>registered as {client.getUserId()}</span>
),
}));
vi.mock("./UnauthenticatedView", () => ({
UnauthenticatedView: () => <span>unauthenticated</span>,
}));
test("waits for the session to be restored", () => {
renderHome({ state: "loading" });
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
});
test("shows the session's error", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
renderHome({ state: "error", error: new Error("Opened elsewhere") });
expect(screen.getByText("Something went wrong")).toBeVisible();
});
test("welcomes a signed-in user", () => {
renderHome({
state: "signedIn",
client: { getUserId: () => "@alice:example.org" } as MatrixClient,
passwordlessUser: false,
});
expect(screen.getByText("registered as @alice:example.org")).toBeVisible();
});
test("invites a signed-out user to start a call", () => {
renderHome({ state: "signedOut" });
expect(screen.getByText("unauthenticated")).toBeVisible();
});
function renderHome(state: ClientSessionState): void {
render(
<BrowserRouter>
<ClientContextProvider
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
>
<HomePage />
</ClientContextProvider>
</BrowserRouter>,
);
}
+11 -12
View File
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { useTranslation } from "react-i18next";
import { type FC } from "react";
import { useClientState } from "../ClientContext";
import { useClientSessionState } from "../ClientContext";
import { ErrorPage, LoadingPage } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView";
import { RegisteredView } from "./RegisteredView";
@@ -18,17 +18,16 @@ export const HomePage: FC = () => {
const { t } = useTranslation();
usePageTitle(t("common.home"));
const clientState = useClientState();
const session = useClientSessionState();
if (!clientState) {
return <LoadingPage />;
} else if (clientState.state === "error") {
return <ErrorPage error={clientState.error} />;
} else {
return clientState.authenticated ? (
<RegisteredView client={clientState.authenticated.client} />
) : (
<UnauthenticatedView />
);
switch (session.state) {
case "loading":
return <LoadingPage />;
case "error":
return <ErrorPage error={session.error} />;
case "signedIn":
return <RegisteredView client={session.client} />;
case "signedOut":
return <UnauthenticatedView />;
}
};
+136
View File
@@ -0,0 +1,136 @@
/*
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 { UnauthenticatedView } from "./UnauthenticatedView";
import { ClientManagerProvider } from "../ClientContext";
import { type ClientManager } from "../state/ClientSession";
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
import type * as matrixUtils from "../utils/matrix";
import { createRoom, getRelativeRoomUrl } from "../utils/matrix";
import { E2eeType } from "../e2ee/e2eeType";
vi.mock("../auth/useRecaptcha", () => ({
useRecaptcha: () => ({
execute: vi.fn().mockResolvedValue("captcha-response"),
reset,
recaptchaId: "recaptcha",
}),
}));
vi.mock("../auth/useRegistrationTerms", () => ({
useRegistrationTerms: () => ({ recaptchaKey: "site-key" }),
}));
vi.mock("../auth/generateRandomName", () => ({
generateRandomName: () => "Random Name",
}));
vi.mock("../utils/matrix", async (importOriginal) => ({
...(await importOriginal<typeof matrixUtils>()),
createRoom: vi.fn(),
}));
vi.mock("../UserMenuContainer", () => ({ UserMenuContainer: () => null }));
const reset = vi.fn();
const client = {} as MatrixClient;
afterEach(() => vi.clearAllMocks());
test("registers a guest, creates their call and signs them in", async () => {
const manager = mockManager();
const setSignedIn = vi.fn();
vi.mocked(manager.register).mockResolvedValue({ client, setSignedIn });
vi.mocked(createRoom).mockResolvedValue({
roomId: "!call:example.org",
alias: "#my-call:example.org",
password: "secret",
});
renderView(manager);
await submit("My Call", "Alice");
expect(manager.register).toHaveBeenCalledWith(
"Random Name",
expect.stringMatching(/^.{16}$/),
"Alice",
"captcha-response",
true,
);
expect(createRoom).toHaveBeenCalledWith(
client,
"My Call",
E2eeType.SHARED_KEY,
);
expect(setSignedIn).toHaveBeenCalled();
const url = getRelativeRoomUrl(
"!call:example.org",
{ kind: E2eeType.SHARED_KEY, secret: "secret" },
"My Call",
);
expect(await screen.findByText(`at ${url}`)).toBeVisible();
});
test("signs in only once the user chooses to join a call that exists", async () => {
const manager = mockManager();
const setSignedIn = vi.fn();
vi.mocked(manager.register).mockResolvedValue({ client, setSignedIn });
vi.mocked(createRoom).mockRejectedValue({ errcode: "M_ROOM_IN_USE" });
renderView(manager);
await submit("My Call", "Alice");
const join = await screen.findByRole("button", { name: "Yes, join call" });
expect(setSignedIn).not.toHaveBeenCalled();
await userEvent.click(join);
expect(setSignedIn).toHaveBeenCalled();
expect(await screen.findByText("at /my-call")).toBeVisible();
});
test("shows why registering failed and resets the captcha", async () => {
const manager = mockManager();
vi.mocked(manager.register).mockRejectedValue(new Error("Rate limited"));
renderView(manager);
await submit("My Call", "Alice");
expect(await screen.findByText("Rate limited")).toBeVisible();
expect(reset).toHaveBeenCalled();
expect(createRoom).not.toHaveBeenCalled();
});
async function submit(callName: string, displayName: string): Promise<void> {
const user = userEvent.setup();
await user.type(screen.getByTestId("home_callName"), callName);
await user.type(screen.getByTestId("home_displayName"), displayName);
await user.click(screen.getByTestId("home_go"));
}
function mockManager(): ClientManager {
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
}
const WhereAmI: FC = () => {
const { pathname, search, hash } = useLocation();
return <span>{`at ${pathname}${search}${hash}`}</span>;
};
function renderView(manager: ClientManager): void {
render(
<MemoryRouter initialEntries={["/"]}>
<UrlParamsProvider value={computeUrlParams()}>
<ClientManagerProvider value={manager}>
<UnauthenticatedView />
<WhereAmI />
</ClientManagerProvider>
</UrlParamsProvider>
</MemoryRouter>,
);
}
+12 -24
View File
@@ -12,7 +12,7 @@ import { Button, Heading, Text } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import { useNavigate } from "react-router-dom";
import { useClient } from "../ClientContext";
import { useClientManager } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { UserMenuContainer } from "../UserMenuContainer";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
@@ -22,7 +22,7 @@ import {
roomAliasLocalpartFromRoomName,
sanitiseRoomNameInput,
} from "../utils/matrix";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useRegistrationTerms } from "../auth/useRegistrationTerms";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useRecaptcha } from "../auth/useRecaptcha";
import { Form } from "../form/Form";
@@ -37,12 +37,12 @@ import { ExternalLink, Link } from "../button/Link";
import { useUrlParams } from "../UrlParams";
export const UnauthenticatedView: FC = () => {
const { setClient } = useClient();
const { register } = useClientManager();
const { header } = useUrlParams();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics();
const { recaptchaKey, register } = useInteractiveRegistration();
const { recaptchaKey } = useRegistrationTerms();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
@@ -67,7 +67,9 @@ export const UnauthenticatedView: FC = () => {
setLoading(true);
const recaptchaResponse = await execute();
const userName = generateRandomName();
const [client, session] = await register(
// Signed in only once the call exists too: this view is replaced the
// moment the session has a user, and the call is what they came for
const { client, setSignedIn } = await register(
userName,
secureRandomString(16),
displayName,
@@ -83,15 +85,12 @@ export const UnauthenticatedView: FC = () => {
E2eeType.SHARED_KEY,
);
} catch (error) {
if (!setClient) {
throw error;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => {
setClient(client, session);
// Wrapped once more, as a bare function would be taken for an updater
setOnFinished(() => () => {
setSignedIn();
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
navigate(`/${aliasLocalpart}`)?.catch((error) => {
logger.error("Failed to navigate to alias localpart", error);
@@ -106,14 +105,10 @@ export const UnauthenticatedView: FC = () => {
}
}
// Only consider the registration successful if we managed to create the room, too
if (!setClient) {
throw new Error("setClient is undefined");
}
if (!createRoomResult.password)
throw new Error("Failed to create room with shared secret");
setClient(client, session);
setSignedIn();
await navigate(
getRelativeRoomUrl(
createRoomResult.roomId,
@@ -130,14 +125,7 @@ export const UnauthenticatedView: FC = () => {
reset();
});
},
[
register,
reset,
execute,
navigate,
setJoinExistingCallModalOpen,
setClient,
],
[register, reset, execute, navigate, setJoinExistingCallModalOpen],
);
return (
+19 -9
View File
@@ -20,7 +20,9 @@ import { App } from "./App";
import { init as initRageshake } from "./settings/rageshake";
import { Initializer } from "./initializer";
import { AppViewModel } from "./state/AppViewModel";
import { pendingHostClientSession } from "./state/ClientSession";
import { globalScope } from "./state/ObservableScope";
import { StandaloneClientSession } from "./state/StandaloneClientSession";
import { getUrlParams } from "./UrlParams";
initRageshake().catch((e) => {
@@ -51,17 +53,25 @@ if (fatalError !== null) {
Initializer.initBeforeReact()
.then((widget) => {
const { controlledAudioDevices, callIntent } = getUrlParams();
// As a widget, the client comes from the host over the widget API, and
// the session is the host's. Standalone, Element Call restores or creates
// a session of its own, which it also manages.
const options = { controlledAudioDevices, callIntent };
let vm: AppViewModel;
if (widget === null) {
const session = new StandaloneClientSession(globalScope);
vm = new AppViewModel(globalScope, options, session, session);
} else {
vm = new AppViewModel(
globalScope,
options,
pendingHostClientSession(globalScope, widget.client),
null,
);
}
root.render(
<StrictMode>
<App
vm={
new AppViewModel(globalScope, {
controlledAudioDevices,
callIntent,
})
}
widget={widget}
/>
<App vm={vm} widget={widget} />
</StrictMode>,
);
})
+24 -24
View File
@@ -7,30 +7,30 @@ Please see LICENSE in the repository root for full details.
import { type RelationType } from "matrix-js-sdk";
import catSoundOgg from "../sound/reactions/cat.ogg?url";
import catSoundMp3 from "../sound/reactions/cat.mp3?url";
import clapSoundOgg from "../sound/reactions/clap.ogg?url";
import clapSoundMp3 from "../sound/reactions/clap.mp3?url";
import cricketsSoundOgg from "../sound/reactions/crickets.ogg?url";
import cricketsSoundMp3 from "../sound/reactions/crickets.mp3?url";
import dogSoundOgg from "../sound/reactions/dog.ogg?url";
import dogSoundMp3 from "../sound/reactions/dog.mp3?url";
import genericSoundOgg from "../sound/reactions/generic.ogg?url";
import genericSoundMp3 from "../sound/reactions/generic.mp3?url";
import lightbulbSoundOgg from "../sound/reactions/lightbulb.ogg?url";
import lightbulbSoundMp3 from "../sound/reactions/lightbulb.mp3?url";
import partySoundOgg from "../sound/reactions/party.ogg?url";
import partySoundMp3 from "../sound/reactions/party.mp3?url";
import deerSoundOgg from "../sound/reactions/deer.ogg?url";
import deerSoundMp3 from "../sound/reactions/deer.mp3?url";
import rockSoundOgg from "../sound/reactions/rock.ogg?url";
import rockSoundMp3 from "../sound/reactions/rock.mp3?url";
import waveSoundOgg from "../sound/reactions/wave.ogg?url";
import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
import bellSoundOgg from "../sound/reactions/bell.ogg?url";
import bellSoundMp3 from "../sound/reactions/bell.mp3?url";
import catSoundOgg from "../sound/reactions/cat.ogg?url&no-inline";
import catSoundMp3 from "../sound/reactions/cat.mp3?url&no-inline";
import clapSoundOgg from "../sound/reactions/clap.ogg?url&no-inline";
import clapSoundMp3 from "../sound/reactions/clap.mp3?url&no-inline";
import cricketsSoundOgg from "../sound/reactions/crickets.ogg?url&no-inline";
import cricketsSoundMp3 from "../sound/reactions/crickets.mp3?url&no-inline";
import dogSoundOgg from "../sound/reactions/dog.ogg?url&no-inline";
import dogSoundMp3 from "../sound/reactions/dog.mp3?url&no-inline";
import genericSoundOgg from "../sound/reactions/generic.ogg?url&no-inline";
import genericSoundMp3 from "../sound/reactions/generic.mp3?url&no-inline";
import lightbulbSoundOgg from "../sound/reactions/lightbulb.ogg?url&no-inline";
import lightbulbSoundMp3 from "../sound/reactions/lightbulb.mp3?url&no-inline";
import partySoundOgg from "../sound/reactions/party.ogg?url&no-inline";
import partySoundMp3 from "../sound/reactions/party.mp3?url&no-inline";
import deerSoundOgg from "../sound/reactions/deer.ogg?url&no-inline";
import deerSoundMp3 from "../sound/reactions/deer.mp3?url&no-inline";
import rockSoundOgg from "../sound/reactions/rock.ogg?url&no-inline";
import rockSoundMp3 from "../sound/reactions/rock.mp3?url&no-inline";
import waveSoundOgg from "../sound/reactions/wave.ogg?url&no-inline";
import waveSoundMp3 from "../sound/reactions/wave.mp3?url&no-inline";
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url&no-inline";
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url&no-inline";
import bellSoundOgg from "../sound/reactions/bell.ogg?url&no-inline";
import bellSoundMp3 from "../sound/reactions/bell.mp3?url&no-inline";
export const ElementCallReactionEventType = "io.element.call.reaction";
+2 -4
View File
@@ -18,7 +18,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext";
import { useHostBridge } from "../HostBridge";
import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
@@ -54,9 +54,7 @@ export const ReactionsSenderProvider = ({
vm: CallViewModel;
}): JSX.Element => {
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const clientState = useClientState();
const supportsReactions =
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
const { supportsReactions } = useHostBridge();
const room = rtcSession.room;
const myUserId = room.client.getUserId();
const myDeviceId = room.client.getDeviceId();
+12 -12
View File
@@ -8,18 +8,18 @@ Please see LICENSE in the repository root for full details.
import { type ReactNode, useEffect } from "react";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import joinCallSoundMp3 from "../sound/join_call.mp3";
import joinCallSoundOgg from "../sound/join_call.ogg";
import leftCallSoundMp3 from "../sound/left_call.mp3";
import leftCallSoundOgg from "../sound/left_call.ogg";
import handSoundOgg from "../sound/raise_hand.ogg";
import handSoundMp3 from "../sound/raise_hand.mp3";
import screenShareStartedOgg from "../sound/screen_share_started.ogg";
import screenShareStartedMp3 from "../sound/screen_share_started.mp3";
import declineMp3 from "../sound/call_declined.mp3?url";
import declineOgg from "../sound/call_declined.ogg?url";
import timeoutMp3 from "../sound/call_timeout.mp3?url";
import timeoutOgg from "../sound/call_timeout.ogg?url";
import joinCallSoundMp3 from "../sound/join_call.mp3?url&no-inline";
import joinCallSoundOgg from "../sound/join_call.ogg?url&no-inline";
import leftCallSoundMp3 from "../sound/left_call.mp3?url&no-inline";
import leftCallSoundOgg from "../sound/left_call.ogg?url&no-inline";
import handSoundOgg from "../sound/raise_hand.ogg?url&no-inline";
import handSoundMp3 from "../sound/raise_hand.mp3?url&no-inline";
import screenShareStartedOgg from "../sound/screen_share_started.ogg?url&no-inline";
import screenShareStartedMp3 from "../sound/screen_share_started.mp3?url&no-inline";
import declineMp3 from "../sound/call_declined.mp3?url&no-inline";
import declineOgg from "../sound/call_declined.ogg?url&no-inline";
import timeoutMp3 from "../sound/call_timeout.mp3?url&no-inline";
import timeoutOgg from "../sound/call_timeout.ogg?url&no-inline";
import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest";
+5 -7
View File
@@ -190,12 +190,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.matrixRoom,
mediaDevices,
props.muteStates,
props.e2eeSystem,
scope.behavior(observeElementSize$(rootElement)),
reactionsReader.raisedHands$,
reactionsReader.reactions$,
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
capture,
backgroundEffectsState$,
hostBridge,
@@ -205,7 +202,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
scope,
props.rtcSession,
),
windowSize$: scope.behavior(observeElementSize$(rootElement)),
},
reactionsReader.raisedHands$,
reactionsReader.reactions$,
);
setVm(vm);
@@ -822,9 +822,7 @@ export function rtcParticipationOptions(
const session = config.matrix_rtc_session;
return {
sendNotificationType: urlParams.sendNotificationType,
applicationData: urlParams.callIntent
? { "m.call.intent": urlParams.callIntent }
: undefined,
callIntent: urlParams.callIntent,
timings: {
syncDisconnectGracePeriodMs: config.sync_disconnect_grace_period_ms,
networkErrorRetryMs: session.network_error_retry_ms,
+2 -1
View File
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { expect, type MockedFunction, test, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { BehaviorSubject } from "rxjs";
import { RTC_CALL_INTENT_AUDIO } from "matrix-js-sdk/lib/matrixrtc";
import { useAudioContext } from "../useAudioContext";
import { createRingingMedia } from "../state/media/RingingMediaViewModel";
@@ -39,7 +40,7 @@ test("ringtone plays on loop while ringing", () => {
userId: alice.userId,
displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined),
intent: "audio",
intent: RTC_CALL_INTENT_AUDIO,
pickupState$,
});
+2 -2
View File
@@ -12,8 +12,8 @@ import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel
import { useBehavior } from "../useBehavior";
import { useInitial } from "../useInitial";
import { prefetchSounds } from "../soundUtils";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
import ringtoneMp3 from "../sound/ringtone.mp3?url&no-inline";
import ringtoneOgg from "../sound/ringtone.ogg?url&no-inline";
import { type UseAudioContext, useAudioContext } from "../useAudioContext";
import { useLatest } from "../useLatest";
+101
View File
@@ -0,0 +1,101 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { BehaviorSubject } from "rxjs";
import { RoomPage } from "./RoomPage";
import { ClientContextProvider } from "../ClientContext";
import { type ClientSessionState } from "../state/ClientSession";
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
import { useLoadGroupCall } from "./useLoadGroupCall";
vi.mock("./useLoadGroupCall", () => ({
useLoadGroupCall: vi.fn(() => ({ kind: "loading" })),
CallTerminatedMessage: class extends Error {},
}));
vi.mock("./useRoomName", () => ({ useRoomName: () => null }));
vi.mock("../profile/useProfile", () => ({ useProfile: () => ({}) }));
vi.mock("../auth/useRegisterPasswordlessUser", () => ({
useRegisterPasswordlessUser: () => ({ registerPasswordlessUser }),
}));
vi.mock("./RoomAuthView", () => ({
RoomAuthView: () => <span>room auth view</span>,
}));
vi.mock("../home/HomePage", () => ({
HomePage: () => <span>home page</span>,
}));
vi.mock("./CallView", () => ({
CallView: ({ isPasswordlessUser }: { isPasswordlessUser: boolean }) => (
<span>call view, passwordless: {String(isPasswordlessUser)}</span>
),
}));
vi.mock("./KnockLobbyView", () => ({ KnockLobbyView: () => null }));
const registerPasswordlessUser = vi.fn().mockResolvedValue(undefined);
afterEach(() => vi.clearAllMocks());
test("waits for the session to be restored", () => {
renderRoom({ state: "loading" }, "/call");
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
});
test("asks a signed-out user to join as a guest", () => {
renderRoom({ state: "signedOut" }, "/call");
expect(screen.getByText("room auth view")).toBeVisible();
expect(registerPasswordlessUser).not.toHaveBeenCalled();
});
test("registers a passwordless user when the URL names one", () => {
renderRoom({ state: "signedOut" }, "/call", "Alice");
expect(registerPasswordlessUser).toHaveBeenCalledWith("Alice");
});
test("falls back to the home page without a room", () => {
renderRoom(signedIn(false), "/");
expect(screen.getByText("home page")).toBeVisible();
});
test("tells the call whether its user is passwordless", () => {
vi.mocked(useLoadGroupCall).mockReturnValue({
kind: "loaded",
rtcSession: { room: {} },
} as unknown as ReturnType<typeof useLoadGroupCall>);
renderRoom(signedIn(true), "/call");
expect(screen.getByText("call view, passwordless: true")).toBeVisible();
});
function signedIn(passwordlessUser: boolean): ClientSessionState {
return {
state: "signedIn",
client: { getUserId: () => "@alice:example.org" } as MatrixClient,
passwordlessUser,
};
}
function renderRoom(
state: ClientSessionState,
path: string,
displayName: string | null = null,
): void {
render(
<MemoryRouter initialEntries={[path]}>
<UrlParamsProvider value={{ ...computeUrlParams(), displayName }}>
<ClientContextProvider
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
>
<RoomPage />
</ClientContextProvider>
</UrlParamsProvider>
</MemoryRouter>,
);
}
+7 -4
View File
@@ -12,7 +12,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { Trans, useTranslation } from "react-i18next";
import { UnknownSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useClientLegacy } from "../ClientContext";
import { useClient, useClientSessionState } from "../ClientContext";
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView";
import { CallView } from "./CallView";
@@ -42,8 +42,12 @@ export const RoomPage: FC = (): ReactNode => {
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false);
const { loading, authenticated, client, error, passwordlessUser } =
useClientLegacy();
const { client } = useClient();
const session = useClientSessionState();
const loading = session.state === "loading";
const passwordlessUser =
session.state === "signedIn" && session.passwordlessUser;
const authenticated = client !== undefined;
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
@@ -184,7 +188,6 @@ export const RoomPage: FC = (): ReactNode => {
};
if (loading || isRegistering) return <LoadingPage />;
if (error) return <ErrorPage error={error} />;
if (!client) return <RoomAuthView />;
// TODO: This doesn't belong here, the app routes need to be reworked
if (!roomIdOrAlias) return <HomePage />;
-17
View File
@@ -7,12 +7,9 @@ Please see LICENSE in the repository root for full details.
import { expect, describe, it, beforeAll } from "vitest";
import { render } from "@testing-library/react";
import { constant } from "@element-hq/matrixrtc-sdk";
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
import { E2eeType } from "../e2ee/e2eeType";
import { mockVideoTrack } from "../utils/test";
import styles from "./VideoPreview.module.css";
describe("VideoPreview", () => {
const matrixInfo: MatrixInfo = {
@@ -63,18 +60,4 @@ describe("VideoPreview", () => {
);
expect(queryByRole("status")).toHaveTextContent("Video loading...");
});
it("mirrors a camera that doesn't say which way it faces", () => {
const { container } = render(
<VideoPreview
matrixInfo={matrixInfo}
videoEnabled
videoTrack={mockVideoTrack({ facingMode$: constant(undefined) })}
children={<></>}
/>,
);
expect(
container.querySelector("video")!.classList.contains(styles.mirror),
).toBe(true);
});
});
+1 -2
View File
@@ -16,7 +16,6 @@ import styles from "./VideoPreview.module.css";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import videoPlaceholder from "../graphics/video-placeholder.gif";
import { useBehavior } from "../useBehavior";
import { mirrors } from "../utils/media";
const noFacingMode = constant(undefined);
@@ -65,7 +64,7 @@ export const VideoPreview: FC<Props> = ({
return (
<div className={classNames(styles.preview)} ref={previewRef}>
<video
className={mirrors(facingMode) ? styles.mirror : undefined}
className={facingMode === "user" ? styles.mirror : undefined}
ref={videoEl}
muted
playsInline
@@ -47,7 +47,6 @@ exports[`LobbyView > renders with AppBar android 1`] = `
class="_preview_dd2178"
>
<video
class="_mirror_dd2178"
disablepictureinpicture=""
playsinline=""
poster="/src/graphics/video-placeholder.gif"
@@ -280,7 +279,6 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
class="_preview_dd2178"
>
<video
class="_mirror_dd2178"
disablepictureinpicture=""
playsinline=""
poster="/src/graphics/video-placeholder.gif"
@@ -560,7 +558,6 @@ exports[`LobbyView > renders with header and participant count 1`] = `
class="_preview_dd2178"
>
<video
class="_mirror_dd2178"
disablepictureinpicture=""
playsinline=""
poster="/src/graphics/video-placeholder.gif"
+2 -16
View File
@@ -20,6 +20,7 @@ import { type MatrixClient } from "matrix-js-sdk/lib/client";
import { useSubmitRageshake, getRageshakeSubmitUrl } from "./submit-rageshake";
import { ClientContextProvider } from "../ClientContext";
import { hostClientSession } from "../state/ClientSession";
import { getUrlParams } from "../UrlParams";
import { mockConfig } from "../utils/test";
import { DEFAULT_CONFIG } from "../config/ConfigOptions";
@@ -74,22 +75,7 @@ function renderWithMockClient(
} as unknown as MatrixClient);
render(
<ClientContextProvider
value={{
state: "valid",
disconnected: false,
supportedFeatures: {
reactions: true,
},
setClient: vi.fn(),
authenticated: {
client,
isPasswordlessUser: true,
changePassword: vi.fn(),
logout: vi.fn(),
},
}}
>
<ClientContextProvider value={hostClientSession(client)}>
<TestComponent
sendLogs={sendLogs}
getRageshakeSubmitUrl={getRageshakeSubmitUrl}
+7 -26
View File
@@ -34,23 +34,6 @@ describe("prefetchSounds", () => {
expect(bytesOf(sounds.beep)).toEqual([1, 2, 3]);
});
it("decodes base64 data: URLs without fetching them", async () => {
// "OggS" — the bytes 0x4f 0x67 0x67 0x53
const sounds = await prefetchSounds({
beep: { ogg: "data:audio/ogg;base64,T2dnUw==" },
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(bytesOf(sounds.beep)).toEqual([0x4f, 0x67, 0x67, 0x53]);
});
it("leaves out a sound with a percent-encoded data: URL", async () => {
const sounds = await prefetchSounds({
beep: { ogg: "data:audio/ogg,Ogg%53" },
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(sounds).toEqual({});
});
it("leaves out a sound whose response is not okay", async () => {
fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
const sounds = await prefetchSounds({
@@ -60,18 +43,16 @@ describe("prefetchSounds", () => {
});
it("leaves out a sound that fails to load, keeping the others", async () => {
fetchSpy.mockRejectedValue(new TypeError("Failed to fetch"));
// Fetched in the order given
fetchSpy
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValueOnce(
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
);
const sounds = await prefetchSounds({
beep: { ogg: "https://example.org/beep.ogg" },
boop: { ogg: "data:audio/ogg;base64,T2dnUw==" },
boop: { ogg: "https://example.org/boop.ogg" },
});
expect(Object.keys(sounds)).toEqual(["boop"]);
});
it("leaves out a sound with a malformed data: URL", async () => {
const sounds = await prefetchSounds({
beep: { ogg: "data:audio/ogg;base64" },
});
expect(sounds).toEqual({});
});
});
+1 -27
View File
@@ -32,38 +32,12 @@ function getPreferredAudioFormat(): "ogg" | "mp3" {
const preferredFormat = getPreferredAudioFormat();
/**
* Decode a `data:` URL into the bytes it carries.
*
* The component build inlines the sound files as `data:` URLs. Those could be
* `fetch`ed like any other URL, but that fetch is subject to the host page's
* Content Security Policy, and `connect-src` lists rarely allow `data:` (a `*`
* source does not cover it). Reading the bytes out of the URL directly needs no
* network access, so no policy can stop it.
* @param url The `data:` URL to decode.
* @returns The decoded bytes.
*/
function decodeDataUrl(url: string): ArrayBuffer {
const separator = url.indexOf(",");
if (!url.startsWith("data:") || separator === -1)
throw new Error("Not a valid data: URL");
const header = url.slice("data:".length, separator);
// Vite only percent-encodes SVGs; every other inlined asset is base64.
if (!header.split(";").includes("base64"))
throw new Error("Only base64 data: URLs are supported");
// `decoded` is a binary string: one char per byte
const decoded = atob(url.slice(separator + 1));
const bytes = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
return bytes.buffer;
}
/**
* Load the bytes of a single sound file.
* @param url Where the sound is: an ordinary URL to fetch, or a `data:` URL.
* @param url Where the sound is.
* @returns The bytes, or null if the sound could not be loaded.
*/
async function loadSound(url: string): Promise<ArrayBuffer | null> {
if (url.startsWith("data:")) return decodeDataUrl(url);
const response = await fetch(url);
if (isFailure(response)) return null;
return await response.arrayBuffer();
+18 -12
View File
@@ -7,7 +7,11 @@ Please see LICENSE in the repository root for full details.
import { it, vi, expect, beforeEach, afterEach, describe } from "vitest";
import { firstValueFrom, of, Subject, take, toArray } from "rxjs";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import {
RTC_CALL_INTENT_AUDIO,
RTC_CALL_INTENT_VIDEO,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
import type { Controls, OutputDevice } from "../controls";
@@ -117,7 +121,7 @@ describe("Default selection", () => {
const controlledAudioOutput = new AndroidControlledAudioOutput(
of(BASE_DEVICE_LIST),
testScope,
"video",
RTC_CALL_INTENT_VIDEO,
mockControls,
);
@@ -142,7 +146,7 @@ describe("Default selection", () => {
const controlledAudioOutput = new AndroidControlledAudioOutput(
of(BASE_DEVICE_LIST),
testScope,
"audio",
RTC_CALL_INTENT_AUDIO,
mockControls,
);
@@ -163,7 +167,7 @@ describe("Default selection", () => {
});
});
["audio", "video"].forEach((callIntent) => {
[RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => {
it(`Default to BT headset for ${callIntent} calls if present`, async () => {
const controlledAudioOutput = new AndroidControlledAudioOutput(
of(BT_HEADSET_BASE_DEVICE_LIST),
@@ -190,7 +194,7 @@ describe("Default selection", () => {
});
});
["audio", "video"].forEach((callIntent) => {
[RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => {
it(`Default to wired headset for ${callIntent} calls if present`, async () => {
const controlledAudioOutput = new AndroidControlledAudioOutput(
of(WIRED_HEADSET_BASE_DEVICE_LIST),
@@ -300,7 +304,7 @@ describe("Test select a device", () => {
b: BT_HEADSET_BASE_DEVICE_LIST,
}),
testScope,
"audio",
RTC_CALL_INTENT_AUDIO,
mockControls,
);
@@ -340,7 +344,7 @@ describe("Test select a device", () => {
c: BASE_DEVICE_LIST,
}),
testScope,
"audio",
RTC_CALL_INTENT_AUDIO,
mockControls,
);
@@ -393,7 +397,7 @@ describe("Available device changes", () => {
});
it("When a BT headset is added, control should switch to use it", () => {
createAudioControlledOutput("video");
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
// Emit the base device list, the speaker should be selected
availableSource$.next(BASE_DEVICE_LIST);
@@ -419,7 +423,9 @@ describe("Available device changes", () => {
// Android does not set `isExternalHeadset` to true for wired headphones, so we can't test this case.'
it.skip("When a wired headset is added, control should switch to use it", async () => {
const controlledAudioOutput = createAudioControlledOutput("video");
const controlledAudioOutput = createAudioControlledOutput(
RTC_CALL_INTENT_VIDEO,
);
// Emit the base device list, the speaker should be selected
availableSource$.next(BASE_DEVICE_LIST);
@@ -446,7 +452,7 @@ describe("Available device changes", () => {
});
it("When the active bt headset is removed on audio call, control should switch to earpiece", () => {
createAudioControlledOutput("audio");
createAudioControlledOutput(RTC_CALL_INTENT_AUDIO);
// Emit the BT headset device list, the BT headset should be selected
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
@@ -472,7 +478,7 @@ describe("Available device changes", () => {
});
it("When the active bt headset is removed on video call, control should switch to speaker", () => {
createAudioControlledOutput("video");
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
@@ -497,7 +503,7 @@ describe("Available device changes", () => {
});
it("Do not repeatidly set the same device", () => {
createAudioControlledOutput("video");
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
+6 -3
View File
@@ -22,7 +22,10 @@ import {
type SelectedAudioOutputDevice,
} from "./MediaDevices.ts";
import type { ObservableScope } from "./ObservableScope.ts";
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import {
RTC_CALL_INTENT_AUDIO,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import { type Controls, type OutputDevice } from "../controls.ts";
import { type Behavior } from "./Behavior.ts";
@@ -102,7 +105,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
* @constructor
* @param controlledDevices$ - The list of available output devices coming from the hosting application, ordered by preference order (most preferred first).
* @param scope - The ObservableScope to create the Behaviors in.
* @param initialIntent - The initial call intent (e.g. "audio" or "video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls).
* @param initialIntent - The initial call intent (e.g. "m.audio" or "m.video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls).
* @param controls - The controls provided by the hosting application to control the audio routing and notify of user actions.
*/
public constructor(
@@ -316,7 +319,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
this.logger.debug(
`Android routing logic intent: ${this.initialIntent} finding best default...`,
);
if (this.initialIntent === "audio") {
if (this.initialIntent === RTC_CALL_INTENT_AUDIO) {
const systemProposed = available[0];
// If no headset is connected, android will route to the speaker by default,
// but for a voice call we want to route to the earpiece instead,
+7
View File
@@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ClientManager, type ClientSession } from "./ClientSession";
import { type AudioOutputOptions, MediaDevices } from "./MediaDevices";
import { type ObservableScope } from "./ObservableScope";
import { createAppBackgroundEffects } from "../tracks/BackgroundEffectsContext";
@@ -22,8 +23,14 @@ export class AppViewModel {
// TODO: Move more application logic here. The CallViewModel, at the very
// least, ought to be accessible from this object.
/**
* @param clientSession Where the client comes from. SPA js-sdk, EW host, widget
*/
public constructor(
private readonly scope: ObservableScope,
private readonly audioOutputOptions: AudioOutputOptions,
public readonly clientSession: ClientSession,
/** Null where the session is a host's and not ours to manage. */
public readonly clientManager: ClientManager | null,
) {}
}
+1 -2
View File
@@ -57,8 +57,7 @@ export interface BackgroundEffectsOptions {
supported: boolean;
/** The effect chosen, as the setting stores it. */
effect$: Behavior<string>;
/** Saves the chosen effect. Used to reset the choice to "none" when the
* chosen effect can't run here. */
/** Stores a choice, to forget one that can't be honoured. */
setEffect: (id: EffectId) => void;
/** The backgrounds the device keeps, undefined until it has said. */
added$: Behavior<AddedBackground[] | undefined>;
@@ -12,7 +12,11 @@ import {
MatrixEvent,
type Room,
} from "matrix-js-sdk";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import {
RTC_CALL_INTENT_AUDIO,
RTC_CALL_INTENT_VIDEO,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
import { withTestScheduler } from "../../utils/test";
@@ -33,10 +37,12 @@ function mockRingEvent(
eventId: string,
lifetimeMs: number | undefined,
sender = local.userId,
legacyIntent?: string,
): CallNotificationWrapper {
return {
event_id: eventId,
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
...(legacyIntent === undefined ? {} : { "m.call.intent": legacyIntent }),
notification_type: "ring",
sender,
} as unknown as CallNotificationWrapper;
@@ -128,7 +134,25 @@ test("ring attempt times out after nobody joins", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
A: { outcome: "timeout" },
});
});
});
test("ring attempt maps the legacy intent on the notification to its stable value", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
memberUserIds$: constant<string[]>([]),
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30, local.userId, "video"),
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: RTC_CALL_INTENT_VIDEO, recipient: alice.userId },
A: { outcome: "timeout" },
});
});
@@ -148,7 +172,7 @@ test("ring attempt is accepted once recipient joins", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
a: { intent: "audio", recipient: alice.userId },
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
A: { outcome: "accept" },
});
});
@@ -166,7 +190,7 @@ test("ring attempt is immediately accepted if recipient is already joined", () =
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", {
a: { intent: "audio", recipient: alice.userId },
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
A: { outcome: "accept" },
});
});
@@ -201,7 +225,7 @@ test("ring attempt can be declined", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
a: { intent: "audio", recipient: alice.userId },
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
A: { outcome: "decline" },
});
});
@@ -236,7 +260,7 @@ test("ring attempt times out if recipient declines too late", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
A: { outcome: "timeout" },
});
});
@@ -271,7 +295,7 @@ test("decline event relating to wrong event is ignored (times out)", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
A: { outcome: "timeout" },
});
});
@@ -306,7 +330,7 @@ test("decline event from wrong sender is ignored (times out)", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
A: { outcome: "timeout" },
});
});
@@ -9,7 +9,9 @@ import {
type IRTCNotificationContent,
type MatrixRTCSession,
MatrixRTCSessionEvent,
RTC_CALL_INTENT_AUDIO,
type RTCCallIntent,
toStableCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import {
filter,
@@ -155,7 +157,10 @@ export function createCallNotificationLifecycle$({
);
return of({
intent: notificationEvent["m.call.intent"] ?? "audio",
// The notification still carries the legacy intent values
intent:
toStableCallIntent(notificationEvent["m.call.intent"]) ??
RTC_CALL_INTENT_AUDIO,
recipient,
outcome$: race(timeout$, accept$, decline$).pipe(
take(1),
@@ -21,6 +21,7 @@ import {
type CallMembership,
type IRTCNotificationContent,
MatrixRTCSessionEvent,
RTC_CALL_INTENT_AUDIO,
} from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
@@ -35,6 +36,7 @@ import {
mockRtcMembership,
testScope,
} from "../../utils/test.ts";
import { E2eeType } from "../../e2ee/e2eeType.ts";
import {
alice,
aliceId,
@@ -1464,6 +1466,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
},
{
autoLeaveWhenOthersLeft: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
@@ -1481,6 +1484,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
},
{
autoLeaveWhenOthersLeft: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
@@ -1498,6 +1502,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
},
{
autoLeaveWhenOthersLeft: false,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
@@ -1527,6 +1532,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
},
{
autoLeaveWhenOthersLeft: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
@@ -1554,7 +1560,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
b: expect.objectContaining({
type: "ringing",
userId: alice.userId,
intent: "audio",
intent: RTC_CALL_INTENT_AUDIO,
}),
});
// Layout should show placeholder media for the participant we're
@@ -1600,7 +1606,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
b: expect.objectContaining({
type: "ringing",
userId: alice.userId,
intent: "audio",
intent: RTC_CALL_INTENT_AUDIO,
}),
});
// Layout should show placeholder media for the participant we're

Some files were not shown because too many files have changed in this diff Show More