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 cfd8312e46 Try to fix flaky background tests 2026-11-09 12:20:00 +01: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
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
93 changed files with 2882 additions and 1380 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
+3 -3
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
@@ -74,7 +74,7 @@ jobs:
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.
@@ -150,7 +150,7 @@ jobs:
name: Run end-to-end tests
needs: playwright
if: ${{ always() && !inputs.disable_e2e_tests }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
+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:
+2 -2
View File
@@ -19,10 +19,10 @@ 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:37d88b4426ff93a884b31c63d5319a7bcee32580f189c724e6b09f793a6de390
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:37d88b4426ff93a884b31c63d5319a7bcee32580f189c724e6b09f793a6de390
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.
+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",
+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
+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 };
}
+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";
+1 -3
View File
@@ -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 />;
+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,
) {}
}
@@ -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";
@@ -1559,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
@@ -1605,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
+45
View File
@@ -0,0 +1,45 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "vitest";
import { type MatrixClient } from "matrix-js-sdk";
import { hostClientSession, pendingHostClientSession } from "./ClientSession";
import { flushPromises, testScope } from "../utils/test";
const client = {} as MatrixClient;
test("a host's client is signed in from the start", () => {
const session = hostClientSession(client);
expect(session.state$.value).toEqual({
state: "signedIn",
client,
passwordlessUser: false,
});
});
test("a pending host client is loading until it arrives", async () => {
const session = pendingHostClientSession(
testScope(),
Promise.resolve(client),
);
expect(session.state$.value).toEqual({ state: "loading" });
await flushPromises();
expect(session.state$.value).toEqual({
state: "signedIn",
client,
passwordlessUser: false,
});
});
test("a pending host client that never arrives is an error", async () => {
const error = new Error("no widget API");
const session = pendingHostClientSession(testScope(), Promise.reject(error));
await flushPromises();
// An error page rather than a spinner that never ends
expect(session.state$.value).toEqual({ state: "error", error });
});
+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 { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { catchError, from, map, of } from "rxjs";
import { type Behavior, constant } from "./Behavior";
import { type ObservableScope } from "./ObservableScope";
/**
* A session of Element Call's own, as the standalone app stores it.
*/
export interface StoredSession {
user_id: string;
device_id: string;
access_token: string;
passwordlessUser: boolean;
}
export type ClientSessionState =
| { state: "loading" }
| { state: "signedOut" }
| { state: "signedIn"; client: MatrixClient; passwordlessUser: boolean }
| { state: "error"; error: Error };
/**
* A newly registered account, with its client started. Registering and signing
* in are separate steps, as a caller may have more to do with the account
* before the session changes hands — creating the call it was registered for,
* say.
*/
export interface Registration {
client: MatrixClient;
setSignedIn: () => void;
}
/**
* Where Element Call's client comes from. Every build has one: a session of
* Element Call's own, or one a host has supplied.
*/
export interface ClientSession {
state$: Behavior<ClientSessionState>;
}
/**
* What can be done with a client created in EC (SPA). Widget and host client
* wont have this, see useClientManager and useClientManagerIfAny
*/
export interface ClientManager {
/** Signs in to the homeserver with a password, ending any current session. */
login: (
homeserver: string,
username: string,
password: string,
) => Promise<MatrixClient>;
/** Registers an account on the default homeserver. */
register: (
username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser: boolean,
) => Promise<Registration>;
logout: () => Promise<void>;
}
/**
* A client supplied by the host, which owns the user's session: signed in from
* the start, nothing to restore. Used for the component target.
*/
export function hostClientSession(client: MatrixClient): ClientSession {
return { state$: constant(signedIn(client)) };
}
/**
* A client the host is still handing over.
* Used for the embedded target, widget API.
*/
export function pendingHostClientSession(
scope: ObservableScope,
client: Promise<MatrixClient>,
): ClientSession {
return {
state$: scope.behavior<ClientSessionState>(
from(client).pipe(
map(signedIn),
catchError((e) => {
logger.error("Failed to obtain the host's client", e);
return of<ClientSessionState>({ state: "error", error: e });
}),
),
{ state: "loading" },
),
};
}
function signedIn(client: MatrixClient): ClientSessionState {
return { state: "signedIn", client, passwordlessUser: false };
}
+9 -5
View File
@@ -21,6 +21,10 @@ import {
EARPIECE_CONFIG_ID,
IOSControlledAudioOutput,
} from "./IOSControlledAudioOutput";
import {
RTC_CALL_INTENT_AUDIO,
RTC_CALL_INTENT_VIDEO,
} from "matrix-js-sdk/lib/matrixrtc";
// `vi.mock` calls are hoisted above all imports, so the static imports below
// already see these mocks. Force the iOS platform so that the virtual earpiece
@@ -81,7 +85,7 @@ describe("Default selection", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
RTC_CALL_INTENT_AUDIO,
);
const selected = latestSelection(output);
@@ -100,7 +104,7 @@ describe("Default selection", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"video",
RTC_CALL_INTENT_VIDEO,
);
const selected = latestSelection(output);
@@ -113,7 +117,7 @@ describe("Default selection", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
RTC_CALL_INTENT_AUDIO,
);
const selected = latestSelection(output);
@@ -130,7 +134,7 @@ describe("Explicit selection", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
RTC_CALL_INTENT_AUDIO,
);
const selected = latestSelection(output);
@@ -150,7 +154,7 @@ describe("Explicit selection", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
RTC_CALL_INTENT_AUDIO,
);
const selected = latestSelection(output);
+6 -3
View File
@@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details.
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import {
RTC_CALL_INTENT_AUDIO,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import {
availableOutputDevices$ as controlledAvailableOutputDevices$,
outputDevice$ as controlledOutputSelection$,
@@ -111,7 +114,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
* Chooses the default output device when no explicit selection (from the user
* or the hosting application) has been made yet.
*
* For voice calls (`initialIntent === "audio"`) we want to start on the
* For voice calls (`initialIntent === "m.audio"`) we want to start on the
* earpiece rather than the speaker, like a regular phone call. We only
* override when the device that would otherwise be the default is the
* speaker: if the host already routed to a headset (e.g. Bluetooth) — which
@@ -122,7 +125,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
available: Map<string, AudioOutputDeviceLabel>,
): string | undefined {
const firstId = available.keys().next().value;
if (this.initialIntent === "audio") {
if (this.initialIntent === RTC_CALL_INTENT_AUDIO) {
const firstLabel =
firstId !== undefined ? available.get(firstId) : undefined;
if (firstLabel?.type === "speaker") {
+6 -2
View File
@@ -35,6 +35,10 @@ import { AudioOutput, MediaDevices } from "./MediaDevices";
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput";
import { ObservableScope } from "./ObservableScope";
import {
RTC_CALL_INTENT_AUDIO,
RTC_CALL_INTENT_VIDEO,
} from "matrix-js-sdk/lib/matrixrtc";
// Which audio output implementation is used is decided by what the app hosting
// Element Call told it, rather than being discovered from the environment.
@@ -52,7 +56,7 @@ describe("MediaDevices audio output", () => {
const devices = new MediaDevices(new ObservableScope(), {
controlledAudioDevices: true,
callIntent: "audio",
callIntent: RTC_CALL_INTENT_AUDIO,
});
expect(devices.audioOutput).toBeInstanceOf(AndroidControlledAudioOutput);
@@ -63,7 +67,7 @@ describe("MediaDevices audio output", () => {
const devices = new MediaDevices(new ObservableScope(), {
controlledAudioDevices: true,
callIntent: "video",
callIntent: RTC_CALL_INTENT_VIDEO,
});
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
+265
View File
@@ -0,0 +1,265 @@
/*
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 { type MatrixClient, MatrixError, type User } from "matrix-js-sdk";
import { StandaloneClientSession } from "./StandaloneClientSession";
import { type StoredSession } from "./ClientSession";
import { initClient } from "../utils/initClient";
import {
loginWithPassword,
registerWithPassword,
} from "../auth/interactiveAuth";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { Config } from "../config/Config";
import { OpenElsewhereError } from "../RichError";
import { flushPromises, testScope } from "../utils/test";
vi.mock("../utils/initClient", () => ({ initClient: vi.fn() }));
vi.mock("../auth/interactiveAuth", () => ({
loginWithPassword: vi.fn(),
registerWithPassword: vi.fn(),
}));
const SESSION_KEY = "matrix-auth-store";
const session: StoredSession = {
user_id: "@alice:example.org",
device_id: "AAAA",
access_token: "secret",
passwordlessUser: true,
};
const user = { setRawDisplayName: vi.fn(), setDisplayName: vi.fn() };
const mockClient = (): MatrixClient =>
({
stopClient: vi.fn(),
logout: vi.fn().mockResolvedValue(undefined),
clearStores: vi.fn().mockResolvedValue(undefined),
setDisplayName: vi.fn().mockResolvedValue(undefined),
getUserId: () => session.user_id,
getUser: () => user as unknown as User,
}) as Partial<MatrixClient> as MatrixClient;
const credentials = {
user_id: "@bob:example.org",
device_id: "BBBB",
access_token: "fresh",
};
const storeSession = (): void =>
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
const storedSession = (): StoredSession | null =>
JSON.parse(localStorage.getItem(SESSION_KEY) ?? "null");
beforeEach(() => {
vi.spyOn(Config, "init").mockResolvedValue(undefined);
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
"https://hs.example.org",
);
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
test("restores the stored session once the config is loaded", async () => {
storeSession();
const client = mockClient();
vi.mocked(initClient).mockResolvedValue(client);
const clientSession = new StandaloneClientSession(testScope());
expect(clientSession.state$.value).toEqual({ state: "loading" });
await flushPromises();
expect(Config.init).toHaveBeenCalled();
expect(initClient).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: "https://hs.example.org",
accessToken: "secret",
userId: "@alice:example.org",
deviceId: "AAAA",
}),
// Restoring: the crypto store is the session's, not to be wiped
true,
);
expect(clientSession.state$.value).toEqual({
state: "signedIn",
client,
passwordlessUser: true,
});
});
test("has no one signed in when there is no session to restore", async () => {
const clientSession = new StandaloneClientSession(testScope());
await flushPromises();
expect(initClient).not.toHaveBeenCalled();
expect(clientSession.state$.value).toEqual({ state: "signedOut" });
});
test("drops a session the homeserver no longer knows", async () => {
storeSession();
vi.mocked(initClient).mockRejectedValue(
new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Unknown token" }),
);
const clientSession = new StandaloneClientSession(testScope());
await flushPromises();
expect(storedSession()).toBeNull();
expect(clientSession.state$.value).toEqual({ state: "signedOut" });
});
test("drops the session and reports any other error", async () => {
storeSession();
const error = new Error("offline");
vi.mocked(initClient).mockRejectedValue(error);
const clientSession = new StandaloneClientSession(testScope());
await flushPromises();
expect(storedSession()).toBeNull();
// An error page rather than a spinner that never ends
expect(clientSession.state$.value).toEqual({ state: "error", error });
});
test("logs in with a password, ending the session it had", async () => {
storeSession();
const restored = mockClient();
vi.mocked(initClient).mockResolvedValueOnce(restored);
const clientSession = new StandaloneClientSession(testScope());
await flushPromises();
const client = mockClient();
vi.mocked(loginWithPassword).mockResolvedValue(credentials);
vi.mocked(initClient).mockResolvedValueOnce(client);
await expect(
clientSession.login("https://other.example.org", "bob", "hunter2"),
).resolves.toBe(client);
expect(loginWithPassword).toHaveBeenCalledWith(
"https://other.example.org",
"bob",
"hunter2",
);
// The old session is logged out before the new client starts, so that the
// crypto stores are not shared between the two
expect(restored.logout).toHaveBeenCalledWith(true);
expect(initClient).toHaveBeenLastCalledWith(
{
baseUrl: "https://other.example.org",
accessToken: "fresh",
userId: "@bob:example.org",
deviceId: "BBBB",
},
false,
);
expect(clientSession.state$.value).toEqual({
state: "signedIn",
client,
passwordlessUser: false,
});
expect(storedSession()).toEqual({ ...credentials, passwordlessUser: false });
});
test("registers a passwordless account, signing in only when asked", async () => {
const clientSession = new StandaloneClientSession(testScope());
await flushPromises();
const client = mockClient();
vi.mocked(registerWithPassword).mockResolvedValue(credentials);
vi.mocked(initClient).mockResolvedValue(client);
const registration = await clientSession.register(
"bob",
"temporary",
"Bob",
"captcha",
true,
);
expect(registerWithPassword).toHaveBeenCalledWith(
"https://hs.example.org",
"bob",
"temporary",
"captcha",
);
expect(registration.client).toBe(client);
expect(client.setDisplayName).toHaveBeenCalledWith("Bob");
expect(user.setDisplayName).toHaveBeenCalledWith("Bob");
// Registered, but not yet signed in: the caller may still be setting up
expect(clientSession.state$.value).toEqual({ state: "signedOut" });
expect(storedSession()).toBeNull();
registration.setSignedIn();
expect(clientSession.state$.value).toEqual({
state: "signedIn",
client,
passwordlessUser: true,
});
expect(storedSession()).toEqual({ ...credentials, passwordlessUser: true });
});
test("registers an account with a password of its own", async () => {
const clientSession = new StandaloneClientSession(testScope());
await flushPromises();
vi.mocked(registerWithPassword).mockResolvedValue(credentials);
vi.mocked(initClient).mockResolvedValue(mockClient());
const { setSignedIn } = await clientSession.register(
"bob",
"hunter2",
"Bob",
"captcha",
false,
);
setSignedIn();
expect(storedSession()).toEqual({ ...credentials, passwordlessUser: false });
});
test("logs out, clearing the client's stores and the stored session", async () => {
storeSession();
const client = mockClient();
vi.mocked(initClient).mockResolvedValue(client);
const clientSession = new StandaloneClientSession(testScope());
await flushPromises();
const analyticsLogout = vi.spyOn(PosthogAnalytics.instance, "logout");
await clientSession.logout();
expect(client.logout).toHaveBeenCalledWith(true);
expect(client.clearStores).toHaveBeenCalled();
expect(storedSession()).toBeNull();
expect(clientSession.state$.value).toEqual({ state: "signedOut" });
// Analytics forget the user along with the session
expect(analyticsLogout).toHaveBeenCalled();
});
test("claims exclusive use of storage from other instances of the app", () => {
// The channel is created with the session, so spy on the prototype rather
// than trying to replace the global.
const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage");
new StandaloneClientSession(testScope());
expect(postMessage).toHaveBeenCalled();
});
test("stops when another instance of the app claims storage", async () => {
storeSession();
const client = mockClient();
vi.mocked(initClient).mockResolvedValue(client);
const clientSession = new StandaloneClientSession(testScope());
await flushPromises();
new BroadcastChannel("load").postMessage({});
await vi.waitFor(() =>
expect(clientSession.state$.value).toMatchObject({ state: "error" }),
);
expect(client.stopClient).toHaveBeenCalled();
const { value } = clientSession.state$;
expect(value.state === "error" && value.error).toBeInstanceOf(
OpenElsewhereError,
);
});
+222
View File
@@ -0,0 +1,222 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient, MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject } from "rxjs";
import {
PosthogAnalytics,
RegistrationType,
} from "../analytics/PosthogAnalytics";
import { Config } from "../config/Config";
import { OpenElsewhereError } from "../RichError";
import {
loginWithPassword,
registerWithPassword,
} from "../auth/interactiveAuth";
import { initClient } from "../utils/initClient";
import { fallbackICEServerAllowed } from "../utils/matrix";
import { type Behavior } from "./Behavior";
import {
type ClientManager,
type ClientSession,
type ClientSessionState,
type Registration,
type StoredSession,
} from "./ClientSession";
import { type ObservableScope } from "./ObservableScope";
const SESSION_KEY = "matrix-auth-store";
/**
* A client session for the SPA: restored from storage on construction, and
* signed in to, out of, or re-authenticated by the login, registration and
* account flows.
*
* This class is responsible for restoring from store and logout and change Pwd.
*
* To make sure we do not load/bundle the client store worker it is important
* that this is a separate file: only this module reaches `utils/initClient`,
* and therefore the client store worker, so it may only be imported by the
* standalone app's entry point.
*/
export class StandaloneClientSession implements ClientSession, ClientManager {
private readonly state = new BehaviorSubject<ClientSessionState>({
state: "loading",
});
public readonly state$: Behavior<ClientSessionState> = this.scope.behavior(
this.state,
);
// 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.
private readonly loadChannel =
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
public constructor(private readonly scope: ObservableScope) {
this.loadChannel?.postMessage({});
const onOpenedElsewhere = (): void => {
this.client?.stopClient();
this.state.next({ state: "error", error: new OpenElsewhereError() });
};
this.loadChannel?.addEventListener("message", onOpenedElsewhere);
scope.onEnd(() =>
this.loadChannel?.removeEventListener("message", onOpenedElsewhere),
);
this.restore().catch((e) => {
logger.error("Failed to restore the session", e);
this.state.next({ state: "error", error: e });
});
}
private get client(): MatrixClient | undefined {
const { value } = this.state;
return value.state === "signedIn" ? value.client : undefined;
}
private async restore(): Promise<void> {
// The homeserver to restore against is in the config, which is loaded
// concurrently with this
await Config.init();
try {
const session = loadSession();
if (session === undefined) {
logger.log("No session stored; continuing without a client");
this.state.next({ state: "signedOut" });
return;
}
logger.log("Using a standalone client");
const client = await initClient(
{
baseUrl: Config.defaultHomeserverUrl()!,
accessToken: session.access_token,
userId: session.user_id,
deviceId: session.device_id,
fallbackICEServerAllowed,
livekitServiceURL: Config.get().livekit?.livekit_service_url,
},
true,
);
this.state.next({
state: "signedIn",
client,
passwordlessUser: session.passwordlessUser,
});
} catch (e) {
// Whatever went wrong, the stored session is not to be tried again
clearSession();
if (e instanceof MatrixError && e.errcode === "M_UNKNOWN_TOKEN") {
logger.log(
"The session from local store is invalid; continuing without a client",
);
this.state.next({ state: "signedOut" });
return;
}
throw e;
}
}
public readonly login = async (
homeserver: string,
username: string,
password: string,
): Promise<MatrixClient> => {
const session: StoredSession = {
...(await loginWithPassword(homeserver, username, password)),
passwordlessUser: false,
};
const client = await this.createClient(homeserver, session);
this.signIn(client, session);
return client;
};
public readonly register = async (
username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser: boolean,
): Promise<Registration> => {
const homeserver = Config.defaultHomeserverUrl()!;
const session: StoredSession = {
...(await registerWithPassword(
homeserver,
username,
password,
recaptchaResponse,
)),
passwordlessUser,
};
const client = await this.createClient(homeserver, session);
await client.setDisplayName(displayName);
const user = client.getUser(client.getUserId()!)!;
user.setRawDisplayName(displayName);
user.setDisplayName(displayName);
return { client, setSignedIn: (): void => this.signIn(client, session) };
};
/**
* Starts a client for a new session, ending the current one first.
*/
private async createClient(
homeserver: string,
session: StoredSession,
): Promise<MatrixClient> {
// To not confuse the rust crypto sessions we need to log out the old
// client before initializing the new one
await this.client?.logout(true);
return initClient(
{
baseUrl: homeserver,
accessToken: session.access_token,
userId: session.user_id,
deviceId: session.device_id,
},
false,
);
}
private signIn(client: MatrixClient, session: StoredSession): void {
saveSession(session);
this.state.next({
state: "signedIn",
client,
passwordlessUser: session.passwordlessUser,
});
}
public readonly logout = async (): Promise<void> => {
const client = this.client;
if (!client) return;
await client.logout(true);
await client.clearStores();
clearSession();
this.state.next({ state: "signedOut" });
PosthogAnalytics.instance.logout();
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
};
}
function loadSession(): StoredSession | undefined {
const data = localStorage.getItem(SESSION_KEY);
return data ? JSON.parse(data) : undefined;
}
function saveSession(session: StoredSession): void {
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
}
function clearSession(): void {
localStorage.removeItem(SESSION_KEY);
}
+15 -11
View File
@@ -6,7 +6,11 @@ Please see LICENSE in the repository root for full details.
*/
import { test, expect } from "vitest";
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 { calculateInitialMuteState } from "./initialMuteState";
@@ -14,10 +18,10 @@ test.each<{
callIntent: RTCCallIntent;
allowJoinUnmutedViaIntent: boolean;
}>([
{ callIntent: "audio", allowJoinUnmutedViaIntent: false },
{ callIntent: "audio", allowJoinUnmutedViaIntent: true },
{ callIntent: "video", allowJoinUnmutedViaIntent: false },
{ callIntent: "video", allowJoinUnmutedViaIntent: true },
{ callIntent: RTC_CALL_INTENT_AUDIO, allowJoinUnmutedViaIntent: false },
{ callIntent: RTC_CALL_INTENT_AUDIO, allowJoinUnmutedViaIntent: true },
{ callIntent: RTC_CALL_INTENT_VIDEO, allowJoinUnmutedViaIntent: false },
{ callIntent: RTC_CALL_INTENT_VIDEO, allowJoinUnmutedViaIntent: true },
{ callIntent: "unknown", allowJoinUnmutedViaIntent: false },
{ callIntent: "unknown", allowJoinUnmutedViaIntent: true },
])(
@@ -29,15 +33,15 @@ test.each<{
allowJoinUnmutedViaIntent,
);
expect(audioEnabled).toBe(true);
expect(videoEnabled).toBe(callIntent !== "audio");
expect(videoEnabled).toBe(callIntent !== RTC_CALL_INTENT_AUDIO);
},
);
test.each<{
callIntent: RTCCallIntent;
}>([
{ callIntent: "audio" },
{ callIntent: "video" },
{ callIntent: RTC_CALL_INTENT_AUDIO },
{ callIntent: RTC_CALL_INTENT_VIDEO },
{ callIntent: "unknown" },
])(
"Should always mute on start if skipping lobby and the host does not vouch for the intent (callIntent: $callIntent)",
@@ -55,8 +59,8 @@ test.each<{
test.each<{
callIntent: RTCCallIntent;
}>([
{ callIntent: "audio" },
{ callIntent: "video" },
{ callIntent: RTC_CALL_INTENT_AUDIO },
{ callIntent: RTC_CALL_INTENT_VIDEO },
{ callIntent: "unknown" },
])(
"Can start unmuted if skipping lobby and the host vouches for the intent (callIntent: $callIntent)",
@@ -67,6 +71,6 @@ test.each<{
true,
);
expect(audioEnabled).toBe(true);
expect(videoEnabled).toBe(callIntent !== "audio");
expect(videoEnabled).toBe(callIntent !== RTC_CALL_INTENT_AUDIO);
},
);
+6 -3
View File
@@ -6,7 +6,10 @@ Please see LICENSE in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/lib/logger";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import {
RTC_CALL_INTENT_AUDIO,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
/**
* Calculates the initial mute state for media devices based on configuration.
@@ -40,10 +43,10 @@ export function calculateInitialMuteState(
// A host that vouches for the intent is a trusted environment, so it allows
// unmuted by default. Same for when showing a lobby, as users can adjust
// their settings there. Additionally, if the call intent is "audio", we
// their settings there. Additionally, if the call intent is "m.audio", we
// disable video by default.
return {
audioEnabled: true,
videoEnabled: callIntent != "audio",
videoEnabled: callIntent != RTC_CALL_INTENT_AUDIO,
};
}
+5 -2
View File
@@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details.
import { test, expect } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import {
RTC_CALL_INTENT_AUDIO,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject } from "rxjs";
import { GridTile } from "./GridTile";
@@ -122,7 +125,7 @@ test("GridTile displays ringing media", async () => {
const vm = createRingingMedia({
pickupState$,
id: "test",
intent: "audio",
intent: RTC_CALL_INTENT_AUDIO,
userId: "@alice:example.org",
displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined),
+2 -1
View File
@@ -12,6 +12,7 @@ import {
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { RTC_CALL_INTENT_VIDEO } from "matrix-js-sdk/lib/matrixrtc";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { useBehavior } from "../useBehavior";
@@ -25,7 +26,7 @@ export const RingingStatus: FC<Props> = ({ vm }) => {
const pickupState = useBehavior(vm.pickupState$);
const Icon =
pickupState === "ringing"
? vm.intent === "video"
? vm.intent === RTC_CALL_INTENT_VIDEO
? VideoCallSolidIcon
: VoiceCallSolidIcon
: EndCallIcon;
+2 -1
View File
@@ -11,6 +11,7 @@ import { axe } from "vitest-axe";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import { BehaviorSubject } from "rxjs";
import { RTC_CALL_INTENT_AUDIO } from "matrix-js-sdk/lib/matrixrtc";
import { SpotlightTile } from "./SpotlightTile";
import {
@@ -176,7 +177,7 @@ test("SpotlightTile displays ringing media", async () => {
const vm = createRingingMedia({
pickupState$,
id: "test",
intent: "audio",
intent: RTC_CALL_INTENT_AUDIO,
userId: "@alice:example.org",
displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined),
+52 -1
View File
@@ -20,6 +20,7 @@ import {
const frame = {} as VideoFrame;
const controller = {} as TransformStreamDefaultController<VideoFrame>;
const stats = { processingTimeMs: 1, segmentationTimeMs: 1, filterTimeMs: 0 };
describe("BackgroundEffectTransformer", () => {
afterEach(() => {
@@ -47,10 +48,29 @@ describe("BackgroundEffectTransformer", () => {
expect(load).toHaveBeenCalledWith("/background.jpg");
});
it("runs MediaPipe from its own bundle", async () => {
vi.spyOn(VideoTransformer.prototype, "init").mockResolvedValue();
const create = vi
.spyOn(ImageSegmenter, "createFromOptions")
.mockResolvedValue({} as ImageSegmenter);
await new BackgroundEffectTransformer({ backgroundDisabled: true }).init(
{} as VideoTransformerInitOptions,
);
const [fileset, options] = create.mock.calls[0];
expect(fileset.wasmLoaderPath).toMatch(/vision_wasm_internal\.js/);
expect(fileset.wasmBinaryPath).toMatch(/vision_wasm_internal\.wasm/);
expect(options.baseOptions?.modelAssetPath).toMatch(
/selfie_segmenter\.tflite/,
);
});
it("reports the first frame carrying an effect, once", async () => {
// The library reports every frame it draws.
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
async function (this: BackgroundTransformer) {
this.isFirstFrame = false;
this.options.onFrameProcessed?.(stats);
return Promise.resolve();
},
);
@@ -73,6 +93,37 @@ describe("BackgroundEffectTransformer", () => {
expect(transformer.onFirstFrame).not.toHaveBeenCalled();
});
it("keeps reporting drawn frames to whoever asked", async () => {
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
async function (this: BackgroundTransformer) {
this.options.onFrameProcessed?.(stats);
return Promise.resolve();
},
);
const onFrameProcessed = vi.fn();
const transformer = new BackgroundEffectTransformer({ onFrameProcessed });
await transformer.transform(frame, controller);
expect(onFrameProcessed).toHaveBeenCalledWith(stats);
});
it("never sends the first frame on untouched", async () => {
// The library copies its first frame through untouched, ahead of the
// drawn one, unless told the first frame is past
const seen: boolean[] = [];
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
async function (this: BackgroundTransformer) {
seen.push(this.isFirstFrame);
return Promise.resolve();
},
);
const transformer = new BackgroundEffectTransformer({});
expect(transformer.isFirstFrame).toBe(true);
await transformer.transform(frame, controller);
expect(seen).toEqual([false]);
});
it("passes frames on untouched once disabled, even after blurring", async () => {
const transformer = new BackgroundEffectTransformer({});
await transformer.update({ blurRadius: 15, backgroundDisabled: false });
+36 -26
View File
@@ -16,7 +16,13 @@ import {
import { logger } from "matrix-js-sdk/lib/logger";
import { ImageSegmenter } from "@mediapipe/tasks-vision";
import modelAssetPath from "../mediapipe/imageSegmenter/selfie_segmenter.tflite?url";
// Only the SIMD build of MediaPipe's runtime: every browser Element Call
// supports has WebAssembly SIMD. As files rather than data URLs, so that a
// host's bundler serves them as assets of its own, and a browser fetches them
// only when an effect is first switched on.
import wasmLoaderPath from "@mediapipe/tasks-vision/vision_wasm_internal.js?url&no-inline";
import wasmBinaryPath from "@mediapipe/tasks-vision/vision_wasm_internal.wasm?url&no-inline";
import modelAssetPath from "../mediapipe/imageSegmenter/selfie_segmenter.tflite?url&no-inline";
interface WasmFileset {
/** The path to the Wasm loader script. */
@@ -25,26 +31,7 @@ interface WasmFileset {
wasmBinaryPath: string;
}
// The MediaPipe package, by default, ships some alternative versions of the
// WASM files which avoid SIMD for compatibility with older browsers. But SIMD
// in WASM is actually fine by our support policy, so we include just the SIMD
// versions.
// It's really not ideal that we have to reference these internal files from
// MediaPipe and depend on node_modules having this specific structure. It's
// easy to see this breaking if our dependencies changed and MediaPipe were
// no longer hoisted, or if we switched to another dependency loader such as
// yarn PnP.
// https://github.com/google-ai-edge/mediapipe/issues/5961
const wasmFileset: WasmFileset = {
wasmLoaderPath: new URL(
"../../node_modules/@mediapipe/tasks-vision/wasm/vision_wasm_internal.js",
import.meta.url,
).href,
wasmBinaryPath: new URL(
"../../node_modules/@mediapipe/tasks-vision/wasm/vision_wasm_internal.wasm",
import.meta.url,
).href,
};
const wasmFileset: WasmFileset = { wasmLoaderPath, wasmBinaryPath };
/**
* Track processor that applies effects such as blurring to a user's background.
@@ -54,18 +41,41 @@ const wasmFileset: WasmFileset = {
* resource fetched from the public internet.
*/
export class BackgroundEffectTransformer extends BackgroundTransformer {
/** Called once, when the first frame carrying an effect has been drawn. */
/**
* Called once, when the first frame carrying an effect has been drawn.
* This is used for drewAFrame$ to show the correct loading state.
*/
public onFirstFrame: (() => void) | undefined;
private drewAFrame = false;
public constructor(opts: BackgroundOptions) {
super(opts);
// Called by the library for a frame it drew, never for one it passed on
// untouched.
this.options.onFrameProcessed = (stats): void => {
opts.onFrameProcessed?.(stats);
if (this.drewAFrame) return;
this.drewAFrame = true;
this.onFirstFrame?.();
};
}
public override async transform(
frame: VideoFrame,
controller: TransformStreamDefaultController<VideoFrame>,
): Promise<void> {
const priming = this.isFirstFrame;
// From the LK docs:
// > Render a copy of the first frame is rendered to the screen as soon as possible to act
// > as a less jarring initial state than a solid color while the synchronous work below
// > (segmentation + frame rendering) occurs.
// We do not want this copy (it leaks unblurred media).
// So we always set `isFirstFrame` to false -> never show an unprocessed frame.
// It does not have any impact other than setting the unprocessed frame while loading the processor.
//
// To not show the blank frame, we have our own mechanism for hiding the loading overlay:
// `onFirstFrame` is what the EC UI will listen to and only then show the video feed.
this.isFirstFrame = false;
await super.transform(frame, controller);
// Cleared only once a frame has been through the segmenter; a frame
// passed through untouched leaves it set.
if (priming && !this.isFirstFrame) this.onFirstFrame?.();
}
/**
+2 -2
View File
@@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import arc from "../graphics/background-arc.jpg?url";
import glow from "../graphics/background-glow.jpg?url";
import arc from "../graphics/background-arc.jpg?url&no-inline";
import glow from "../graphics/background-glow.jpg?url&no-inline";
export interface ShippedBackground {
/** Stored in the user's settings when chosen, so it must not change. */
+214
View File
@@ -0,0 +1,214 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventEmitter } from "events";
import {
afterEach,
beforeEach,
describe,
expect,
it,
type Mock,
vi,
} from "vitest";
import {
ClientEvent,
createClient,
type ICreateClientOpts,
IndexedDBStore,
MemoryStore,
} from "matrix-js-sdk";
import type * as MatrixSdk from "matrix-js-sdk";
import { initClient } from "./initClient";
import { flushPromises } from "./test";
interface Store {
startup: Mock<() => Promise<void>>;
getSavedSyncToken: Mock<() => Promise<string | null>>;
}
const store = (): Store => ({
startup: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
getSavedSyncToken: vi
.fn<() => Promise<string | null>>()
.mockResolvedValue(null),
});
// The client is the SDK's business; what these tests are about is how it is
// set up: its stores, its crypto, and when it counts as ready.
vi.mock("matrix-js-sdk", async (importOriginal) => ({
...(await importOriginal<typeof MatrixSdk>()),
createClient: vi.fn(),
IndexedDBStore: vi.fn(function (this: Store) {
Object.assign(this, store());
}),
MemoryStore: vi.fn(function (this: Store) {
Object.assign(this, store());
}),
}));
interface FakeClient extends EventEmitter {
store: Store;
clearStores: Mock<() => Promise<void>>;
initRustCrypto: Mock<() => Promise<void>>;
startClient: Mock<() => Promise<void>>;
}
/**
* A client that, unless told otherwise, reports its first sync a tick after
* being started, as the real one does once the network answers.
*/
function fakeClient(opts: ICreateClientOpts, syncs = true): FakeClient {
const client = Object.assign(new EventEmitter(), {
store: opts.store as unknown as Store,
clearStores: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
initRustCrypto: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
startClient: vi.fn(async () => {
if (!syncs) return;
await new Promise((resolve) => setTimeout(resolve, 0));
client.emit(ClientEvent.Sync, "PREPARED", null);
}),
});
return client;
}
const options: ICreateClientOpts = { baseUrl: "https://hs.example.org" };
const clients = vi.mocked(createClient) as unknown as Mock<
(opts: ICreateClientOpts) => FakeClient
>;
const indexedDBStores = vi.mocked(IndexedDBStore);
const memoryStores = vi.mocked(MemoryStore);
describe("initClient", () => {
beforeEach(() => {
clients.mockImplementation((opts) => fakeClient(opts));
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("hands back the client once its first sync is done", async () => {
const client = await initClient(options, true);
expect(client).toBe(clients.mock.results[0].value);
expect(client.startClient).toHaveBeenCalledWith({
clientWellKnownPollPeriod: 600,
});
expect(clients).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: "https://hs.example.org",
useAuthorizationHeader: true,
localTimeoutMs: 5000,
}),
);
});
it("keeps the stores when restoring a session", async () => {
const client = (await initClient(options, true)) as unknown as FakeClient;
expect(client.clearStores).not.toHaveBeenCalled();
});
it("clears the stores before starting them when not restoring", async () => {
const client = (await initClient(options, false)) as unknown as FakeClient;
expect(client.clearStores).toHaveBeenCalled();
expect(client.clearStores.mock.invocationCallOrder[0]).toBeLessThan(
client.store.startup.mock.invocationCallOrder[0],
);
});
it("syncs into memory where there is no IndexedDB", async () => {
// jsdom has no indexedDB
const client = await initClient(options, true);
expect(client.store).toBe(memoryStores.mock.instances[0]);
expect(indexedDBStores).not.toHaveBeenCalled();
});
it("syncs into IndexedDB through a worker, where it was bundled", async () => {
vi.stubGlobal("indexedDB", {});
vi.stubEnv("DEV", false);
const client = await initClient(options, true);
expect(client.store).toBe(indexedDBStores.mock.instances[0]);
const [storeOptions] = indexedDBStores.mock.calls[0];
expect(storeOptions.dbName).toBe("element-call-sync");
expect(storeOptions.workerFactory).toBeTypeOf("function");
});
it("syncs into IndexedDB without a worker in development", async () => {
vi.stubGlobal("indexedDB", {});
vi.stubEnv("DEV", true);
await initClient(options, true);
// The dev server does not bundle the worker
expect(indexedDBStores.mock.calls[0][0].workerFactory).toBeUndefined();
});
it("falls back to memory when the IndexedDB store cannot start", async () => {
vi.stubGlobal("indexedDB", {});
const failing = store();
failing.startup.mockRejectedValue(new Error("quota exceeded"));
indexedDBStores.mockImplementationOnce(function (this: IndexedDBStore) {
Object.assign(this, failing);
});
const client = await initClient(options, true);
expect(client.store).toBe(memoryStores.mock.instances[0]);
expect(client.store.startup).toHaveBeenCalled();
});
it("carries on without crypto when it cannot be initialised", async () => {
clients.mockImplementation((opts) => {
const client = fakeClient(opts);
client.initRustCrypto.mockRejectedValue(new Error("stores in use"));
return client;
});
await expect(initClient(options, true)).resolves.toBeDefined();
});
it("waits for a sync from the network, past one restored from the store", async () => {
let client!: FakeClient;
clients.mockImplementation((opts) => {
client = fakeClient(opts, false);
client.store.getSavedSyncToken.mockResolvedValue("saved");
return client;
});
let ready = false;
const result = initClient(options, true).then(() => (ready = true));
await flushPromises();
// The first sync the client reports is the one restored from the store
client.emit(ClientEvent.Sync, "PREPARED", null);
await flushPromises();
expect(ready).toBe(false);
client.emit(ClientEvent.Sync, "PREPARED", null);
await result;
expect(ready).toBe(true);
});
it("fails when the first sync does", async () => {
let client!: FakeClient;
clients.mockImplementation((opts) => (client = fakeClient(opts, false)));
const result = initClient(options, true);
await flushPromises();
client.emit(ClientEvent.Sync, "ERROR", null, {
error: new Error("offline"),
});
await expect(result).rejects.toThrow("offline");
});
});
+165
View File
@@ -0,0 +1,165 @@
/*
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 {
ClientEvent,
createClient,
type ICreateClientOpts,
IndexedDBStore,
type MatrixClient,
MemoryStore,
} from "matrix-js-sdk";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { logger } from "matrix-js-sdk/lib/logger";
// This import pulls in many MBs of SDK code and crypto WASM which should only
// be included in full SPA builds, which is why `initClient` gets its own module.
import IndexedDBWorker from "../IndexedDBWorker?worker";
import { getUrlParams } from "../UrlParams";
import { Config } from "../config/Config";
import { fallbackICEServerAllowed } from "./matrix";
const SYNC_STORE_NAME = "element-call-sync";
/**
* Initialises and returns a new standalone Matrix Client.
* This can only be called safely if no other client is running
* otherwise rust crypto will throw since it is not ready to initialize a new session.
* If another client is running make sure `.logout()` is called before executing this function.
* @param clientOptions Object of options passed through to the client
* @param restore If the rust crypto should be reset before the client initialization or
* if the initialization should try to restore the crypto state from the indexDB.
* @returns The MatrixClient instance
*/
export async function initClient(
clientOptions: ICreateClientOpts,
restore: boolean,
): Promise<MatrixClient> {
let indexedDB: IDBFactory | undefined;
try {
indexedDB = window.indexedDB;
} catch (e) {
logger.warn("Could not get indexDB from window.", e);
}
// options we always pass to the client (stuff that we need in order to work)
const baseOpts = {
fallbackICEServerAllowed: fallbackICEServerAllowed,
isVoipWithNoMediaAllowed:
Config.get().features?.feature_group_calls_without_video_and_audio,
} as ICreateClientOpts;
if (indexedDB && localStorage) {
baseOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage,
dbName: SYNC_STORE_NAME,
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
// in dev mode: it expects them to use native modules. Ours don't, and even then only
// Chrome supports it. (It bundles them fine in production mode.)
workerFactory: import.meta.env.DEV
? undefined
: (): Worker => new IndexedDBWorker(),
});
} else if (localStorage) {
baseOpts.store = new MemoryStore({ localStorage });
}
// XXX: we read from the URL params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const { e2eEnabled } = getUrlParams();
if (!e2eEnabled) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
const client = createClient({
...baseOpts,
...clientOptions,
useAuthorizationHeader: true,
// Use a relatively low timeout for API calls: this is a realtime app
// so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: fallbackICEServerAllowed,
});
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
if (!restore) {
await client.clearStores();
}
// Start client store.
// Note: The `client.store` is used to store things like sync results. It's independent of
// the cryptostore, and uses a separate indexeddb database.
try {
await client.store.startup();
} catch (error) {
logger.error(
"Error starting matrix client indexDB store. Falling back to memory store.",
error,
);
client.store = new MemoryStore({ localStorage });
await client.store.startup();
}
// Also creates and starts any crypto related stores.
try {
await client.initRustCrypto();
} catch (err) {
logger.warn(
err,
"Make sure to clear client stores before initializing the rust crypto.",
);
}
// Once startClient is called, syncs are run asynchronously.
// Also, sync completion is communicated only via events.
// So, apply the event listener *before* starting the client.
// Otherwise, a sync may complete before the listener gets applied,
// and we will miss it.
const syncPromise = waitForSync(client);
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
await syncPromise;
return client;
}
async function waitForSync(client: MatrixClient): Promise<void> {
// If there is a saved sync, the client will fire an additional sync event
// for restoring it before it runs the first network sync.
// However, the sync we want to wait for is the network sync,
// as the saved sync may be missing some state.
// Thus, don't resolve on the first sync when we know it's for the saved sync.
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
return new Promise<void>((resolve, reject) => {
const onSync = (
state: SyncState,
_old: SyncState | null,
data?: ISyncStateData,
): void => {
if (state === "PREPARED") {
if (waitForSavedSync) {
waitForSavedSync = false;
} else {
client.removeListener(ClientEvent.Sync, onSync);
resolve();
}
} else if (state === "ERROR") {
client.removeListener(ClientEvent.Sync, onSync);
reject(data?.error);
}
};
client.on(ClientEvent.Sync, onSync);
});
}
+5 -152
View File
@@ -5,21 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ClientEvent,
createClient,
IndexedDBStore,
MemoryStore,
Preset,
Visibility,
} from "matrix-js-sdk";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
// *Do not* import any client stores in this module, to keep the component
// build lean.
import { ClientEvent, Preset, Visibility } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { secureRandomBase64Url } from "matrix-js-sdk/lib/randomstring";
import type { ICreateClientOpts, MatrixClient, Room } from "matrix-js-sdk";
import IndexedDBWorker from "../IndexedDBWorker?worker";
import { generateUrlSearchParams, getUrlParams } from "../UrlParams";
import type { MatrixClient, Room } from "matrix-js-sdk";
import { generateUrlSearchParams } from "../UrlParams";
import { Config } from "../config/Config";
import { E2eeType } from "../e2ee/e2eeType";
import {
@@ -30,146 +23,6 @@ import {
export const fallbackICEServerAllowed =
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
const SYNC_STORE_NAME = "element-call-sync";
async function waitForSync(client: MatrixClient): Promise<void> {
// If there is a saved sync, the client will fire an additional sync event
// for restoring it before it runs the first network sync.
// However, the sync we want to wait for is the network sync,
// as the saved sync may be missing some state.
// Thus, don't resolve on the first sync when we know it's for the saved sync.
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
return new Promise<void>((resolve, reject) => {
const onSync = (
state: SyncState,
_old: SyncState | null,
data?: ISyncStateData,
): void => {
if (state === "PREPARED") {
if (waitForSavedSync) {
waitForSavedSync = false;
} else {
client.removeListener(ClientEvent.Sync, onSync);
resolve();
}
} else if (state === "ERROR") {
client.removeListener(ClientEvent.Sync, onSync);
reject(data?.error);
}
};
client.on(ClientEvent.Sync, onSync);
});
}
/**
* Initialises and returns a new standalone Matrix Client.
* This can only be called safely if no other client is running
* otherwise rust crypto will throw since it is not ready to initialize a new session.
* If another client is running make sure `.logout()` is called before executing this function.
* @param clientOptions Object of options passed through to the client
* @param restore If the rust crypto should be reset before the client initialization or
* if the initialization should try to restore the crypto state from the indexDB.
* @returns The MatrixClient instance
*/
export async function initClient(
clientOptions: ICreateClientOpts,
restore: boolean,
): Promise<MatrixClient> {
let indexedDB: IDBFactory | undefined;
try {
indexedDB = window.indexedDB;
} catch (e) {
logger.warn("Could not get indexDB from window.", e);
}
// options we always pass to the client (stuff that we need in order to work)
const baseOpts = {
fallbackICEServerAllowed: fallbackICEServerAllowed,
isVoipWithNoMediaAllowed:
Config.get().features?.feature_group_calls_without_video_and_audio,
} as ICreateClientOpts;
if (indexedDB && localStorage) {
baseOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage,
dbName: SYNC_STORE_NAME,
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
// in dev mode: it expects them to use native modules. Ours don't, and even then only
// Chrome supports it. (It bundles them fine in production mode.)
workerFactory: import.meta.env.DEV
? undefined
: (): Worker => new IndexedDBWorker(),
});
} else if (localStorage) {
baseOpts.store = new MemoryStore({ localStorage });
}
// XXX: we read from the URL params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const { e2eEnabled } = getUrlParams();
if (!e2eEnabled) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
const client = createClient({
...baseOpts,
...clientOptions,
useAuthorizationHeader: true,
// Use a relatively low timeout for API calls: this is a realtime app
// so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: fallbackICEServerAllowed,
});
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
if (!restore) {
await client.clearStores();
}
// Start client store.
// Note: The `client.store` is used to store things like sync results. It's independent of
// the cryptostore, and uses a separate indexeddb database.
try {
await client.store.startup();
} catch (error) {
logger.error(
"Error starting matrix client indexDB store. Falling back to memory store.",
error,
);
client.store = new MemoryStore({ localStorage });
await client.store.startup();
}
// Also creates and starts any crypto related stores.
try {
await client.initRustCrypto();
} catch (err) {
logger.warn(
err,
"Make sure to clear client stores before initializing the rust crypto.",
);
}
// Once startClient is called, syncs are run asynchronously.
// Also, sync completion is communicated only via events.
// So, apply the event listener *before* starting the client.
// Otherwise, a sync may complete before the listener gets applied,
// and we will miss it.
const syncPromise = waitForSync(client);
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
await syncPromise;
return client;
}
export function roomAliasLocalpartFromRoomName(roomName: string): string {
return roomName
.trim()
-62
View File
@@ -1,62 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ICreateClientOpts, MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { Config } from "../config/Config";
import { fallbackICEServerAllowed, initClient } from "./matrix";
import type { InitResult, Session } from "../ClientContext";
export async function initSPA(
loadSession: () => Session | undefined,
clearSession: () => void,
): Promise<InitResult | null> {
// We're running as a standalone application
try {
const session = loadSession();
if (!session) {
logger.log("No session stored; continuing without a client");
return null;
}
logger.log("Using a standalone client");
/* eslint-disable camelcase */
const { user_id, device_id, access_token, passwordlessUser } = session;
const initClientParams: ICreateClientOpts = {
baseUrl: Config.defaultHomeserverUrl()!,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
fallbackICEServerAllowed,
livekitServiceURL: Config.get().livekit?.livekit_service_url,
};
try {
const client = await initClient(initClientParams, true);
return {
client,
passwordlessUser,
};
} catch (err) {
if (err instanceof MatrixError && err.errcode === "M_UNKNOWN_TOKEN") {
// We can't use this session anymore, so let's log it out
logger.log(
"The session from local store is invalid; continuing without a client",
);
clearSession();
// returning null = "no client` pls register" (undefined = "loading" which is the current value when reaching this line)
return null;
}
throw err;
}
} catch (err) {
clearSession();
throw err;
}
}
+29
View File
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { defineConfig } from "vite";
import { posix } from "node:path";
import { vitePluginsConfig } from "./vite.config.ts";
import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot.ts";
@@ -34,6 +35,25 @@ export default defineConfig(({ mode }) => {
// whatever is in `public` — including the developer's own config.json, which
// is not in the repository — into the output we would publish.
publicDir: false,
// A library does not know where it will be served from, so every file it
// emits beside itself — the sounds, the pictures, MediaPipe's runtime — is
// referenced relative to the module's own URL. (A library build inlines
// each asset as a data URL unless its import says `no-inline`, so the
// imports do.)
base: "./",
experimental: {
// Relative to the chunk that references it, wherever that chunk lands,
// and spelled `./assets/…` rather than `assets/…`: a host's bundler reads
// a bare name as a package to look up.
renderBuiltUrl: (filename, { hostId, hostType }) => {
if (hostType !== "js") return { relative: true };
const relative = posix.relative(posix.dirname(hostId), filename);
const specifier = relative.startsWith(".") ? relative : `./${relative}`;
return {
runtime: `new URL(${JSON.stringify(specifier)}, import.meta.url).href`,
};
},
},
// A host's document is not ours to style: everything in the stylesheet is
// confined to the element Element Call is mounted in
css: { postcss: { plugins: [scopeStylesToRoot()] } },
@@ -64,6 +84,15 @@ export default defineConfig(({ mode }) => {
cssFileName: "element-call",
},
rollupOptions: {
output: {
// Hashed, as the app's are, so that a file a host serves as it is
// stays correct across versions of the package. Not the stylesheet,
// which the package exports by name (see `cssFileName`).
assetFileNames: ({ names }) =>
names.some((name) => name.endsWith(".css"))
? "[name][extname]"
: "assets/[name]-[hash][extname]",
},
// The host already has these, and a second copy of any of them does not
// merely bloat the bundle: React would hold two sets of hooks, the
// Matrix client would run two sync loops, and a second Compound would