Compare commits

..
Author SHA1 Message Date
fkwpandClaude Opus 5.5 b74e63c1c0 Show the user their camera at the top of the camera menu
- The camera menu leads with the user's own camera, with the effect in
  force, full width at sixteen by nine: a background is judged by how it
  looks rather than by its thumbnail.
- It shows the pipeline's own track again, not a second capture. Each
  provider keeps the camera its pipeline is synced to, before joining and
  after, and hands it out with its state, so two calls on a page keep
  their own. It is reported again only when it changes, which keeps the
  preview from blanking on every change of state.
- It and the pre-join preview share useAttachedTrack, which detaches only
  its own element: the pre-join preview detached every element showing
  its track, the menu's among them.
- Paid for out of the list's share of the call, so a menu with it is no
  taller: at the top where the list keeps its floor after paying, else
  first among the options, scrolling away with them. Its size comes from
  the tokens the stylesheets draw it with, so it is placed once.
- While the first effect is prepared the preview shows the wait; with the
  camera off it says so, and an effect can still be chosen.
- The video is rounded itself as well as clipped by its frame: Firefox
  composites it on a layer of its own, which the frame's rounded clip
  didn't reach at 100% zoom. Confirmed by hand, as headless Firefox draws
  it rounded either way.
- Every camera story shows the preview, with a stand-in camera drawn to a
  stream. End to end on Chromium and Firefox, the lobby's menu shows the
  camera and then the chosen picture.

Rebased onto main, where the call's camera is the MatrixRTC SDK's track
rather than LiveKit's, and the Publisher that synced it is gone:
- The synced camera is a ShownCamera, which LiveKit's preview track and
  the SDK's call track both fill; useAttachedTrack and SelfPreview take
  that.
- CallViewModel exposes camera$, and the call view reports it with
  reportCamera, the same reporting the lobby's sync uses. An end-to-end
  test checks the call's menu shows the call's camera, then the picture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-06 10:58:46 +01:00
128 changed files with 1953 additions and 3788 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ permissions:
jobs:
prevent-blocked:
name: Prevent blocked
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
@@ -17,7 +17,7 @@ env:
jobs:
build_and_deploy:
name: Build & publish docker
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
permissions:
contents: write # required to upload release asset
packages: write # needed for publishing packages to GHCR
+1 -1
View File
@@ -29,7 +29,7 @@ on:
jobs:
build:
name: Build Element Call
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+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-24.04
runs-on: ubuntu-latest
steps:
- name: Deploy to call.element.dev
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
@@ -92,7 +92,7 @@ jobs:
build_storybook:
name: Build Storybook
if: contains(github.event.pull_request.labels.*.name, 'storybook build')
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+1 -1
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
pr-changelog-label:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
with:
+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-24.04
runs-on: ubuntu-latest
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-24.04
runs-on: ubuntu-latest
permissions:
deployments: write
environment: Netlify
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs:
lint:
name: Lint, format & type check
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+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-24.04
runs-on: ubuntu-latest
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-24.04
runs-on: ubuntu-latest
outputs:
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
@@ -59,7 +59,7 @@ jobs:
publish_npm:
needs: versioning
name: Publish NPM
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
outputs:
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
permissions:
@@ -111,7 +111,7 @@ jobs:
needs: [versioning, publish_npm]
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
name: Update release notes
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
# Shared with publish-embedded-packages.yaml, which appends to the same release: two
# appends at once would each write back the body without the other's section
concurrency:
@@ -14,7 +14,7 @@ on:
jobs:
versioning:
name: Versioning
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
outputs:
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
@@ -76,7 +76,7 @@ jobs:
needs: [build_element_call, versioning]
if: always()
name: Publish tarball
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
permissions:
contents: write # required to upload release asset
steps:
@@ -107,7 +107,7 @@ jobs:
needs: [build_element_call, versioning]
if: always()
name: Publish NPM
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
outputs:
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
permissions:
@@ -152,7 +152,7 @@ jobs:
needs: [build_element_call, versioning]
if: always()
name: Publish Android AAR
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
outputs:
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
permissions:
@@ -212,7 +212,7 @@ jobs:
needs: [build_element_call, versioning]
if: always()
name: Publish SwiftPM Library
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
outputs:
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
permissions:
@@ -282,7 +282,7 @@ jobs:
needs: [versioning, publish_npm, publish_android, publish_ios]
if: always()
name: Update release notes
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
# Shared with publish-component.yaml, which appends to the same release: two appends
# at once would each write back the body without the other's section
concurrency:
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
needs: build_element_call
if: always()
name: Publish tarball
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
permissions:
contents: write # required to upload release asset
packages: write
@@ -68,7 +68,7 @@ jobs:
add_docker_release_note:
needs: publish_docker
name: Add docker release note
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Add release note
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
+4 -68
View File
@@ -25,7 +25,7 @@ on:
jobs:
vitest:
name: Run unit tests
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -71,27 +71,10 @@ jobs:
flags: unittests
fail_ci_if_error: true
playwright:
name: Run end-to-end tests (${{ matrix.part }})
name: Run end-to-end tests
if: ${{ !inputs.disable_e2e_tests }}
timeout-minutes: 60
runs-on: ubuntu-24.04
# We manually shard the tests across runners. Playwright's own shards cut
# the list of tests into equal counts in file order, which would leave the
# widget suite, the slowest tests by far, together at the end.
strategy:
fail-fast: false
matrix:
include:
- part: chromium-1
args: --project=chromium --shard=1/2
- part: chromium-2
args: --project=chromium --shard=2/2
- part: widget-1
args: --project=widget --shard=1/2
- part: widget-2
args: --project=widget --shard=2/2
- part: firefox-mobile
args: --project=firefox --project=mobile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -124,64 +107,17 @@ jobs:
run: pnpm playwright install --with-deps
- name: Run backend components
run: |
# Make sure the directory for the embedded build exists as Docker might
# create it with wrong access rights otherwise.
mkdir -p embedded/web/dist
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml pull
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml up -d
docker ps
- name: Run Playwright tests
env:
USE_DOCKER: 1
ARGS: ${{ matrix.args }}
# Unquoted on purpose: the part's arguments are several words
run: pnpm exec playwright test $ARGS
run: pnpm exec playwright test
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
name: blob-report-${{ matrix.part }}
path: blob-report
if-no-files-found: error
# Only read by the merge below.
retention-days: 1
e2e_report:
# Combines the blobs into a single HTML report and blocks PRs on failure.
name: Run end-to-end tests
needs: playwright
if: ${{ always() && !inputs.disable_e2e_tests }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: scripts/layered.sh --ignore-pnpmfile
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Download the parts' reports
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: blob-report-*
path: all-blob-reports
merge-multiple: true
- name: Merge into one HTML report
run: pnpm exec playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: html-report
path: playwright-report
if-no-files-found: error
retention-days: 4
- name: Fail on a failed part
if: ${{ needs.playwright.result != 'success' }}
run: exit 1
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
download:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
permissions:
contents: read
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs:
upload:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
permissions:
contents: read
+1 -1
View File
@@ -10,7 +10,7 @@ permissions: {}
jobs:
zizmor:
name: Run zizmor 🌈
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
+99 -48
View File
@@ -221,68 +221,119 @@ See also:
- [Developing with linked packages](./docs/linking.md)
#### Element Call as a component
#### Element Call as a component (experimental)
Element Call can be rendered inside another React app instead of an iframe.
The host supplies its own Matrix client, and the call stays inside the element
it is mounted in.
Element Call can also be embedded directly into another React application
rather than being loaded in an iframe as a widget. `pnpm build:component`
builds it as a library into `component/dist` (the bundle, its stylesheet and
type declarations), and
##### Install
```sh
pnpm dev:component
```
serves a harness on port 3001 that stands in for such an application: it signs
in twice against the development backend and shows two calls side by side, in
resizable boxes, with page furniture of its own around them. Use it to see how
Element Call behaves when it does not own the page — the size it is given,
whether it stays inside its container, and what it says to its host, which is
logged along the bottom. The harness is served with the same development
certificate as the app, so unless the development CA is trusted, the browser
needs a certificate exception for `https://localhost:3001` as well (see the
note under [Backend](#backend)). It reads the same `public/config.json` as
`pnpm dev` if one exists, and runs with Element Call's defaults otherwise.
The call lays itself out for the size of the element it is mounted in, not the
window: a host that shrinks the container to a corner of its page gets the
picture-in-picture layout, just as a host that shrank the whole iframe used to.
The breakpoints in the stylesheets the component uses are
`@container element-call` queries against its root element for the same reason;
for the standalone app the root is the page, so they mean what the media queries
they replaced did. (The standalone-only views, such as the home and login pages,
still use plain media queries, since the component never shows them.)
The component's stylesheet is confined to the element it is mounted in: the
build rewrites every selector so that it matches only Element Call's root or
what is inside it, with `html`, `body` and `:root` standing for that root (see
`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles,
and Element Call brings its own fonts and design tokens along.
The component speaks every language the app does. English is bundled in; the
other locales are split into chunks the host's bundler loads the first time
they are needed. It starts in the browser's language, and follows the host's
own language setting through the `language` prop (`supportedLanguages` lists
the tags it accepts, and anything else falls back to its base language or to
English). The `theme` prop works the same way and takes the same values as the
widget's `theme` URL parameter: `light`, `dark`, `light-high-contrast` or
`dark-high-contrast`. Both can change while a call is running without
disturbing it.
A host must call and await `initializeElementCall(config)` once before
rendering the component: it loads the `Intl` polyfills, applies the
deployment-wide `config.json`-style configuration and sets up translations.
The component itself takes the host's `client` and the `roomId` to call in, an
`intent` saying what the user asked for (which decides whether to show the
lobby, ring, and so on), an optional `config` overriding what the intent
implies, and an optional `hostBridge` through which Element Call tells the host
that the user has joined or hung up, that it wants to stay on screen, and so
on. The host makes its own requests (`join`, `hangUp`, `setDeviceMute`) through
the handle exposed on `ref`. The full API is documented in the type declarations
(`component/index.tsx` and `component/host.ts`).
A few things differ from the widget on purpose: the component draws a solid
background rather than a gradient unless told otherwise, never offers to edit
the user's profile (the account is the host's), scopes its keyboard shortcuts to
its own root element so that several instances can share a page, and only shows
its own post-call and error screens when the host has not supplied a `close()`
callback; with one, it asks the host to unmount it instead. The
[global JS controls](./docs/controls.md) on `window` are unchanged and remain
page-wide, so with several instances on one page they apply to all of them.
Each release publishes the package to npm, with the same version as the release:
```sh
npm install @element-hq/element-call-component
```
Releases publish the package with the same version as Element Call. For an
unreleased commit, depend on this repository directly:
To use an unreleased commit, a host can instead install it as a git dependency
on the `component` directory of this repository,
```json
"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component"
```
This builds on install, so the host needs pnpm (via Corepack) and must allow
the build (`allowBuilds` in its `pnpm-workspace.yaml`).
whose `prepare` script runs the build on install. That build needs pnpm (via
Corepack) on the host's machine, runs a full `pnpm install` of this repository,
since it inherits the `--max-old-space-size` setting of
the app build; the host's pnpm also has to allow it to run at all
(`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm
project of its own for this reason, so pnpm commands run from inside that
directory target it rather than the repository; run them from the repository
root. The host imports the component from
`@element-hq/element-call-component` and the stylesheet from
`@element-hq/element-call-component/style.css`, and has to provide `react`,
`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
`@vector-im/compound-design-tokens` itself, since the bundle leaves them
external.
Compound's stylesheet are the host's to load too: the component shares
the host's copy of Compound, (required for example for tooltips)
##### Use
The component is large, and a host will usually load it lazily, only once a
call is shown. Everything a host needs in order to talk about a call before
then is also exported
from `@element-hq/element-call-component/api` (a small entry point that does not
load the component).
1. Provide the externals yourself: `react`, `react-dom`, `matrix-js-sdk`,
`@vector-im/compound-web`, `@vector-im/compound-design-tokens`, and
Compound's stylesheet.
2. Import `@element-hq/element-call-component/style.css`.
3. Await `initializeElementCall(config)` once before rendering.
4. Render the component with `client`, `roomId` and an `intent`; optionally
pass `config`, `hostBridge`, `language` and `theme` (both can change
mid-call). Control it through the `ref` handle (`join`, `hangUp`,
`setDeviceMute`).
Load the component lazily. Types, enums and `configurationForIntent` are
available without it from `@element-hq/element-call-component/api`. The full
API is in `component/index.tsx`, `component/api.ts` and `component/host.ts`.
##### How it differs from the widget
- It lays out for its container, not the window: a small container gets the
picture-in-picture layout.
- Its styles and keyboard shortcuts are scoped to its root, so the host page is
untouched and several instances can share a page. The
[global JS controls](./docs/controls.md) stay page-wide.
- It draws a solid background by default and never offers profile editing.
- If the host bridge implements `close()`, it asks to be unmounted instead of
showing its own post-call and error screens.
- Sounds, images and the MediaPipe runtime are separate files the host's
bundler copies (webpack 5, Vite, Rollup); MediaPipe is fetched only when a
background effect is first used.
##### Develop
- `pnpm build:component` builds the library into `component/dist`.
- `pnpm dev:component` serves a test host on `https://localhost:3001`: two
calls side by side in resizable boxes, with host messages logged at the
bottom. It uses the development backend, `public/config.json` if present,
and needs the same certificate exception as `pnpm dev` (see
[Backend](#backend)).
- Run pnpm commands from the repository root; `component/` is its own pnpm
project.
- props and the types they are made of
- the handle
- the host bridge
- `UserIntent`
- `HeaderStyle`
- `BackgroundStyle` enums
- `configurationForIntent`, the defaults each intent implies
Import from it wherever a value such as
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
entry point re-exports all of it too.
### Backend
+3 -5
View File
@@ -26,7 +26,8 @@
"features": {
"feature_element_call_video_rooms": true,
"feature_video_rooms": true,
"feature_group_calls": true
"feature_group_calls": true,
"feature_release_announcement": false
},
"default_federate": true,
"default_theme": "light",
@@ -39,10 +40,7 @@
},
"setting_defaults": {
"breadcrumbs": true,
"feature_group_calls": true,
"releaseAnnouncementData": {
"room_list_section": true
}
"feature_group_calls": true
},
"jitsi": {
"preferred_domain": "meet.element.io"
+3 -5
View File
@@ -26,7 +26,8 @@
"features": {
"feature_element_call_video_rooms": true,
"feature_video_rooms": true,
"feature_group_calls": true
"feature_group_calls": true,
"feature_release_announcement": false
},
"default_federate": true,
"default_theme": "light",
@@ -39,10 +40,7 @@
},
"setting_defaults": {
"breadcrumbs": true,
"feature_group_calls": true,
"releaseAnnouncementData": {
"room_list_section": true
}
"feature_group_calls": true
},
"jitsi": {
"preferred_domain": "meet.element.io"
+7 -13
View File
@@ -73,7 +73,6 @@ import {
} from "../src/UrlParams";
import { MediaDevicesContext } from "../src/MediaDevicesContext";
import { AppViewModel } from "../src/state/AppViewModel";
import { hostClientSession } from "../src/state/ClientSession";
import { ObservableScope } from "../src/state/ObservableScope";
import { BackgroundEffectsProvider } from "../src/tracks/BackgroundEffectsContext";
import { Config } from "../src/config/Config";
@@ -190,25 +189,20 @@ export const ElementCall: FC<ElementCallProps> = ({
[roomId, intent, stableConfig],
);
// Created in an effect so that the scope it lives in ends when the component
// is unmounted (or these options change), rather than keeping its device
// observers running for the rest of the page's life. Null until then, which
// is one render.
const { controlledAudioDevices, callIntent } = params;
const [vm, setVm] = useState<AppViewModel | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setVm(
new AppViewModel(
scope,
{ controlledAudioDevices, callIntent },
// The host owns the client. We create a clientSession wrapping the host client.
hostClientSession(client),
// The host owns the session too, so there is nothing to manage here
null,
),
);
setVm(new AppViewModel(scope, { controlledAudioDevices, callIntent }));
return (): void => {
setVm(null);
scope.end();
};
}, [controlledAudioDevices, callIntent, client]);
}, [controlledAudioDevices, callIntent]);
const room = client.getRoom(roomId);
const rtcSession = useMemo(
@@ -256,7 +250,7 @@ export const ElementCall: FC<ElementCallProps> = ({
>
<Decoration>
<TooltipProvider>
<ClientProvider session={vm.clientSession}>
<ClientProvider client={client}>
<MediaDevicesContext value={vm.mediaDevices}>
<BackgroundEffectsProvider
effects={vm.backgroundEffects}
+2 -2
View File
@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
pull_policy: always
hostname: auth-server
environment:
@@ -33,7 +33,7 @@ services:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
pull_policy: always
hostname: auth-server-1
environment:
+2 -7
View File
@@ -19,12 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
image: ghcr.io/element-hq/element-web:develop@sha256:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
volumes:
# The other site opens the embedded package under test in place of the
# copy its image bundles (the first site opens the dev server's app.
# Built by the Playwright run (see build.setup.ts).
- ./embedded/web/dist:/app/widgets/element-call:ro,Z
image: ghcr.io/element-hq/element-web:develop@sha256:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
-6
View File
@@ -34,10 +34,6 @@ translations. The browser is Playwright's, so a fresh clone needs
## End-to-end
The Element Web instance `HOST1` opens the app the dev server serves. The instance
`HOST2` (the other site) opens the embedded package as a host ships it. This package
is built by the `build-embedded` setup project and mounted in `docker-compose-playwright.yml`.
- `playwright/*.spec.ts` — standalone, on Chromium and Firefox.
- `playwright/widget/` — widget mode, where cross-context bugs surface.
- `playwright/component/` — the component in a host page, via the harness on port
@@ -52,8 +48,6 @@ Test what a user observes: the peer sees the change, it survives a reconnect, it
right after a reload. Reuse `playwright/spa-helpers.ts`,
`playwright/widget/test-helpers.ts`, `playwright/fixtures/`.
To mark new release announcements as viewed, add them in `backend/ew.test*.config.json`.
```sh
pnpm test # unit + storybook
pnpm backend # Synapse + LiveKit, required for e2e
+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#dc8610e796c92553c658300a4882dde961a0678a",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
"matrix-widget-api": "^1.18.0",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
+21 -33
View File
@@ -26,19 +26,6 @@ process.env.NODE_EXTRA_CA_CERTS = join(
"backend/dev_tls_local-ca.crt",
);
const desktopChrome = {
...devices["Desktop Chrome"],
permissions: ["clipboard-write", "clipboard-read", "microphone", "camera"],
ignoreHTTPSErrors: true,
launchOptions: {
args: [
"--use-fake-ui-for-media-stream",
"--use-fake-device-for-media-stream",
"--mute-audio",
],
},
};
/**
* See https://playwright.dev/docs/test-configuration.
*/
@@ -56,11 +43,11 @@ export default defineConfig({
* Reporter to use. See https://playwright.dev/docs/test-reporters
*
* On CI, `list` prints a line per test as it finishes, which keeps the job
* log moving; `github` annotates failures on the PR; the `blob` format is
* uploaded by sharded CI runs and combined into a HTML report by the workflow.
* log moving; `github` annotates failures on the PR; `html` is uploaded as
* an artifact and must never try to open a browser there.
*/
reporter: process.env.CI
? [["list"], ["github"], ["blob"]]
? [["list"], ["github"], ["html", { open: "never" }]]
: [["list"], ["html"]],
/* Stop a broken run early on CI instead of waiting out every retry. */
maxFailures: process.env.CI ? 5 : undefined,
@@ -76,28 +63,29 @@ export default defineConfig({
},
/* Configure projects for major browsers */
projects: [
{
// Builds the embedded package into the directory the other site's
// Element Web container serves it from (see docker-compose-playwright.yml).
name: "build-embedded",
testMatch: "**/widget/embedded/build.setup.ts",
},
{
name: "chromium",
testIgnore: ["**/mobile/**", "**/widget/**"],
use: desktopChrome,
},
{
// Element Call as a widget in Element Web, on Chromium. This uses its
// own project so that it can be sharded on CI.
name: "widget",
dependencies: ["build-embedded"],
testMatch: "**/widget/**/*.@(spec|test).ts",
use: desktopChrome,
testIgnore: "**/mobile/**",
use: {
...devices["Desktop Chrome"],
permissions: [
"clipboard-write",
"clipboard-read",
"microphone",
"camera",
],
ignoreHTTPSErrors: true,
launchOptions: {
args: [
"--use-fake-ui-for-media-stream",
"--use-fake-device-for-media-stream",
"--mute-audio",
],
},
},
},
{
name: "firefox",
dependencies: ["build-embedded"],
testIgnore: "**/mobile/**",
use: {
...devices["Desktop Firefox"],
-40
View File
@@ -211,46 +211,6 @@ test.describe("background effects section", () => {
});
});
test.describe("a pipeline that fails to build", () => {
test("offers no effects for the session, and keeps the choice", async ({
page,
}) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Failing user", "Pipeline fails");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
await page.route("**/*.tflite*", async (route) => route.abort());
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
await expect(
page.getByText("Background effects are not supported on this platform."),
).toBeVisible({ timeout: 30_000 });
await expect(tile).toBeDisabled();
await page.keyboard.press("Escape");
// The camera and microphone carry on without it.
expect(distance(await cameraColour(preview), camera)).toBeLessThan(30);
await expect(page.getByTestId("incall_mute")).toHaveAttribute(
"aria-checked",
"true",
);
await page.unroute("**/*.tflite*");
await page.reload();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
await expect(tile).toHaveAttribute("aria-checked", "true");
const picture = await averageColour(tile.locator("img"));
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
});
});
test.describe("the chosen effect", () => {
test("chosen effect survives leaving and rejoining", async ({ page }) => {
test.setTimeout(120_000);
+40 -4
View File
@@ -29,10 +29,47 @@ export interface MyFixtures {
addUser: (username: string, host: string) => Promise<UserBaseFixture>;
}
// Minimal config.json for the local element-web instance
const CONFIG_JSON = {
default_server_config: {
"m.homeserver": {
base_url: "https://synapse.m.localhost",
server_name: "synapse.m.localhost",
},
},
element_call: {
participant_limit: 8,
brand: "Element Call",
},
// The default language is set here for test consistency
setting_defaults: {
language: "en-GB",
feature_group_calls: true,
},
// the location tests want a map style url.
map_style_url:
"https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx",
features: {
// We don't want to go through the feature announcement during the e2e test
feature_release_announcement: false,
feature_element_call_video_rooms: true,
feature_video_rooms: true,
feature_group_calls: true,
},
};
export const widgetTest = test.extend<MyFixtures>({
// allow per-test override: `widgetTest.use({ callType: "dm" })`
callType: ["room", { option: true }],
asWidget: async ({ browser, callType }, pUse) => {
asWidget: async ({ browser, context, callType }, pUse) => {
await context.route(`http://localhost:8081/config.json*`, async (route) => {
await route.fulfill({ json: CONFIG_JSON });
});
const brooksDisplayName = `brooks_${Date.now()}`;
const whistlerDisplayName = `whistler_${Date.now()}`;
@@ -127,14 +164,13 @@ export const widgetTest = test.extend<MyFixtures>({
* Provide a way to add additional users within a test.
* The returned user will be registered on the default homeserver, the name will be made unique by appending a timestamp.
*/
addUser: async ({ browser }, use, testInfo) => {
addUser: async ({ browser }, use) => {
await use(
async (
username: string,
host: string = HOST1,
): Promise<UserBaseFixture> => {
// Tests in parallel workers can reach this in the same millisecond.
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
const uniqueSuffix = Date.now();
const { page, clientHandle, mxId } = await TestHelpers.registerUser(
browser,
`${username.toLowerCase()}_${uniqueSuffix}`,
-20
View File
@@ -1,20 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test as setup } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
const EMBEDDED_DIST = "embedded/web/dist";
setup("build the embedded package", () => {
setup.setTimeout(10 * 60 * 1000);
mkdirSync(EMBEDDED_DIST, { recursive: true });
execFileSync("pnpm", ["build:embedded", "--outDir", EMBEDDED_DIST], {
stdio: "inherit",
});
});
@@ -1,56 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Route } from "@playwright/test";
import { widgetTest } from "../../fixtures/widget-user.ts";
import { HOST2, TestHelpers } from "../test-helpers.ts";
/** Where the other site's Element Web serves the embedded package from */
const CONFIG_JSON = "**/widgets/element-call/config.json";
const HOST_ANSWERS: Record<string, (route: Route) => Promise<void>> = {
"a 404": async (route) => route.fulfill({ status: 404 }),
"a failed fetch": async (route) => route.abort("failed"),
"an HTML page": async (route) =>
route.fulfill({
status: 200,
contentType: "text/html",
body: "<!doctype html><title>Element</title>",
}),
};
widgetTest.skip(
({ browserName }) => browserName === "firefox",
"The widget suite does not run on Firefox, see the other specs here",
);
for (const [answer, respond] of Object.entries(HOST_ANSWERS)) {
widgetTest(
`reaches the lobby when config.json gets ${answer}`,
async ({ addUser }) => {
const { page } = await addUser("Brooks", HOST2);
await TestHelpers.createRoom("Config Room", page);
let answered = 0;
await page.route(CONFIG_JSON, async (route) => {
answered++;
await respond(route);
});
await TestHelpers.startCallInCurrentRoom(page);
await expect(
page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall"),
).toBeVisible({ timeout: 15000 });
expect(answered).toBeGreaterThan(0);
},
);
}
+35 -15
View File
@@ -144,11 +144,7 @@ export class TestHelpers {
await this.dismissStartupToasts(page);
// The other site's Element Web opens the embedded package it serves itself
// (see docker-compose-playwright.yml), not the dev server's app.
if (host !== HOST2) {
await TestHelpers.setDevToolElementCallDevUrl(page);
}
await TestHelpers.setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
@@ -157,10 +153,9 @@ export class TestHelpers {
return { page, clientHandle, mxId };
}
// Dismisses the toasts a fresh device gets after sign-in.
//
// Toasts stack and only the top one can be dismissed, so look
// at what is on top each time.
// Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
// dismiss (if part of expected toats)
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts: {
title: string;
@@ -168,7 +163,6 @@ export class TestHelpers {
confirm?: string;
}[] = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Verify this device", button: "Later" },
{ title: "Back up your chats", button: "Not now" },
{
title: "Turn on key storage",
@@ -219,6 +213,30 @@ export class TestHelpers {
}
}
/**
* Clears what Element Web stacks over the room list after sign-in. The
* release announcement is held back while any toast is up, so it is closed
* after the toasts, not before.
*/
public static async clearRoomListOverlays(page: Page): Promise<void> {
await TestHelpers.dismissStartupToasts(page);
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
}
public static async closeReleaseAnnouncement(
page: Page,
name: string,
): Promise<void> {
try {
await page
.getByRole("dialog", { name })
.getByRole("button", { name: "OK" })
.click({ timeout: 2000 });
} catch {
// Announcement not shown; nothing to do
}
}
public static async expandAllSections(page: Page): Promise<void> {
try {
await page
@@ -264,14 +282,15 @@ export class TestHelpers {
/**
* Opens the room list's "New" menu and picks the given item.
*
* Driven by keyboard, as a toast arriving late sits over the button and
* swallows clicks.
* While the sections announcement is anchored to the "New" button, the
* button has no menu at all, so the overlays go first. Driven by keyboard,
* as a toast arriving afterwards sits over the button and swallows clicks.
*/
public static async chooseFromRoomListNewMenu(
page: Page,
itemName: string,
): Promise<void> {
await TestHelpers.dismissStartupToasts(page);
await TestHelpers.clearRoomListOverlays(page);
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New", exact: true })
@@ -408,8 +427,9 @@ export class TestHelpers {
name: "Toggle Invites section",
});
await expect(invitesSection).toBeVisible({ timeout: 10000 });
// Toasts sit over the top of the room list, where this header is.
await TestHelpers.dismissStartupToasts(page);
// Toasts and the announcement sit over the top of the room list, where
// this header is
await TestHelpers.clearRoomListOverlays(page);
if ((await invitesSection.getAttribute("aria-expanded")) !== "true") {
await invitesSection.click();
}
+26 -39
View File
@@ -8,7 +8,7 @@ overrides:
'@livekit/components-core>rxjs': ^7.8.1
'@livekit/track-processors>@mediapipe/tasks-vision': ^0.10.18
tar: ^7.5.11
minimatch: ^11.0.0
minimatch: ^10.2.3
glob: ^10.5.0
qs: ^6.15.2
js-yaml: ^4.2.0
@@ -191,8 +191,8 @@ importers:
specifier: ^1.9.1
version: 1.9.2
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
matrix-widget-api:
specifier: ^1.18.0
version: 1.20.0
@@ -1145,9 +1145,6 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@isaacs/nonrecursive-glob-match@1.0.3':
resolution: {integrity: sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==}
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0':
resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==}
peerDependencies:
@@ -3533,8 +3530,8 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
braces@3.0.3:
@@ -4734,8 +4731,8 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
resolution: {gitHosted: true, integrity: sha512-Ldt/VkCzBtre6m1cn0FqgrzRV0GrgWIoARgQUQO58ef2Edu2ERFCINPzp/psFS5RwoN3RIy9Rm4oPVc1ATkEWA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
version: 45.0.0
engines: {node: '>=22.0.0'}
@@ -4771,9 +4768,9 @@ packages:
minimalistic-crypto-utils@1.0.1:
resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==}
minimatch@11.0.4:
resolution: {integrity: sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==}
engines: {node: 20 || >=22}
minimatch@10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -4946,8 +4943,8 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-retry@9.0.0:
resolution: {integrity: sha512-HuEats8gYn/UUYa5uyveyQGv75mQmwdFtuMgnZlS12dj/7bdnJSO6keaTjgcZUhvkANTj5EO3lACUmV3xmgj0Q==}
p-retry@8.0.1:
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
engines: {node: '>=22'}
p-try@2.2.0:
@@ -5318,10 +5315,6 @@ packages:
randomfill@1.0.4:
resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==}
re2js@2.9.1:
resolution: {integrity: sha512-JIT3bEEUZTOR2YS1walNEIfH4cF/cQeZcBUFq7ldqO6lR6g0X5CBq/S8rsa9omzZcsNf8qc36jCkWpKKtncrZg==}
engines: {node: '>=20.0.0'}
react-docgen-typescript@2.4.0:
resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
peerDependencies:
@@ -6909,7 +6902,7 @@ snapshots:
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.3.1
minimatch: 11.0.4
minimatch: 10.2.6
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -6988,7 +6981,7 @@ snapshots:
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3(supports-color@7.2.0)
minimatch: 11.0.4
minimatch: 10.2.6
transitivePeerDependencies:
- supports-color
@@ -7124,8 +7117,6 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@isaacs/nonrecursive-glob-match@1.0.3': {}
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
dependencies:
glob: 10.5.0
@@ -8785,7 +8776,7 @@ snapshots:
'@typescript-eslint/types': 8.69.0
'@typescript-eslint/visitor-keys': 8.69.0
debug: 4.4.3(supports-color@7.2.0)
minimatch: 11.0.4
minimatch: 10.2.6
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -8800,7 +8791,7 @@ snapshots:
'@typescript-eslint/types': 8.72.0
'@typescript-eslint/visitor-keys': 8.72.0
debug: 4.4.3(supports-color@7.2.0)
minimatch: 11.0.4
minimatch: 10.2.6
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -9125,7 +9116,7 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@5.0.12:
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -9670,7 +9661,7 @@ snapshots:
json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1
lodash.merge: 4.6.2
minimatch: 11.0.4
minimatch: 10.2.6
natural-compare: 1.4.0
optionator: 0.9.4
strip-ansi: 6.0.1
@@ -9790,7 +9781,7 @@ snapshots:
filelist@1.0.6:
dependencies:
minimatch: 11.0.4
minimatch: 10.2.6
fill-range@7.1.1:
dependencies:
@@ -9890,7 +9881,7 @@ snapshots:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 11.0.4
minimatch: 10.2.6
minipass: 7.1.3
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
@@ -10015,7 +10006,7 @@ snapshots:
jiti: 2.7.0
jsonc-parser: 3.3.1
magic-string: 1.2.3
minimatch: 11.0.4
minimatch: 10.2.6
ora: 9.4.1
react: 19.2.8
react-i18next: 17.0.13(i18next@26.4.1(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
@@ -10413,7 +10404,7 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
dependencies:
'@babel/runtime': 8.0.8
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
@@ -10423,8 +10414,7 @@ snapshots:
loglevel: 1.9.2
matrix-events-sdk: 0.0.1
matrix-widget-api: 1.20.0
p-retry: 9.0.0
re2js: 2.9.1
p-retry: 8.0.1
sdp-transform: 3.0.0
unhomoglyph: 1.0.6
@@ -10459,10 +10449,9 @@ snapshots:
minimalistic-crypto-utils@1.0.1: {}
minimatch@11.0.4:
minimatch@10.2.6:
dependencies:
'@isaacs/nonrecursive-glob-match': 1.0.3
brace-expansion: 5.0.12
brace-expansion: 5.0.9
minimist@1.2.8: {}
@@ -10746,7 +10735,7 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-retry@9.0.0:
p-retry@8.0.1:
dependencies:
is-network-error: 1.3.2
@@ -11170,8 +11159,6 @@ snapshots:
randombytes: 2.1.0
safe-buffer: 5.2.1
re2js@2.9.1: {}
react-docgen-typescript@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
+1 -1
View File
@@ -27,7 +27,7 @@ overrides:
# Security fixes:
# - https://github.com/advisories/GHSA-7r86-cg39-jmmj
# - https://github.com/advisories/GHSA-23c5-xmqv-rm74
"minimatch": "^11.0.0"
"minimatch": "^10.2.3"
# Security fix: https://github.com/element-hq/element-call/security/dependabot/109
"glob": "^10.5.0"
# Security fixes:
-1
View File
@@ -162,7 +162,6 @@ describe("createLocalMedia", () => {
media.release("camera");
expect(trackBehind(camera).stop).toHaveBeenCalled();
expect(media.tracks$.value).toEqual([microphone]);
expect(media.requests.map((r) => r.source)).toEqual(["microphone"]);
media.stop();
expect(trackBehind(microphone).stop).toHaveBeenCalled();
-1
View File
@@ -107,7 +107,6 @@ export function createLocalMedia(
if (source !== "microphone" && source !== "camera") return;
capturedTracks.get(source)?.stop();
capturedTracks.delete(source);
requests.delete(source);
publishCapturedTracks();
};
-1
View File
@@ -184,7 +184,6 @@ export function createRTCParticipation(
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
matrixRTCMode,
sendNotificationType: options.sendNotificationType,
callIntent: options.callIntent,
applicationData: options.applicationData,
timings,
delayedLeave,
+4 -8
View File
@@ -12,7 +12,6 @@ Please see LICENSE in the repository root for full details.
*/
import {
type RTCCallIntent,
type RTCNotificationType,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -104,14 +103,11 @@ export interface RTCParticipationOptions {
* to a notification (ringing, timeouts, declines) is the application's job.
*/
sendNotificationType?: RTCNotificationType;
/**
* The kind of call the local member intends to take part in: `"m.audio"`
* or `"m.video"`. Carried by the membership as a field of its own.
*/
callIntent?: RTCCallIntent;
/**
* Whatever the application wants to say about itself in the membership,
* under namespaced keys. Opaque to the SDK.
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
* (`"audio"` or `"video"`), which the membership format carries as a
* field of its own.
*/
applicationData?: Record<string, unknown>;
/** Timings the host has configured; the defaults otherwise. */
@@ -291,7 +287,7 @@ export interface RTCMember {
memberId: string;
/** Which transport this member is on; undefined when the membership has none. */
transport$: Behavior<TransportMetadata | undefined>;
/** What the member's application says about itself, e.g. `intent`. */
/** What the member's application says about itself, e.g. `m.call.intent`. */
applicationData$: Behavior<Record<string, unknown>>;
displayName$: Behavior<string>;
+6 -8
View File
@@ -60,7 +60,7 @@ describe("joinJsSdkSession", () => {
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transportWithAlias], {
notificationType: "ring",
callIntent: undefined,
applicationData: undefined,
applicationData: {},
manageMediaKeys: true,
delayedLeaveEventRestartMs: 300_000,
delayedLeaveEventDelayMs: 3_600_000,
@@ -95,14 +95,13 @@ describe("joinJsSdkSession", () => {
);
});
it("passes the call intent and application data through and sends sticky events in Matrix 2.0 mode", () => {
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
const s = session();
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
encryptMedia: true,
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
callIntent: "m.audio",
applicationData: { custom: 1 },
applicationData: { "m.call.intent": "audio", custom: 1 },
timings: defaultSessionTimings,
delayedLeave: defaultSessionTimings.delayedLeave,
});
@@ -111,7 +110,7 @@ describe("joinJsSdkSession", () => {
ownMemberMock,
[transportWithAlias],
expect.objectContaining({
callIntent: "m.audio",
callIntent: "audio",
applicationData: { custom: 1 },
unstableSendStickyEvents: true,
}),
@@ -125,8 +124,7 @@ describe("joinJsSdkSession", () => {
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
encryptMedia: true,
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
callIntent: "m.audio",
applicationData: { custom: 1 },
applicationData: { "m.call.intent": "audio", custom: 1 },
timings: defaultSessionTimings,
delayedLeave: defaultSessionTimings.delayedLeave,
});
@@ -135,7 +133,7 @@ describe("joinJsSdkSession", () => {
ownMemberMock,
[transport],
expect.objectContaining({
callIntent: "m.audio",
callIntent: "audio",
applicationData: { custom: 1 },
unstableSendStickyEvents: true,
}),
+5 -3
View File
@@ -23,7 +23,6 @@ interface Options {
encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode;
sendNotificationType?: RTCNotificationType;
callIntent?: RTCCallIntent;
applicationData?: Record<string, unknown>;
timings: SessionTimings;
/** The delayed leave timings to use: delegated to the SFU or not. */
@@ -44,12 +43,15 @@ export function joinJsSdkSession(
encryptMedia,
matrixRTCMode,
sendNotificationType,
callIntent,
applicationData,
timings,
delayedLeave,
}: Options,
): void {
// The membership format carries the intent as a field of its own
const { "m.call.intent": intent, ...rest } = applicationData ?? {};
const callIntent =
typeof intent === "string" ? (intent as RTCCallIntent) : undefined;
// Give up on the network as soon as either the sync has been down for the
// grace period or the delayed leave has probably been sent, whichever is
// sooner
@@ -76,7 +78,7 @@ export function joinJsSdkSession(
{
notificationType: sendNotificationType,
callIntent,
applicationData,
applicationData: rest,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
@@ -225,51 +225,6 @@ describe("createLivekitMediaTrack", () => {
);
});
// LiveKit's media stream track is the processed one while a processor runs,
// and that one names no device; the camera behind it does
it.each([
{ deviceId: "cam-1", switches: false },
{ deviceId: "cam-2", switches: true },
])(
"leaves a processed camera alone unless its device changes: $deviceId",
async ({ deviceId, switches }) => {
vi.stubGlobal("MediaStreamTrack", class {});
onTestFinished(() => {
vi.unstubAllGlobals();
});
const camera = Object.assign(Object.create(LocalVideoTrack.prototype), {
source: Track.Source.Camera,
_mediaStreamTrack: {
readyState: "live",
label: "",
getSettings: () => ({ deviceId: "cam-1" }),
},
// What LiveKit shows as the track, with no device of its own
processor: {
processedTrack: { label: "", getSettings: () => ({}) },
},
}) as LocalVideoTrack;
const switchActiveDevice = vi.fn().mockResolvedValue(true);
const { participant, publication, room } = fakes({
track: camera as unknown as FakeTrack,
});
const track = createLocalLivekitMediaTrack(
scope,
Object.assign(participant, {
isLocal: true,
}) as unknown as LocalParticipant,
publication as unknown as TrackPublication,
Object.assign(room, { switchActiveDevice }),
vi.fn(),
);
await track.setDevice(deviceId);
expect(switchActiveDevice.mock.calls).toEqual(
switches ? [["videoinput", deviceId, false]] : [],
);
},
);
// LiveKit stops a camera that is turned off; the processor is built when it
// is turned on again
it.each([
@@ -199,13 +199,11 @@ export function createLocalLivekitMediaTrack(
throw new MatrixRTCError(`A ${base.source} track has no device`);
// LiveKit decides whether to restart by the constraints it keeps, which
// a browser that does not echo the device id back (Firefox) leaves
// without one; a track already on the device is left alone here. Asked
// of the camera track itself: with a processor on, LiveKit's media
// stream track is the processed one, which names no device
// without one; a track already on the device is left alone here
const current = publication.track;
if (
current instanceof LocalTrack &&
(await current.getDeviceId(false)) === deviceId
current.mediaStreamTrack.getSettings().deviceId === deviceId
)
return;
await room.switchActiveDevice(
+22 -9
View File
@@ -23,13 +23,14 @@ import {
import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixClient } from "matrix-js-sdk";
import { I18nextProvider } from "react-i18next";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
import { ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { Initializer } from "./initializer";
import { type WidgetHelpers } from "./widget";
@@ -130,9 +131,22 @@ export const App: FC<Props> = ({ vm, widget }) => {
.catch(logger.error);
});
const content = loaded ? (
<ClientProvider session={vm.clientSession}>
<ClientManagerProvider value={vm.clientManager}>
// As a widget, the client comes from the host over the widget API. Standalone,
// Element Call finds one itself, so there is nothing to wait for here.
const [widgetClient, setWidgetClient] = useState<MatrixClient | undefined>(
undefined,
);
useEffect(() => {
if (widget === null) return;
widget.client
.then(setWidgetClient)
.catch((e) => logger.error("Failed to obtain the host's client", e));
}, [widget]);
const clientReady = widget === null || widgetClient !== undefined;
const content =
loaded && clientReady ? (
<ClientProvider client={widgetClient}>
<MediaDevicesContext value={vm.mediaDevices}>
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
<Sentry.ErrorBoundary
@@ -147,11 +161,10 @@ export const App: FC<Props> = ({ vm, widget }) => {
</Sentry.ErrorBoundary>
</BackgroundEffectsProvider>
</MediaDevicesContext>
</ClientManagerProvider>
</ClientProvider>
) : (
<LoadingPage />
);
</ClientProvider>
) : (
<LoadingPage />
);
return (
<I18nextProvider i18n={i18n}>
+16 -2
View File
@@ -12,7 +12,6 @@ import { type FC, type PropsWithChildren } from "react";
import { ClientContextProvider } from "./ClientContext";
import { Avatar } from "./Avatar";
import { hostClientSession } from "./state/ClientSession";
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
import {
type HostBridge,
@@ -27,7 +26,22 @@ const TestComponent: FC<
}>
> = ({ client, hostBridge = nullHostBridge, children }) => {
return (
<ClientContextProvider value={hostClientSession(client)}>
<ClientContextProvider
value={{
state: "valid",
disconnected: false,
supportedFeatures: {
reactions: true,
},
setClient: vi.fn(),
authenticated: {
client,
isPasswordlessUser: true,
changePassword: vi.fn(),
logout: vi.fn(),
},
}}
>
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
</ClientContextProvider>
);
+10 -6
View File
@@ -15,7 +15,7 @@ import {
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { useClient } from "./ClientContext";
import { useClientState } from "./ClientContext";
import { useHostBridge } from "./HostBridge";
export enum Size {
@@ -74,7 +74,7 @@ export const Avatar: FC<Props> = ({
style,
...props
}) => {
const { client } = useClient();
const clientState = useClientState();
const hostBridge = useHostBridge();
const sizePx = useMemo(
@@ -87,7 +87,7 @@ export const Avatar: FC<Props> = ({
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
// In theory, a change in `client` or `sizePx` could run extra media
// In theory, a change in `clientState` or `sizePx` could run extra media
// downloads, but in practice they should be stable long before this code runs.
useEffect(() => {
if (!src) {
@@ -99,8 +99,12 @@ export const Avatar: FC<Props> = ({
if (hostBridge.downloadMedia) {
blob = hostBridge.downloadMedia(src);
} else if (client && sizePx) {
blob = getAvatarFromServer(client, src, sizePx);
} else if (
clientState?.state === "valid" &&
clientState.authenticated?.client &&
sizePx
) {
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
} else {
setAvatarUrl(undefined);
return;
@@ -129,7 +133,7 @@ export const Avatar: FC<Props> = ({
URL.revokeObjectURL(objectUrl);
}
};
}, [client, hostBridge, src, sizePx]);
}, [clientState, hostBridge, src, sizePx]);
return (
<CompoundAvatar
+60 -93
View File
@@ -6,25 +6,12 @@ Please see LICENSE in the repository root for full details.
*/
import { expect, test, vi } from "vitest";
import { act, render, renderHook } from "@testing-library/react";
import { render, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { type FC } from "react";
import { BehaviorSubject } from "rxjs";
import {
ClientManagerProvider,
ClientProvider,
useClientManager,
useClientManagerIfAny,
useClientSessionState,
} from "./ClientContext";
import {
type ClientManager,
type ClientSession,
type ClientSessionState,
hostClientSession,
} from "./state/ClientSession";
import { ClientProvider, useClientState } from "./ClientContext";
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
({
@@ -37,47 +24,67 @@ const mockClient = (userId = "@alice:example.org"): MatrixClient =>
/** Reports what the context says, so a test can assert on it. */
const ShowClientState: FC = () => {
const state = useClientSessionState();
switch (state.state) {
case "signedIn":
return <span>{state.client.getUserId()}</span>;
case "signedOut":
return <span>unauthenticated</span>;
default:
return <span>{state.state}</span>;
}
const state = useClientState();
if (state === undefined) return <span>loading</span>;
if (state.state === "error") return <span>error</span>;
return (
<span>{state.authenticated?.client.getUserId() ?? "unauthenticated"}</span>
);
};
const renderProvider = (
session: ClientSession,
children = <ShowClientState />,
): ReturnType<typeof render> =>
render(
test("uses a client supplied by the host without waiting", () => {
const client = mockClient();
const { container } = render(
<BrowserRouter>
<ClientProvider session={session}>{children}</ClientProvider>
<ClientProvider client={client}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
test("uses a client supplied by the host without waiting", () => {
const { container } = renderProvider(hostClientSession(mockClient()));
// Available on the very first render: a supplied client needs no session
// restoring, so there is no loading state to pass through.
expect(container.textContent).toBe("@alice:example.org");
});
test("does not claim exclusive use of storage when given a client", () => {
// The channel is created when the module loads, so spy on the prototype
// rather than trying to replace the global.
const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage");
render(
<BrowserRouter>
<ClientProvider client={mockClient()}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
// The broadcast shuts down other instances to protect Element Call's own
// stores. A host's client brings its own, so there is nothing to protect.
expect(postMessage).not.toHaveBeenCalled();
postMessage.mockRestore();
});
test("follows the client when the host swaps it", () => {
const { container, rerender } = renderProvider(
hostClientSession(mockClient()),
const first = mockClient();
const second = mockClient("@bob:example.org");
const { container, rerender } = render(
<BrowserRouter>
<ClientProvider client={first}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
expect(container.textContent).toBe("@alice:example.org");
// A host that re-authenticates hands us a new client on a mounted component
rerender(
<BrowserRouter>
<ClientProvider
session={hostClientSession(mockClient("@bob:example.org"))}
>
<ClientProvider client={second}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
@@ -86,63 +93,23 @@ test("follows the client when the host swaps it", () => {
expect(container.textContent).toBe("@bob:example.org");
});
test("follows a session of its own as it is restored", () => {
const state$ = new BehaviorSubject<ClientSessionState>({ state: "loading" });
const { container } = renderProvider({ state$ });
test("finds a client of its own when the host supplies none", async () => {
const client = mockClient();
vi.doMock("./utils/spa", () => ({
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
}));
const { container } = render(
<BrowserRouter>
<ClientProvider>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
// Nothing to show until a session has been restored or created
expect(container.textContent).toBe("loading");
act(() =>
state$.next({
state: "signedIn",
client: mockClient(),
passwordlessUser: true,
}),
);
expect(container.textContent).toBe("@alice:example.org");
});
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
test("has no one signed in when the session says so", () => {
const { container } = renderProvider({
state$: new BehaviorSubject<ClientSessionState>({ state: "signedOut" }),
});
expect(container.textContent).toBe("unauthenticated");
});
test("shows the session's error in place of its children", () => {
const { container } = renderProvider({
state$: new BehaviorSubject<ClientSessionState>({
state: "error",
error: new Error("Opened elsewhere"),
}),
});
// The error page, not the children
expect(container.textContent).not.toContain("error");
expect(container.textContent).toContain("Something went wrong");
});
test("hands the session's manager to views that need one", () => {
const manager: ClientManager = {
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
};
const { result } = renderHook(
() => [useClientManager(), useClientManagerIfAny()],
{
wrapper: ({ children }) => (
<ClientManagerProvider value={manager}>
<>{children}</>
</ClientManagerProvider>
),
},
);
expect(result.current).toEqual([manager, manager]);
});
test("has no manager for a host's session", () => {
// The component and the widget provide none; a view that insists on one is
// in the wrong build
expect(renderHook(() => useClientManagerIfAny()).result.current).toBeNull();
expect(() => renderHook(() => useClientManager())).toThrow();
vi.doUnmock("./utils/spa");
});
+353 -51
View File
@@ -5,18 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useEffect, createContext, use, type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import {
type FC,
useCallback,
useEffect,
useState,
createContext,
use,
useRef,
useMemo,
type JSX,
} from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
import { ErrorPage } from "./FullScreenView";
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
import { useBehavior } from "./useBehavior";
import { constant } from "./state/Behavior";
import { useHostBridge } from "./HostBridge";
import { useLeaveToHome } from "./LeaveToHomeContext";
import {
type ClientManager,
type ClientSession,
type ClientSessionState,
} from "./state/ClientSession";
PosthogAnalytics,
RegistrationType,
} from "./analytics/PosthogAnalytics";
import { useEventTarget } from "./useEvents";
import { OpenElsewhereError } from "./RichError";
declare global {
interface Window {
@@ -25,70 +37,360 @@ declare global {
}
}
// Without a provider, no one is signed in and there is no session to act on
const noClientSession: ClientSession = {
state$: constant({ state: "signedOut" }),
export type ClientState = ValidClientState | ErrorState;
export type ValidClientState = {
state: "valid";
authenticated?: AuthenticatedClient;
// 'Disconnected' rather than 'connected' because it tracks specifically
// whether the client is supposed to be connected but is not
disconnected: boolean;
supportedFeatures: {
reactions: boolean;
};
setClient: (client: MatrixClient, session: Session) => void;
};
const ClientContext = createContext<ClientSession>(noClientSession);
export type AuthenticatedClient = {
client: MatrixClient;
isPasswordlessUser: boolean;
changePassword: (password: string) => Promise<void>;
logout: () => void;
};
export type ErrorState = {
state: "error";
error: Error;
};
const ClientContext = createContext<ClientState | undefined>(undefined);
export const ClientContextProvider = ClientContext.Provider;
export const useClientSessionState = (): ClientSessionState =>
useBehavior(use(ClientContext).state$);
export const useClientState = (): ClientState | undefined => use(ClientContext);
export function useClient(): { client?: MatrixClient } {
const state = useClientSessionState();
return { client: state.state === "signedIn" ? state.client : undefined };
export function useClient(): {
client?: MatrixClient;
setClient?: (client: MatrixClient, session: Session) => void;
} {
let client;
let setClient;
const clientState = useClientState();
if (clientState?.state === "valid") {
client = clientState.authenticated?.client;
setClient = clientState.setClient;
}
return { client, setClient };
}
// Null where the session is a host's: the component, and the widget
const ClientManagerContext = createContext<ClientManager | null>(null);
// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
export function useClientLegacy(): {
client?: MatrixClient;
setClient?: (client: MatrixClient, session: Session) => void;
passwordlessUser: boolean;
loading: boolean;
authenticated: boolean;
logout?: () => void;
error?: Error;
} {
const clientState = useClientState();
export const ClientManagerProvider = ClientManagerContext.Provider;
let client;
let setClient;
let passwordlessUser = false;
let loading = true;
let error;
let authenticated = false;
let logout;
/**
* The manager of a session that is Element Call's own, for views that only
* the standalone app mounts: the login and registration pages. Throws
* elsewhere, as those views have no business being there.
*/
export function useClientManager(): ClientManager {
const manager = use(ClientManagerContext);
if (manager === null)
throw new Error("This view needs a session of Element Call's own");
return manager;
if (clientState?.state === "valid") {
client = clientState.authenticated?.client;
setClient = clientState.setClient;
passwordlessUser = clientState.authenticated?.isPasswordlessUser ?? false;
loading = false;
authenticated = client !== undefined;
logout = clientState.authenticated?.logout;
} else if (clientState?.state === "error") {
error = clientState.error;
loading = false;
}
return {
client,
setClient,
passwordlessUser,
loading,
authenticated,
logout,
error,
};
}
/**
* The session's manager, or null where the session is a host's. For views
* shared with the widget, which can offer to sign out only when that is ours
* to do.
*/
export const useClientManagerIfAny = (): ClientManager | null =>
use(ClientManagerContext);
const loadChannel =
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
interface Props {
children: JSX.Element;
/** a session from Element Call SPA, from a widget or the host (EW) */
session: ClientSession;
/**
* The client Element Call should use.
*
* An application hosting Element Call as a component already has a client,
* and owns the user's session; supplying it here means Element Call neither
* authenticates anyone nor manages their session. Left out, Element Call finds a client
* itself — from the widget API, or by restoring or creating a session of its
* own.
*/
client?: MatrixClient;
}
export const ClientProvider: FC<Props> = ({ children, session }) => {
const state = useBehavior(session.state$);
export const ClientProvider: FC<Props> = ({ children, client }) => {
const leaveToHome = useLeaveToHome();
const hostBridge = useHostBridge();
// null = signed out, undefined = loading
const [initClientState, setInitClientState] = useState<
InitResult | null | undefined
>(
client === undefined
? undefined
: // A supplied client belongs to the host, so there is no session of ours
// to restore and nothing to wait for.
{ client, passwordlessUser: false },
);
const initializing = useRef(false);
useEffect(() => {
if (client !== undefined) {
// Nothing to load, but a host may hand us a different client later — on
// re-authenticating, say — so follow whichever one it has given us.
setInitClientState((current) =>
current?.client === client
? current
: { client, passwordlessUser: false },
);
// Analytics still need to follow the user's choices.
if (PosthogAnalytics.instance.isEnabled())
PosthogAnalytics.instance.startListeningToSettingsChanges();
return;
}
// In case the component is mounted, unmounted, and remounted quickly (as
// React does in strict mode), we need to make sure not to doubly initialize
// the client.
if (initializing.current) return;
initializing.current = true;
loadClient()
.then((initResult) => {
setInitClientState(initResult);
if (PosthogAnalytics.instance.isEnabled())
PosthogAnalytics.instance.startListeningToSettingsChanges();
})
.catch((err) => logger.error(err))
.finally(() => (initializing.current = false));
}, [client]);
const changePassword = useCallback(
async (password: string) => {
const session = loadSession();
if (!initClientState?.client || !session) {
return;
}
await initClientState.client.setPassword(
{
type: "m.login.password",
identifier: {
type: "m.id.user",
user: session.user_id,
},
user: session.user_id,
password: session.tempPassword,
},
password,
);
saveSession({ ...session, passwordlessUser: false });
setInitClientState({
...initClientState,
passwordlessUser: false,
});
},
[initClientState],
);
const setClient = useCallback(
(client: MatrixClient, session: Session) => {
const oldClient = initClientState?.client;
if (oldClient && oldClient !== client) {
oldClient.stopClient();
}
saveSession(session);
setInitClientState({
client,
passwordlessUser: session.passwordlessUser,
});
if (PosthogAnalytics.instance.isEnabled())
PosthogAnalytics.instance.startListeningToSettingsChanges();
},
[initClientState?.client],
);
const logout = useCallback(async () => {
const client = initClientState?.client;
if (!client) {
return;
}
await client.logout(true);
await client.clearStores();
clearSession();
setInitClientState(null);
leaveToHome?.();
PosthogAnalytics.instance.logout();
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
}, [leaveToHome, initClientState?.client]);
// To protect against multiple sessions writing to the same storage
// simultaneously, we send a broadcast message that shuts down all other
// running instances of the app. Element Call only has storage of its own to
// protect when it created the session itself; given a client — by a host, or
// over the widget API — it is mostly stateless.
const ownsSession = client === undefined;
useEffect(() => {
if (ownsSession) loadChannel?.postMessage({});
}, [ownsSession]);
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
undefined,
);
useEventTarget(
loadChannel,
"message",
useCallback(() => {
initClientState?.client.stopClient();
setAlreadyOpenedErr(new OpenElsewhereError());
}, [initClientState?.client, setAlreadyOpenedErr]),
);
const [isDisconnected, setIsDisconnected] = useState(false);
const [supportsReactions, setSupportsReactions] = useState(false);
const state: ClientState | undefined = useMemo(() => {
if (alreadyOpenedErr) {
return { state: "error", error: alreadyOpenedErr };
}
if (initClientState === undefined) return undefined;
const authenticated =
initClientState === null
? undefined
: {
client: initClientState.client,
isPasswordlessUser: initClientState.passwordlessUser,
changePassword,
logout,
};
return {
state: "valid",
authenticated,
setClient,
disconnected: isDisconnected,
supportedFeatures: {
reactions: supportsReactions,
},
};
}, [
alreadyOpenedErr,
changePassword,
initClientState,
logout,
setClient,
isDisconnected,
supportsReactions,
]);
const onSync = useCallback(
(state: SyncState, _old: SyncState | null, data?: ISyncStateData) => {
setIsDisconnected(clientIsDisconnected(state, data));
},
[],
);
useEffect(() => {
if (state.state !== "signedIn") return;
window.matrixclient = state.client;
window.passwordlessUser = state.passwordlessUser;
if (!initClientState) {
return;
}
window.matrixclient = initClientState.client;
window.passwordlessUser = initClientState.passwordlessUser;
if (PosthogAnalytics.hasInstance())
PosthogAnalytics.instance.onLoginStatusChanged();
// Analytics follow the user's choices whoever the client belongs to
if (PosthogAnalytics.instance.isEnabled())
PosthogAnalytics.instance.startListeningToSettingsChanges();
}, [state]);
if (state.state === "error") return <ErrorPage error={state.error} />;
if (initClientState.client) {
initClientState.client.on(ClientEvent.Sync, onSync);
}
return <ClientContext value={session}>{children}</ClientContext>;
if (!hostBridge.supportsReactions)
logger.warn("The host does not permit reactions");
setSupportsReactions(hostBridge.supportsReactions);
return (): void => {
if (initClientState.client) {
initClientState.client.removeListener(ClientEvent.Sync, onSync);
}
};
}, [initClientState, onSync, hostBridge]);
if (alreadyOpenedErr) {
return <ErrorPage error={alreadyOpenedErr} />;
}
return <ClientContext value={state}>{children}</ClientContext>;
};
export type InitResult = {
client: MatrixClient;
passwordlessUser: boolean;
};
/**
* Restores or creates a session of Element Call's own. Only reached when no
* client was supplied for it to use.
*/
async function loadClient(): Promise<InitResult | null> {
const { initSPA } = await import("./utils/spa");
return initSPA(loadSession, clearSession);
}
export interface Session {
user_id: string;
device_id: string;
access_token: string;
passwordlessUser: boolean;
tempPassword?: string;
}
const clearSession = (): void => localStorage.removeItem("matrix-auth-store");
const saveSession = (s: Session): void =>
localStorage.setItem("matrix-auth-store", JSON.stringify(s));
const loadSession = (): Session | undefined => {
const data = localStorage.getItem("matrix-auth-store");
if (!data) {
return undefined;
}
return JSON.parse(data);
};
const clientIsDisconnected = (
syncState: SyncState,
syncData?: ISyncStateData,
): boolean =>
syncState === "ERROR" && syncData?.error?.name === "ConnectionError";
-43
View File
@@ -1,43 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk";
import { ClientProvider } from "./ClientContext";
import { DisconnectedBanner } from "./DisconnectedBanner";
import { hostClientSession } from "./state/ClientSession";
test("shows while the client's sync fails to connect", () => {
const client = {
on: vi.fn(),
removeListener: vi.fn(),
getUserId: () => "@alice:example.org",
} as Partial<MatrixClient> as MatrixClient;
const { container } = render(
<ClientProvider session={hostClientSession(client)}>
<DisconnectedBanner />
</ClientProvider>,
);
expect(container.textContent).toBe("");
const [, onSync] = vi.mocked(client.on).mock.calls[0] as [
unknown,
(state: string, old: null, data?: { error?: Error }) => void,
];
const connectionError = Object.assign(new Error("offline"), {
name: "ConnectionError",
});
act(() => onSync("ERROR", null, { error: connectionError }));
expect(container.textContent).not.toBe("");
// Any other failure is not a connection problem
act(() => onSync("ERROR", null, { error: new Error("rate limited") }));
expect(container.textContent).toBe("");
act(() => onSync("SYNCING", null));
expect(container.textContent).toBe("");
});
+8 -38
View File
@@ -6,60 +6,30 @@ Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import {
type FC,
type HTMLAttributes,
type ReactNode,
useEffect,
useState,
} from "react";
import { type FC, type HTMLAttributes, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import styles from "./DisconnectedBanner.module.css";
import { useClient } from "./ClientContext";
import { type ValidClientState, useClientState } from "./ClientContext";
interface Props extends HTMLAttributes<HTMLElement> {
children?: ReactNode;
className?: string;
}
/**
* Shown while the signed-in client is supposed to be connected but is not.
*/
export const DisconnectedBanner: FC<Props> = ({
children,
className,
...rest
}) => {
const { t } = useTranslation();
const { client } = useClient();
// Tagged with the client it describes, so that a swapped client does not
// inherit the old one's verdict before its first sync
const [disconnected, setDisconnected] = useState<
[MatrixClient, boolean] | null
>(null);
const clientState = useClientState();
let shouldShowBanner = false;
useEffect(() => {
if (client === undefined) return;
const onSync = (
syncState: SyncState,
_old: SyncState | null,
data?: ISyncStateData,
): void => {
const clientIsDisconnected =
syncState === "ERROR" && data?.error?.name === "ConnectionError";
setDisconnected([client, clientIsDisconnected]);
};
client.on(ClientEvent.Sync, onSync);
return (): void => {
client.removeListener(ClientEvent.Sync, onSync);
};
}, [client]);
const shouldShowBanner =
disconnected !== null && disconnected[0] === client && disconnected[1];
if (clientState?.state === "valid") {
const validClientState = clientState as ValidClientState;
shouldShowBanner = validClientState.disconnected;
}
return (
<>
+8 -10
View File
@@ -18,8 +18,6 @@ Please see LICENSE in the repository root for full details.
*/
import {
RTC_CALL_INTENT_AUDIO,
RTC_CALL_INTENT_VIDEO,
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -181,40 +179,40 @@ export function configurationForIntent(intent: UserIntent): UrlConfiguration {
switch (intent) {
case UserIntent.StartNewCall:
preset.skipLobby = false;
preset.callIntent = RTC_CALL_INTENT_VIDEO;
preset.callIntent = "video";
break;
case UserIntent.JoinExistingCall:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = false;
preset.callIntent = RTC_CALL_INTENT_VIDEO;
preset.callIntent = "video";
break;
case UserIntent.StartNewCallVoice:
preset.skipLobby = false;
preset.callIntent = RTC_CALL_INTENT_AUDIO;
preset.callIntent = "audio";
break;
case UserIntent.JoinExistingCallVoice:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = false;
preset.callIntent = RTC_CALL_INTENT_AUDIO;
preset.callIntent = "audio";
break;
case UserIntent.StartNewCallDMVoice:
preset.callIntent = RTC_CALL_INTENT_AUDIO;
preset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM:
preset.skipLobby = true;
preset.sendNotificationType = "ring";
preset.autoLeaveWhenOthersLeft = true;
preset.waitForCallPickup = true;
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
preset.callIntent = preset.callIntent ?? "video";
break;
case UserIntent.JoinExistingCallDMVoice:
preset.callIntent = RTC_CALL_INTENT_AUDIO;
preset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = true;
preset.autoLeaveWhenOthersLeft = true;
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
preset.callIntent = preset.callIntent ?? "video";
break;
// Non widget usecase defaults
default:
+6 -10
View File
@@ -21,10 +21,6 @@ import {
UserIntent,
} from "../src/UrlParams";
import { mockConfig } from "./utils/test";
import {
RTC_CALL_INTENT_AUDIO,
RTC_CALL_INTENT_VIDEO,
} from "matrix-js-sdk/lib/matrixrtc";
const ROOM_NAME = "roomNameHere";
const ROOM_ID = "!d45f138fsd";
@@ -279,7 +275,7 @@ describe("UrlParams", () => {
).toMatchObject({
...startNewCallDefaults("desktop"),
skipLobby: false,
callIntent: RTC_CALL_INTENT_VIDEO,
callIntent: "video",
});
});
@@ -326,7 +322,7 @@ describe("UrlParams", () => {
).toMatchObject({
...startNewCallDefaults("desktop"),
skipLobby: false,
callIntent: RTC_CALL_INTENT_AUDIO,
callIntent: "audio",
});
});
@@ -337,7 +333,7 @@ describe("UrlParams", () => {
),
).toMatchObject({
...joinExistingCallDefaults("desktop"),
callIntent: RTC_CALL_INTENT_AUDIO,
callIntent: "audio",
});
});
@@ -352,7 +348,7 @@ describe("UrlParams", () => {
sendNotificationType: "ring",
autoLeaveWhenOthersLeft: true,
waitForCallPickup: true,
callIntent: RTC_CALL_INTENT_AUDIO,
callIntent: "audio",
});
});
@@ -367,7 +363,7 @@ describe("UrlParams", () => {
skipLobby: true,
autoLeaveWhenOthersLeft: true,
waitForCallPickup: false,
callIntent: RTC_CALL_INTENT_VIDEO,
callIntent: "video",
});
});
@@ -381,7 +377,7 @@ describe("UrlParams", () => {
skipLobby: true,
autoLeaveWhenOthersLeft: true,
waitForCallPickup: false,
callIntent: RTC_CALL_INTENT_AUDIO,
callIntent: "audio",
});
});
});
-96
View File
@@ -1,96 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { TooltipProvider } from "@vector-im/compound-web";
import { BehaviorSubject } from "rxjs";
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
import { LeaveToHomeProvider } from "./LeaveToHomeContext";
import {
type ClientManager,
type ClientSession,
type ClientSessionState,
hostClientSession,
} from "./state/ClientSession";
import { UserMenuContainer } from "./UserMenuContainer";
import { flushPromises } from "./utils/test";
vi.mock("./settings/SettingsModal", () => ({
defaultSettingsTab: "audio",
SettingsModal: (): null => null,
}));
const mockClient = (): MatrixClient =>
({
on: vi.fn(),
removeListener: vi.fn(),
getUserId: () => "@alice:example.org",
getUserIdLocalpart: () => "alice",
getDeviceId: () => "AAAA",
getUser: () => null,
stopClient: vi.fn(),
}) as Partial<MatrixClient> as MatrixClient;
test("leaves for home after logging out of the session", async () => {
const logout = vi.fn().mockResolvedValue(undefined);
const leaveToHome = vi.fn();
renderMenu(
{
state$: new BehaviorSubject<ClientSessionState>({
state: "signedIn",
client: mockClient(),
passwordlessUser: false,
}),
},
{ login: vi.fn(), register: vi.fn(), logout },
leaveToHome,
);
await selectSignOut();
expect(logout).toHaveBeenCalled();
expect(leaveToHome).toHaveBeenCalled();
});
test("cannot end a session that is not ours", async () => {
const leaveToHome = vi.fn();
renderMenu(hostClientSession(mockClient()), null, leaveToHome);
await selectSignOut();
expect(leaveToHome).not.toHaveBeenCalled();
});
async function selectSignOut(): Promise<void> {
const user = userEvent.setup();
await user.click(screen.getByTestId("usermenu_open"));
await user.click(await screen.findByTestId("usermenu_logout"));
await flushPromises();
}
function renderMenu(
session: ClientSession,
manager: ClientManager | null,
leaveToHome: (() => void) | null = null,
): void {
render(
<BrowserRouter>
<TooltipProvider>
<LeaveToHomeProvider value={leaveToHome}>
<ClientProvider session={session}>
<ClientManagerProvider value={manager}>
<UserMenuContainer />
</ClientManagerProvider>
</ClientProvider>
</LeaveToHomeProvider>
</TooltipProvider>
</BrowserRouter>,
);
}
+5 -18
View File
@@ -9,12 +9,7 @@ import { type FC, useCallback, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import {
useClient,
useClientManagerIfAny,
useClientSessionState,
} from "./ClientContext";
import { useLeaveToHome } from "./LeaveToHomeContext";
import { useClientLegacy } from "./ClientContext";
import { useProfile } from "./profile/useProfile";
import { defaultSettingsTab, SettingsModal } from "./settings/SettingsModal";
import { UserMenu } from "./UserMenu";
@@ -26,13 +21,7 @@ interface Props {
export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
const location = useLocation();
const navigate = useNavigate();
const { client } = useClient();
const session = useClientSessionState();
const passwordlessUser =
session.state === "signedIn" && session.passwordlessUser;
// Absent when the session is a host's, which Element Call cannot end
const logout = useClientManagerIfAny()?.logout;
const leaveToHome = useLeaveToHome();
const { client, logout, authenticated, passwordlessUser } = useClientLegacy();
const { displayName, avatarUrl } = useProfile(client);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const onDismissSettingsModal = useCallback(
@@ -54,9 +43,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
setSettingsModalOpen(true);
break;
case "logout":
logout?.()
.then(() => leaveToHome?.())
.catch((e) => logger.error("Failed to log out", e));
logout?.();
break;
case "login":
navigate("/login", { state: { from: location } })?.catch((error) =>
@@ -65,7 +52,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
break;
}
},
[navigate, location, logout, leaveToHome, setSettingsModalOpen],
[navigate, location, logout, setSettingsModalOpen],
);
const userName = client?.getUserIdLocalpart() ?? "";
@@ -73,7 +60,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
<>
<UserMenu
preventNavigation={preventNavigation}
isAuthenticated={client !== undefined}
isAuthenticated={authenticated}
isPasswordlessUser={passwordlessUser}
avatarUrl={avatarUrl}
onAction={onAction}
-78
View File
@@ -1,78 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type FC } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { LoginPage } from "./LoginPage";
import { ClientManagerProvider } from "../ClientContext";
import { type ClientManager } from "../state/ClientSession";
import { Config } from "../config/Config";
beforeEach(() => {
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
"https://hs.example.org",
);
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
test("signs in with the session's manager and goes home", async () => {
const manager = mockManager();
vi.mocked(manager.login).mockResolvedValue({} as MatrixClient);
renderLogin(manager);
await submit("alice", "hunter2");
expect(manager.login).toHaveBeenCalledWith(
"https://hs.example.org",
"alice",
"hunter2",
);
expect(await screen.findByText("at /")).toBeVisible();
});
test("shows why signing in failed", async () => {
const manager = mockManager();
vi.mocked(manager.login).mockRejectedValue(new Error("Wrong password"));
renderLogin(manager);
await submit("alice", "hunter2");
expect(await screen.findByText("Wrong password")).toBeVisible();
expect(screen.getByText("at /login")).toBeVisible();
});
async function submit(username: string, password: string): Promise<void> {
const user = userEvent.setup();
await user.type(screen.getByTestId("login_username"), username);
await user.type(screen.getByTestId("login_password"), password);
await user.click(screen.getByTestId("login_login"));
}
function mockManager(): ClientManager {
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
}
const WhereAmI: FC = () => <span>at {useLocation().pathname}</span>;
function renderLogin(manager: ClientManager): void {
render(
<MemoryRouter initialEntries={["/login"]}>
<ClientManagerProvider value={manager}>
<LoginPage />
<WhereAmI />
</ClientManagerProvider>
</MemoryRouter>,
);
}
+13 -4
View File
@@ -11,9 +11,10 @@ import { Trans, useTranslation } from "react-i18next";
import { Button } from "@vector-im/compound-web";
import Logo from "../icons/LogoLarge.svg?react";
import { useClientManager } from "../ClientContext";
import { useClient } from "../ClientContext";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { Config } from "../config/Config";
@@ -23,7 +24,8 @@ export const LoginPage: FC = () => {
const { t } = useTranslation();
usePageTitle(t("login_title"));
const { login } = useClientManager();
const { client, setClient } = useClient();
const login = useInteractiveLogin(client);
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
@@ -44,8 +46,15 @@ export const LoginPage: FC = () => {
setLoading(false);
return;
}
login(homeserver, usernameRef.current.value, passwordRef.current.value)
.then(async () => {
.then(async ([client, session]) => {
if (!setClient) {
return;
}
setClient(client, session);
const locationState = location.state;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -63,7 +72,7 @@ export const LoginPage: FC = () => {
setLoading(false);
});
},
[login, location, navigate, homeserver],
[login, location, navigate, homeserver, setClient],
);
// we need to limit the length of the homserver name to not cover the whole loginview input with the string.
let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25);
-133
View File
@@ -1,133 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type FC } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { BehaviorSubject } from "rxjs";
import { RegisterPage } from "./RegisterPage";
import { ClientContextProvider, ClientManagerProvider } from "../ClientContext";
import {
type ClientManager,
type ClientSessionState,
} from "../state/ClientSession";
vi.mock("./useRecaptcha", () => ({
useRecaptcha: () => ({
execute: vi.fn().mockResolvedValue("captcha-response"),
reset,
recaptchaId: "recaptcha",
}),
}));
vi.mock("./useRegistrationTerms", () => ({
useRegistrationTerms: () => ({ recaptchaKey: "site-key" }),
}));
const reset = vi.fn();
afterEach(() => vi.clearAllMocks());
test("waits for the session to be restored", () => {
renderRegister({ state: "loading" }, mockManager());
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
});
test("registers an account, signs in and goes home", async () => {
const manager = mockManager();
const setSignedIn = vi.fn();
vi.mocked(manager.register).mockResolvedValue({
client: {} as MatrixClient,
setSignedIn,
});
renderRegister({ state: "signedOut" }, manager);
await submit("alice", "hunter2");
expect(manager.register).toHaveBeenCalledWith(
"alice",
"hunter2",
"alice",
"captcha-response",
false,
);
expect(setSignedIn).toHaveBeenCalled();
expect(await screen.findByText("at /")).toBeVisible();
});
test("upgrades a passwordless user to a full account", async () => {
const manager = mockManager();
vi.mocked(manager.register).mockResolvedValue({
client: {} as MatrixClient,
setSignedIn: vi.fn(),
});
renderRegister(
{
state: "signedIn",
client: { getUserId: () => "@guest:example.org" } as MatrixClient,
passwordlessUser: true,
},
manager,
);
await submit("alice", "hunter2");
expect(manager.register).toHaveBeenCalledWith(
"alice",
"hunter2",
"alice",
"captcha-response",
true,
);
});
test("shows why registering failed and resets the captcha", async () => {
const manager = mockManager();
vi.mocked(manager.register).mockRejectedValue(new Error("Name taken"));
renderRegister({ state: "signedOut" }, manager);
await submit("alice", "hunter2");
expect(await screen.findByText("Name taken")).toBeVisible();
expect(reset).toHaveBeenCalled();
expect(screen.getByText("at /register")).toBeVisible();
});
async function submit(username: string, password: string): Promise<void> {
const user = userEvent.setup();
await user.type(screen.getByTestId("register_username"), username);
await user.type(screen.getByTestId("register_password"), password);
await user.type(screen.getByTestId("register_confirm_password"), password);
await user.click(screen.getByTestId("register_register"));
}
function mockManager(): ClientManager {
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
}
const WhereAmI: FC = () => <span>at {useLocation().pathname}</span>;
function renderRegister(
state: ClientSessionState,
manager: ClientManager,
): void {
render(
<MemoryRouter initialEntries={["/register"]}>
<ClientContextProvider
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
>
<ClientManagerProvider value={manager}>
<RegisterPage />
<WhereAmI />
</ClientManagerProvider>
</ClientContextProvider>
</MemoryRouter>,
);
}
+17 -17
View File
@@ -22,12 +22,8 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { Button, Text } from "@vector-im/compound-web";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import {
useClient,
useClientManager,
useClientSessionState,
} from "../ClientContext";
import { useRegistrationTerms } from "./useRegistrationTerms";
import { useClientLegacy } from "../ClientContext";
import { useInteractiveRegistration } from "./useInteractiveRegistration";
import styles from "./LoginPage.module.css";
import Logo from "../icons/LogoLarge.svg?react";
import { LoadingPage } from "../FullScreenView";
@@ -41,13 +37,8 @@ export const RegisterPage: FC = () => {
const { t } = useTranslation();
usePageTitle(t("action.register"));
const session = useClientSessionState();
const loading = session.state === "loading";
const passwordlessUser =
session.state === "signedIn" && session.passwordlessUser;
const { client } = useClient();
const authenticated = client !== undefined;
const { register } = useClientManager();
const { loading, authenticated, passwordlessUser, client, setClient } =
useClientLegacy();
const confirmPasswordRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
@@ -56,7 +47,7 @@ export const RegisterPage: FC = () => {
const [error, setError] = useState<Error>();
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const { recaptchaKey } = useRegistrationTerms();
const { recaptchaKey, register } = useInteractiveRegistration(client);
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback(
@@ -73,7 +64,7 @@ export const RegisterPage: FC = () => {
setRegistering(true);
const recaptchaResponse = await execute();
const { client: newClient, setSignedIn } = await register(
const [newClient, session] = await register(
userName,
password,
userName,
@@ -104,7 +95,7 @@ export const RegisterPage: FC = () => {
}
}
setSignedIn();
setClient?.(newClient, session);
PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date());
};
@@ -126,7 +117,16 @@ export const RegisterPage: FC = () => {
reset();
});
},
[register, location, navigate, passwordlessUser, reset, execute, client],
[
register,
location,
navigate,
passwordlessUser,
reset,
execute,
client,
setClient,
],
);
useEffect(() => {
-104
View File
@@ -1,104 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, expect, test, vi } from "vitest";
import { createClient, type MatrixClient, MatrixError } from "matrix-js-sdk";
import type * as MatrixJsSdk from "matrix-js-sdk";
import { loginWithPassword, registerWithPassword } from "./interactiveAuth";
vi.mock("matrix-js-sdk", async (importOriginal) => ({
...(await importOriginal<typeof MatrixJsSdk>()),
createClient: vi.fn(),
}));
const credentials = {
user_id: "@alice:example.org",
device_id: "AAAA",
access_token: "secret",
};
// What interactive auth asks of the client besides the request itself
const authClient = { generateClientSecret: () => "client-secret" };
afterEach(() => {
vi.clearAllMocks();
});
test("logs in with a password", async () => {
const login = vi.fn().mockResolvedValue({ ...credentials, extra: "ignored" });
vi.mocked(createClient).mockReturnValue({
...authClient,
login,
} as Partial<MatrixClient> as MatrixClient);
await expect(
loginWithPassword("https://hs.example.org", "alice", "hunter2"),
).resolves.toEqual(credentials);
expect(createClient).toHaveBeenCalledWith({
baseUrl: "https://hs.example.org",
});
expect(login).toHaveBeenCalledWith("m.login.password", {
identifier: { type: "m.id.user", user: "alice" },
password: "hunter2",
});
});
test("registers, agreeing to the terms and answering the recaptcha", async () => {
// The homeserver turns the first request away with the stages to complete
// No error in it: that is the shape of a challenge rather than a refusal
const challenge = new MatrixError(
{
session: "session",
flows: [{ stages: ["m.login.terms", "m.login.recaptcha"] }],
params: {},
completed: [],
},
401,
);
const registerRequest = vi
.fn()
.mockImplementation(({ auth }: { auth?: { type: string } }) => {
if (auth === undefined) throw challenge;
challenge.data.completed = [
...(challenge.data.completed as string[]),
auth.type,
];
if (challenge.data.completed.length < 2) throw challenge;
return credentials;
});
vi.mocked(createClient).mockReturnValue({
...authClient,
registerRequest,
} as Partial<MatrixClient> as MatrixClient);
await expect(
registerWithPassword(
"https://hs.example.org",
"alice",
"hunter2",
"captcha",
),
).resolves.toEqual(credentials);
expect(registerRequest).toHaveBeenCalledWith(
expect.objectContaining({
username: "alice",
password: "hunter2",
auth: expect.objectContaining({ type: "m.login.terms" }),
}),
);
expect(registerRequest).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
type: "m.login.recaptcha",
response: "captcha",
}),
}),
);
});
-106
View File
@@ -1,106 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
createClient,
InteractiveAuth,
type LoginResponse,
type RegisterResponse,
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { type StoredSession } from "../state/ClientSession";
/** What the homeserver hands out for a new session. */
export type Credentials = Pick<
StoredSession,
"user_id" | "device_id" | "access_token"
>;
/**
* Logs in to the given homeserver with a password.
*/
export async function loginWithPassword(
homeserver: string,
username: string,
password: string,
): Promise<Credentials> {
const authClient = createClient({ baseUrl: homeserver });
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient,
doRequest: async (): Promise<LoginResponse> =>
authClient.login("m.login.password", {
identifier: {
type: "m.id.user",
user: username,
},
password,
}),
stateUpdated: (): void => {},
requestEmailToken: async (): Promise<{ sid: string }> =>
Promise.resolve({ sid: "" }),
});
return credentials(await interactiveAuth.attemptAuth());
}
/**
* Registers an account on the given homeserver, accepting its terms and
* answering its recaptcha challenge.
*/
export async function registerWithPassword(
homeserver: string,
username: string,
password: string,
recaptchaResponse: string,
): Promise<Credentials> {
const authClient = createClient({ baseUrl: homeserver });
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient,
doRequest: async (auth): Promise<RegisterResponse> =>
authClient.registerRequest({
username,
password,
auth: auth || undefined,
}),
stateUpdated: (nextStage, status): void => {
if (status.error) {
throw new Error(status.error);
}
if (nextStage === "m.login.terms") {
interactiveAuth
.submitAuthDict({
type: "m.login.terms",
})
.catch((e) => {
logger.error(e);
});
} else if (nextStage === "m.login.recaptcha") {
interactiveAuth
.submitAuthDict({
type: "m.login.recaptcha",
response: recaptchaResponse,
})
.catch((e) => {
logger.error(e);
});
}
},
requestEmailToken: async (): Promise<{ sid: string }> =>
Promise.resolve({ sid: "dummy" }),
});
return credentials(await interactiveAuth.attemptAuth());
}
// XXX: attemptAuth claims to return an IAuthData which contains none of these
// things - the js-sdk types may be wrong?
function credentials(authData: unknown): Credentials {
/* eslint-disable camelcase */
const { user_id, access_token, device_id } = authData as Credentials;
return { user_id, access_token, device_id };
/* eslint-enable camelcase */
}
+84
View File
@@ -0,0 +1,84 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useCallback } from "react";
import { InteractiveAuth } from "matrix-js-sdk";
import {
createClient,
type LoginResponse,
type MatrixClient,
} from "matrix-js-sdk";
import { initClient } from "../utils/matrix";
import { type Session } from "../ClientContext";
/**
* This provides the login method to login using user credentials.
* @param oldClient If there is an already authenticated client it should be passed to this hook
* this allows the interactive login to sign out the client before logging in.
* @returns A async method that can be called/awaited to log in with the provided credentials.
*/
export function useInteractiveLogin(
oldClient?: MatrixClient,
): (
homeserver: string,
username: string,
password: string,
) => Promise<[MatrixClient, Session]> {
return useCallback<
(
homeserver: string,
username: string,
password: string,
) => Promise<[MatrixClient, Session]>
>(
async (homeserver: string, username: string, password: string) => {
const authClient = createClient({ baseUrl: homeserver });
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient,
doRequest: async (): Promise<LoginResponse> =>
authClient.login("m.login.password", {
identifier: {
type: "m.id.user",
user: username,
},
password,
}),
stateUpdated: (): void => {},
requestEmailToken: async (): Promise<{ sid: string }> =>
Promise.resolve({ sid: "" }),
});
// XXX: This claims to return an IAuthData which contains none of these
// things - the js-sdk types may be wrong?
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
const { user_id, access_token, device_id } =
(await interactiveAuth.attemptAuth()) as any;
const session = {
user_id,
access_token,
device_id,
passwordlessUser: false,
};
// To not confuse the rust crypto sessions we need to logout the old client before initializing the new one.
await oldClient?.logout(true);
const client = await initClient(
{
baseUrl: homeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false,
);
/* eslint-enable camelcase */
return [client, session];
},
[oldClient],
);
}
+145
View File
@@ -0,0 +1,145 @@
/*
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { InteractiveAuth } from "matrix-js-sdk";
import {
createClient,
type MatrixClient,
type RegisterResponse,
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { initClient } from "../utils/matrix";
import { type Session } from "../ClientContext";
import { Config } from "../config/Config";
import { useUrlParams } from "../UrlParams";
export const useInteractiveRegistration = (
oldClient?: MatrixClient,
): {
privacyPolicyUrl?: string;
recaptchaKey?: string;
register: (
username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser: boolean,
) => Promise<[MatrixClient, Session]>;
} => {
const { isWidget } = useUrlParams();
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
undefined,
);
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
undefined,
);
const authClient = useRef<MatrixClient | undefined>(undefined);
if (!authClient.current) {
authClient.current = createClient({
baseUrl: Config.defaultHomeserverUrl()!,
});
}
useEffect(() => {
if (isWidget) return;
// An empty registerRequest is used to get the privacy policy and recaptcha key.
authClient.current!.registerRequest({}).catch((error) => {
setPrivacyPolicyUrl(
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url,
);
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
});
}, [isWidget]);
const register = useCallback(
async (
username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser: boolean,
): Promise<[MatrixClient, Session]> => {
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient.current!,
doRequest: async (auth): Promise<RegisterResponse> =>
authClient.current!.registerRequest({
username,
password,
auth: auth || undefined,
}),
stateUpdated: (nextStage, status): void => {
if (status.error) {
throw new Error(status.error);
}
if (nextStage === "m.login.terms") {
interactiveAuth
.submitAuthDict({
type: "m.login.terms",
})
.catch((e) => {
logger.error(e);
});
} else if (nextStage === "m.login.recaptcha") {
interactiveAuth
.submitAuthDict({
type: "m.login.recaptcha",
response: recaptchaResponse,
})
.catch((e) => {
logger.error(e);
});
}
},
requestEmailToken: async (): Promise<{ sid: string }> =>
Promise.resolve({ sid: "dummy" }),
});
// XXX: This claims to return an IAuthData which contains none of these
// things - the js-sdk types may be wrong?
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
const { user_id, access_token, device_id } =
(await interactiveAuth.attemptAuth()) as any;
await oldClient?.logout(true);
const client = await initClient(
{
baseUrl: Config.defaultHomeserverUrl()!,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false,
);
await client.setDisplayName(displayName);
const session: Session = {
user_id,
device_id,
access_token,
passwordlessUser,
};
/* eslint-enable camelcase */
if (passwordlessUser) {
session.tempPassword = password;
}
const user = client.getUser(client.getUserId()!)!;
user.setRawDisplayName(displayName);
user.setDisplayName(displayName);
return [client, session];
},
[oldClient],
);
return { privacyPolicyUrl, recaptchaKey, register };
};
@@ -1,116 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, expect, test, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { type FC, type PropsWithChildren } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { useRegisterPasswordlessUser } from "./useRegisterPasswordlessUser";
import { ClientManagerProvider } from "../ClientContext";
import { type ClientManager } from "../state/ClientSession";
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
vi.mock("./useRecaptcha", () => ({
useRecaptcha: () => ({
execute: vi.fn().mockResolvedValue("captcha-response"),
reset,
recaptchaId: "recaptcha",
}),
}));
vi.mock("./useRegistrationTerms", () => ({
useRegistrationTerms: () => ({
privacyPolicyUrl: "https://hs.example.org/privacy",
recaptchaKey: "site-key",
}),
}));
vi.mock("./generateRandomName", () => ({
generateRandomName: () => "Random Name",
}));
const reset = vi.fn();
afterEach(() => vi.clearAllMocks());
test("registers a user under a random name and signs them in", async () => {
const setSignedIn = vi.fn();
const manager = mockManager();
vi.mocked(manager.register).mockResolvedValue({
client: {} as MatrixClient,
setSignedIn,
});
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
wrapper: wrapper(manager, false),
});
await result.current.registerPasswordlessUser("Alice");
expect(manager.register).toHaveBeenCalledWith(
"Random Name",
expect.stringMatching(/^.{16}$/),
"Alice",
"captcha-response",
true,
);
expect(setSignedIn).toHaveBeenCalled();
expect(result.current.privacyPolicyUrl).toBe(
"https://hs.example.org/privacy",
);
expect(result.current.recaptchaId).toBe("recaptcha");
});
test("resets the captcha when registration fails", async () => {
const manager = mockManager();
vi.mocked(manager.register).mockRejectedValue(new Error("Nope"));
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
wrapper: wrapper(manager, false),
});
await expect(
result.current.registerPasswordlessUser("Alice"),
).rejects.toThrow("Nope");
expect(reset).toHaveBeenCalled();
});
test("cannot register on a session that is not ours", async () => {
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
wrapper: wrapper(null, false),
});
await expect(
result.current.registerPasswordlessUser("Alice"),
).rejects.toThrow("This session cannot register");
});
test("never registers as a widget", async () => {
const manager = mockManager();
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
wrapper: wrapper(manager, true),
});
await expect(
result.current.registerPasswordlessUser("Alice"),
).rejects.toThrow(/embedded mode/);
expect(manager.register).not.toHaveBeenCalled();
});
function mockManager(): ClientManager {
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
}
function wrapper(
manager: ClientManager | null,
isWidget: boolean,
): FC<PropsWithChildren> {
const params = { ...computeUrlParams(), isWidget };
return ({ children }) => (
<UrlParamsProvider value={params}>
<ClientManagerProvider value={manager}>{children}</ClientManagerProvider>
</UrlParamsProvider>
);
}
+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 { useClientManagerIfAny } from "../ClientContext";
import { useRegistrationTerms } from "../auth/useRegistrationTerms";
import { useClient } from "../ClientContext";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { generateRandomName } from "../auth/generateRandomName";
import { useRecaptcha } from "../auth/useRecaptcha";
import { useUrlParams } from "../UrlParams";
@@ -21,16 +21,16 @@ interface UseRegisterPasswordlessUserType {
}
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
// Null in the widget, which never registers anyone
const register = useClientManagerIfAny()?.register;
const { setClient } = useClient();
const { isWidget } = useUrlParams();
const { privacyPolicyUrl, recaptchaKey } = useRegistrationTerms();
const { privacyPolicyUrl, recaptchaKey, register } =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const registerPasswordlessUser = useCallback(
async (displayName: string) => {
if (!register) {
throw new Error("This session cannot register");
if (!setClient) {
throw new Error("No client context");
}
if (isWidget) {
throw new Error(
@@ -41,20 +41,20 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
try {
const recaptchaResponse = await execute();
const userName = generateRandomName();
const { setSignedIn } = await register(
const [client, session] = await register(
userName,
secureRandomString(16),
displayName,
recaptchaResponse,
true,
);
setSignedIn();
setClient(client, session);
} catch (e) {
reset();
throw e;
}
},
[execute, reset, register, isWidget],
[execute, reset, register, setClient, isWidget],
);
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
-98
View File
@@ -1,98 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import type * as MatrixJsSdk from "matrix-js-sdk";
import { createClient, type MatrixClient } from "matrix-js-sdk";
import { type FC, type PropsWithChildren } from "react";
import { useRegistrationTerms } from "./useRegistrationTerms";
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
import { Config } from "../config/Config";
vi.mock("matrix-js-sdk", async (importOriginal) => ({
...(await importOriginal<typeof MatrixJsSdk>()),
createClient: vi.fn(),
}));
const registerRequest = vi.fn();
beforeEach(() => {
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
"https://hs.example.org",
);
vi.mocked(createClient).mockReturnValue({
registerRequest,
} as Partial<MatrixClient> as MatrixClient);
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
test("reads the terms the homeserver asks for from a registration attempt", async () => {
registerRequest.mockRejectedValue({
data: {
params: {
"m.login.terms": {
policies: {
privacy_policy: { en: { url: "https://hs.example.org/privacy" } },
},
},
"m.login.recaptcha": { public_key: "site-key" },
},
},
});
const { result } = renderHook(() => useRegistrationTerms(), {
wrapper: wrapper(false),
});
expect(createClient).toHaveBeenCalledWith({
baseUrl: "https://hs.example.org",
});
await waitFor(() =>
expect(result.current).toEqual({
privacyPolicyUrl: "https://hs.example.org/privacy",
recaptchaKey: "site-key",
}),
);
});
test("has nothing to agree to where the homeserver asks for nothing", async () => {
registerRequest.mockRejectedValue({ data: { params: {} } });
const { result } = renderHook(() => useRegistrationTerms(), {
wrapper: wrapper(false),
});
await waitFor(() => expect(registerRequest).toHaveBeenCalled());
expect(result.current).toEqual({
privacyPolicyUrl: undefined,
recaptchaKey: undefined,
});
});
test("asks the homeserver nothing as a widget", () => {
const { result } = renderHook(() => useRegistrationTerms(), {
wrapper: wrapper(true),
});
expect(createClient).not.toHaveBeenCalled();
expect(result.current).toEqual({
privacyPolicyUrl: undefined,
recaptchaKey: undefined,
});
});
function wrapper(isWidget: boolean): FC<PropsWithChildren> {
const params = { ...computeUrlParams(), isWidget };
return ({ children }) => (
<UrlParamsProvider value={params}>{children}</UrlParamsProvider>
);
}
-46
View File
@@ -1,46 +0,0 @@
/*
Copyright 2022-2024 New Vector Ltd.
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useEffect, useState } from "react";
import { createClient } from "matrix-js-sdk";
import { Config } from "../config/Config";
import { useUrlParams } from "../UrlParams";
/**
* What the homeserver asks of a registration: its privacy policy to agree to
* and the recaptcha key to answer a challenge with.
*/
export function useRegistrationTerms(): {
privacyPolicyUrl?: string;
recaptchaKey?: string;
} {
const { isWidget } = useUrlParams();
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
undefined,
);
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
undefined,
);
useEffect(() => {
if (isWidget) return;
// An empty registerRequest is used to get the privacy policy and recaptcha key.
createClient({ baseUrl: Config.defaultHomeserverUrl()! })
.registerRequest({})
.catch((error) => {
setPrivacyPolicyUrl(
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en
?.url,
);
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
});
}, [isWidget]);
return { privacyPolicyUrl, recaptchaKey };
}
+2 -2
View File
@@ -35,7 +35,7 @@ import { LayoutSwitch } from "../room/LayoutSwitch";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
import { SelfPreview } from "./SelfPreview";
import { type UnusableReason } from "../tracks/backgroundImages";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { type ShownCamera } from "../tracks/cameraTrack";
export interface BackgroundImageRefusal {
reason: UnusableReason | "not-kept";
@@ -95,7 +95,7 @@ export interface FooterState {
/** Why the last file offered couldn't be kept, if it couldn't. */
backgroundImageRefusal: BackgroundImageRefusal | undefined;
/** The camera to preview, with the effect in force; null while it is off. */
cameraTrack: LocalVideoMediaTrack | null;
cameraTrack: ShownCamera | null;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
+24 -69
View File
@@ -7,17 +7,14 @@ Please see LICENSE in the repository root for full details.
import { afterEach, describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import {
flushPromises,
testScope,
mockMuteStates,
mockMediaDevices,
mockVideoTrack,
} from "../utils/test";
import { type Behavior, constant } from "../state/Behavior";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
@@ -27,6 +24,9 @@ import {
createLobbyFooterViewModel,
} from "./CallFooterViewModel";
import { HeaderStyle } from "../UrlParams";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import { type ShownCamera, SyncedCameraTrack } from "../tracks/cameraTrack";
import { type MuteStates } from "../state/MuteStates";
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
import type * as BackgroundImages from "../tracks/backgroundImages";
@@ -81,7 +81,6 @@ vi.mock("livekit-client", () => ({
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
return {
layout$: constant(layout),
camera$: constant(undefined),
edgeToEdge$: constant(false),
showHeader$: constant(false),
hangup: (): void => {},
@@ -118,11 +117,6 @@ const noPipeline = constant<BackgroundEffectsState>({
processor: undefined,
});
/** The provider's verdict, which the SDK's check stands for here. */
function pipelineAsTheSdkSays(): Behavior<BackgroundEffectsState> {
return constant({ supported: sdkSupportMock(), processor: undefined });
}
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
audioInput: {
available$: constant(
@@ -285,8 +279,7 @@ describe("createCallFooterViewModel", () => {
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipelineAsTheSdkSays(),
/* camera$ */ constant(null),
noPipeline,
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
@@ -374,49 +367,22 @@ describe("createCallFooterViewModel", () => {
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "settling",
settling: true,
});
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipeline$,
/* camera$ */ constant(null),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.backgroundEffectSettling$.value).toBe(true);
pipeline$.next({ ...pipeline$.value, wait: undefined });
pipeline$.next({ ...pipeline$.value, settling: false });
expect(vm.backgroundEffectSettling$.value).toBe(false);
});
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
platformMock.mockReturnValue("desktop");
backgroundEffectSetting.setValue("blur");
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
});
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipeline$,
/* camera$ */ constant(null),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.backgroundEffect$.value).toBe("blur");
pipeline$.next({ supported: false, processor: undefined });
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
expect(vm.addBackgroundImage$.value).toBeUndefined();
expect(vm.backgroundEffect$.value).toBe("none");
expect(backgroundEffectSetting.getValue()).toBe("blur");
});
it("adding is unavailable at the limit", () => {
sdkSupportMock.mockReturnValue(true);
const added$ = addedBackgrounds.added$ as BehaviorSubject<
@@ -473,44 +439,33 @@ describe("createCallFooterViewModel", () => {
expect(store.remove).toHaveBeenCalledWith("spare");
});
it("previews the lobby's camera while there is one", () => {
const track = mockVideoTrack({});
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
it("previews the camera only while it is on", () => {
const camera = new SyncedCameraTrack();
const track = {} as ShownCamera;
camera.report(track);
const videoEnabled$ = new BehaviorSubject(true);
const muteStates = mockMuteStates();
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
{
...muteStates,
video: { ...muteStates.video, enabled$: videoEnabled$ },
} as unknown as MuteStates,
twoMicsAndOneCamMediaDevices,
noPipeline,
camera$,
constant({
supported: true,
processor: undefined,
cameraTrack: camera,
}),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.cameraTrack$.value).toBe(track);
// The lobby releases the camera when it is turned off
camera$.next(null);
videoEnabled$.next(false);
expect(vm.cameraTrack$.value).toBeNull();
});
it("previews the call's camera only while it is not muted", () => {
const muted$ = new BehaviorSubject(false);
const track = mockVideoTrack({ muted$ });
const vm = createCallFooterViewModel(
testScope(),
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.cameraTrack$.value).toBe(track);
muted$.next(true);
expect(vm.cameraTrack$.value).toBeNull();
muted$.next(false);
expect(vm.cameraTrack$.value).toBe(track);
});
it("availability is the same before and during a call", () => {
for (const supported of [true, false]) {
sdkSupportMock.mockReturnValue(supported);
@@ -520,7 +475,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipelineAsTheSdkSays(),
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
+23 -32
View File
@@ -15,8 +15,10 @@ import {
switchMap,
} from "rxjs";
import { supportsAudioOutputSelection } from "livekit-client";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
import {
supportsBackgroundProcessors,
supportsModernBackgroundProcessors,
} from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import {
@@ -24,6 +26,7 @@ import {
serializeEffect,
shippedBackgrounds,
} from "../tracks/backgroundEffectCatalog";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import {
type AddedBackground,
addedBackgrounds,
@@ -32,7 +35,6 @@ import {
} from "../tracks/backgroundImages";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import {
@@ -88,8 +90,7 @@ function buildDeviceBehaviors(
/** return empty arrays for audioOptions and videoOptions*/
disableSwitcher$: Behavior<boolean>,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
/** The camera as the pipeline has it, for the menu's preview. */
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
videoEnabled$: Behavior<boolean>,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions$"
@@ -126,14 +127,10 @@ function buildDeviceBehaviors(
),
);
// Also false once the pipeline has failed to build this session.
const supported$ = backgroundEffectsState$.pipe(
map(({ supported }) => supported === true),
distinctUntilChanged(),
);
const supported = supportsBackgroundProcessors();
const slow = !supportsModernBackgroundProcessors();
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
const offered$ = disableSwitcher$.pipe(
map((switcherDisabled) => !switcherDisabled && supported),
);
// A new object for each refusal, so the same one twice is shown twice.
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
@@ -164,10 +161,8 @@ function buildDeviceBehaviors(
),
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
backgroundEffect$: scope.behavior(
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
map(([raw, supported]) =>
supported ? serializeEffect(parseEffect(raw)) : "none",
),
backgroundEffectSetting.value$.pipe(
map((raw) => (supported ? serializeEffect(parseEffect(raw)) : "none")),
),
),
selectBackgroundEffect$: scope.behavior(
@@ -196,7 +191,7 @@ function buildDeviceBehaviors(
),
backgroundEffectSettling$: scope.behavior(
backgroundEffectsState$.pipe(
map(({ wait }) => wait !== undefined),
map(({ settling }) => settling ?? false),
distinctUntilChanged(),
),
false,
@@ -220,15 +215,13 @@ function buildDeviceBehaviors(
),
),
backgroundImageRefusal$: refusal$,
// None while muted: the call mutes its track rather than releasing it.
cameraTrack$: scope.behavior(
camera$.pipe(
switchMap((camera) =>
camera
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
: of(null),
combineLatest([
backgroundEffectsState$.pipe(
switchMap(({ cameraTrack }) => cameraTrack?.track$ ?? of(null)),
),
),
videoEnabled$,
]).pipe(map(([track, videoEnabled]) => (videoEnabled ? track : null))),
null,
),
// Never the one in force: taking it away would leave nothing chosen.
@@ -260,8 +253,8 @@ function buildDeviceBehaviors(
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param backgroundEffectsState$ - The background pipeline's state: whether
* effects run here, and the wait for the first.
* @param backgroundEffectsState$ - The background pipeline's state: whether effects
* run here, the wait for the first, and the camera it is synced to.
* @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button).
* @param options - `showControls`: whether the call controls should be shown.
@@ -292,7 +285,7 @@ export function createCallFooterViewModel(
mediaDevices,
disableDeviceSwitcher$,
backgroundEffectsState$,
callModel.camera$,
muteStates.video.enabled$,
),
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
@@ -355,9 +348,8 @@ export function createCallFooterViewModel(
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param backgroundEffectsState$ - The background pipeline's state: whether
* effects run here, and the wait for the first.
* @param camera$ - The lobby's camera, for the camera menu's preview.
* @param backgroundEffectsState$ - The background pipeline's state: whether effects
* run here, the wait for the first, and the camera it is synced to.
* @param openSettings - Callback to open the settings modal, or undefined.
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
* @param showLogo - Whether to show the Element Call logo.
@@ -367,7 +359,6 @@ export function createLobbyFooterViewModel(
muteStates: MuteStates,
mediaDevices: MediaDevices,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
camera$: Behavior<LocalVideoMediaTrack | null>,
openSettings: (() => void) | undefined,
hangup: (() => void) | undefined,
showLogo: boolean,
@@ -416,7 +407,7 @@ export function createLobbyFooterViewModel(
mediaDevices,
constant(false),
backgroundEffectsState$,
camera$,
muteStates.video.enabled$,
),
};
}
+7 -4
View File
@@ -114,9 +114,6 @@ const MENU_WIDTH = 296;
/** Space kept between the menu and the call area's sides. */
const MENU_MARGIN = 16;
/** Below the preview, as its stylesheet draws it. */
const PREVIEW_GAP = 12;
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
enabled,
busy,
@@ -218,6 +215,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
const [previewBlock, setPreviewBlock] = useState(0);
useEffect(() => {
if (!menuOpen) return;
// From the tokens the stylesheets size the preview with, so that it is
// placed once rather than measured and moved.
const tokens = getComputedStyle(rootElement);
const px = (token: string): number =>
parseFloat(tokens.getPropertyValue(token)) || 0;
const below = px("--cpd-space-2x") + px("--cpd-space-1x");
// Followed, since a host can resize the call while the menu is open.
const subscription = observeElementSize$(rootElement)
.pipe(
@@ -231,7 +234,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
)
.subscribe(({ height, width }) => {
setListShare(height);
setPreviewBlock(Math.round((width * 9) / 16) + PREVIEW_GAP);
setPreviewBlock(Math.round((width * 9) / 16) + below);
});
return (): void => subscription.unsubscribe();
}, [menuOpen, rootElement]);
-47
View File
@@ -1,47 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { act, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BehaviorSubject } from "rxjs";
import { SelfPreview } from "./SelfPreview";
import styles from "./SelfPreview.module.css";
import { mockVideoTrack } from "../utils/test";
describe("SelfPreview", () => {
it("detaches only its own element from a camera shown twice", () => {
const track = mockVideoTrack({});
const first = render(<SelfPreview track={track} />);
const second = render(<SelfPreview track={track} />);
const [a, b] = [first, second].map((r) =>
r.container.querySelector("video"),
);
expect(track.attach).toHaveBeenCalledWith(a);
expect(track.attach).toHaveBeenCalledWith(b);
first.unmount();
expect(track.detach).toHaveBeenCalledExactlyOnceWith(a);
});
it("mirrors the camera while it faces the user, and only then", () => {
const facingMode$ = new BehaviorSubject<"user" | "environment">("user");
const { container } = render(
<SelfPreview track={mockVideoTrack({ facingMode$ })} />,
);
const video = (): HTMLVideoElement => container.querySelector("video")!;
expect(video().classList.contains(styles.mirrored)).toBe(true);
act(() => facingMode$.next("environment"));
expect(video().classList.contains(styles.mirrored)).toBe(false);
});
it("says the camera is off while there is none", () => {
const { container } = render(<SelfPreview track={null} />);
expect(container.querySelector("video")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
});
+6 -18
View File
@@ -5,18 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useEffect, useRef } from "react";
import { constant, type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { type FC } from "react";
import { VideoCallOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./SelfPreview.module.css";
import { useBehavior } from "../useBehavior";
const noFacingMode = constant(undefined);
import { useAttachedTrack } from "../useAttachedTrack";
import { type ShownCamera } from "../tracks/cameraTrack";
interface Props {
/** The camera as the pipeline has it, or none while the camera is off. */
track: LocalVideoMediaTrack | null;
track: ShownCamera | null;
}
/**
@@ -24,17 +22,7 @@ interface Props {
* shown a second time rather than captured again.
*/
export const SelfPreview: FC<Props> = ({ track }) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const element = videoRef.current;
if (!track || !element) return;
track.attach(element);
// This element only: the pre-join preview may be showing the same camera
return (): void => {
track.detach(element);
};
}, [track]);
const facingMode = useBehavior(track?.facingMode$ ?? noFacingMode);
const { videoRef, mirrored } = useAttachedTrack(track);
if (!track) return <VideoCallOffIcon width={32} height={32} />;
return (
@@ -42,7 +30,7 @@ export const SelfPreview: FC<Props> = ({ track }) => {
ref={videoRef}
muted
playsInline
className={facingMode === "user" ? styles.mirrored : undefined}
className={mirrored ? styles.mirrored : undefined}
/>
);
};
-16
View File
@@ -108,22 +108,6 @@ describe("Config.initWith", () => {
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
});
it("falls back to the defaults when config.json is not JSON", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("<!doctype html><title>Element</title>", {
status: 200,
headers: { "content-type": "text/html" },
}),
);
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
Reflect.set(Config, "internalInstance", undefined);
await expect(Config.init()).resolves.toBeUndefined();
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it("replaces a config initialized earlier", () => {
Config.initWith({ ssla: "https://first.invalid/ssla" });
Config.initWith({ ssla: "https://second.invalid/ssla" });
+1 -6
View File
@@ -156,10 +156,5 @@ async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
return {};
}
try {
return await response.json();
} catch (e) {
logger.warn(`Config at ${fetchTarget} is not JSON, using defaults`, e);
return {};
}
return response.json();
}
-62
View File
@@ -1,62 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { BehaviorSubject } from "rxjs";
import { HomePage } from "./HomePage";
import { ClientContextProvider } from "../ClientContext";
import { type ClientSessionState } from "../state/ClientSession";
vi.mock("./RegisteredView", () => ({
RegisteredView: ({ client }: { client: MatrixClient }) => (
<span>registered as {client.getUserId()}</span>
),
}));
vi.mock("./UnauthenticatedView", () => ({
UnauthenticatedView: () => <span>unauthenticated</span>,
}));
test("waits for the session to be restored", () => {
renderHome({ state: "loading" });
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
});
test("shows the session's error", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
renderHome({ state: "error", error: new Error("Opened elsewhere") });
expect(screen.getByText("Something went wrong")).toBeVisible();
});
test("welcomes a signed-in user", () => {
renderHome({
state: "signedIn",
client: { getUserId: () => "@alice:example.org" } as MatrixClient,
passwordlessUser: false,
});
expect(screen.getByText("registered as @alice:example.org")).toBeVisible();
});
test("invites a signed-out user to start a call", () => {
renderHome({ state: "signedOut" });
expect(screen.getByText("unauthenticated")).toBeVisible();
});
function renderHome(state: ClientSessionState): void {
render(
<BrowserRouter>
<ClientContextProvider
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
>
<HomePage />
</ClientContextProvider>
</BrowserRouter>,
);
}
+12 -11
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 { useClientSessionState } from "../ClientContext";
import { useClientState } from "../ClientContext";
import { ErrorPage, LoadingPage } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView";
import { RegisteredView } from "./RegisteredView";
@@ -18,16 +18,17 @@ export const HomePage: FC = () => {
const { t } = useTranslation();
usePageTitle(t("common.home"));
const session = useClientSessionState();
const clientState = useClientState();
switch (session.state) {
case "loading":
return <LoadingPage />;
case "error":
return <ErrorPage error={session.error} />;
case "signedIn":
return <RegisteredView client={session.client} />;
case "signedOut":
return <UnauthenticatedView />;
if (!clientState) {
return <LoadingPage />;
} else if (clientState.state === "error") {
return <ErrorPage error={clientState.error} />;
} else {
return clientState.authenticated ? (
<RegisteredView client={clientState.authenticated.client} />
) : (
<UnauthenticatedView />
);
}
};
-136
View File
@@ -1,136 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type FC } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { 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>,
);
}
+24 -12
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 { useClientManager } from "../ClientContext";
import { useClient } 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 { useRegistrationTerms } from "../auth/useRegistrationTerms";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
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 { register } = useClientManager();
const { setClient } = useClient();
const { header } = useUrlParams();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics();
const { recaptchaKey } = useRegistrationTerms();
const { recaptchaKey, register } = useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
@@ -67,9 +67,7 @@ export const UnauthenticatedView: FC = () => {
setLoading(true);
const recaptchaResponse = await execute();
const userName = generateRandomName();
// 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(
const [client, session] = await register(
userName,
secureRandomString(16),
displayName,
@@ -85,12 +83,15 @@ 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") {
// Wrapped once more, as a bare function would be taken for an updater
setOnFinished(() => () => {
setSignedIn();
setOnFinished(() => {
setClient(client, session);
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
navigate(`/${aliasLocalpart}`)?.catch((error) => {
logger.error("Failed to navigate to alias localpart", error);
@@ -105,10 +106,14 @@ 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");
setSignedIn();
setClient(client, session);
await navigate(
getRelativeRoomUrl(
createRoomResult.roomId,
@@ -125,7 +130,14 @@ export const UnauthenticatedView: FC = () => {
reset();
});
},
[register, reset, execute, navigate, setJoinExistingCallModalOpen],
[
register,
reset,
execute,
navigate,
setJoinExistingCallModalOpen,
setClient,
],
);
return (
+9 -19
View File
@@ -20,9 +20,7 @@ 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) => {
@@ -53,25 +51,17 @@ 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={vm} widget={widget} />
<App
vm={
new AppViewModel(globalScope, {
controlledAudioDevices,
callIntent,
})
}
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&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";
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";
export const ElementCallReactionEventType = "io.element.call.reaction";
+4 -2
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 { useHostBridge } from "../HostBridge";
import { useClientState } from "../ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
@@ -54,7 +54,9 @@ export const ReactionsSenderProvider = ({
vm: CallViewModel;
}): JSX.Element => {
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const { supportsReactions } = useHostBridge();
const clientState = useClientState();
const supportsReactions =
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
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?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 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 { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest";
+19 -4
View File
@@ -75,6 +75,7 @@ import {
type RTCSlotOptions,
constant,
} from "@element-hq/matrixrtc-sdk";
import { map } from "rxjs";
import {
advancedCamera,
advancedScreenShare,
@@ -109,7 +110,11 @@ import { useBehavior } from "../useBehavior.ts";
import { useValueBehavior } from "../useValueBehavior.ts";
import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css";
import { useBackgroundEffectsState$ } from "../tracks/BackgroundEffectsContext";
import {
reportCamera,
useBackgroundEffectsState$,
} from "../tracks/BackgroundEffectsContext.tsx";
import { sdkCamera } from "../tracks/cameraTrack.ts";
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
@@ -180,7 +185,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.muteStates,
mediaDevices,
capture,
backgroundEffectsState$.value,
backgroundEffectsState$.value.processor,
),
});
@@ -194,7 +199,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
capture,
backgroundEffectsState$,
videoProcessor$: scope.behavior(
backgroundEffectsState$.pipe(map((state) => state.processor)),
),
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
@@ -208,6 +215,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
reactionsReader.reactions$,
);
setVm(vm);
// The call's camera is the one the camera menu shows from here on.
reportCamera(
scope,
vm.camera$.pipe(map((camera) => (camera ? sdkCamera(camera) : null))),
backgroundEffectsState$,
);
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
@@ -822,7 +835,9 @@ export function rtcParticipationOptions(
const session = config.matrix_rtc_session;
return {
sendNotificationType: urlParams.sendNotificationType,
callIntent: urlParams.callIntent,
applicationData: urlParams.callIntent
? { "m.call.intent": urlParams.callIntent }
: undefined,
timings: {
syncDisconnectGracePeriodMs: config.sync_disconnect_grace_period_ms,
networkErrorRetryMs: session.network_error_retry_ms,
+1
View File
@@ -28,6 +28,7 @@ const stable = vi.hoisted(() => ({
media: undefined as unknown,
}));
vi.mock("../tracks/BackgroundEffectsContext", () => ({
reportCamera: (): void => {},
useBackgroundEffectsState$: (): Behavior<BackgroundEffectsState> =>
(stable.processorState$ ??= constant({
supported: false,
+1
View File
@@ -45,6 +45,7 @@ const leaveToHome = vi.fn();
// One instance: the view keys an effect on it
const processorState$ = vi.hoisted(() => ({ current: undefined as unknown }));
vi.mock("../tracks/BackgroundEffectsContext", () => ({
reportCamera: (): void => {},
useBackgroundEffectsState$: (): Behavior<BackgroundEffectsState> =>
(processorState$.current ??= constant({
supported: false,
+21 -12
View File
@@ -16,6 +16,7 @@ import {
type LocalMedia,
type LocalVideoMediaTrack,
} from "@element-hq/matrixrtc-sdk";
import { map } from "rxjs";
import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css";
@@ -30,7 +31,11 @@ import { LeaveToHomeLink } from "../button/LeaveToHomeLink";
import { useLeaveToHome } from "../LeaveToHomeContext";
import { useMediaDevices } from "../MediaDevicesContext";
import { ObservableScope } from "../state/ObservableScope";
import { useBackgroundEffectsState$ } from "../tracks/BackgroundEffectsContext";
import {
reportCamera,
useBackgroundEffectsState$,
} from "../tracks/BackgroundEffectsContext";
import { sdkCamera } from "../tracks/cameraTrack";
import { useBehavior } from "../useBehavior";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
@@ -126,16 +131,23 @@ export const LobbyView: FC<Props> = ({
const [mediaVm, setMediaVm] = useState<LobbyMediaViewModel | null>(null);
useEffect(() => {
const mediaScope = new ObservableScope();
setMediaVm(
createLobbyMediaViewModel(
mediaScope,
media,
muteStates,
devices,
captureSettings(),
backgroundEffectsState$,
const vm = createLobbyMediaViewModel(
mediaScope,
media,
muteStates,
devices,
captureSettings(),
mediaScope.behavior(
backgroundEffectsState$.pipe(map((state) => state.processor)),
),
);
// The lobby's camera is the one the camera menu shows until the call's
reportCamera(
mediaScope,
vm.videoTrack$.pipe(map((camera) => camera && sdkCamera(camera))),
backgroundEffectsState$,
);
setMediaVm(vm);
return (): void => {
mediaScope.end();
};
@@ -155,7 +167,6 @@ export const LobbyView: FC<Props> = ({
null,
);
useEffect(() => {
if (mediaVm === null) return;
const footerScope = new ObservableScope();
setFooterVm(
createLobbyFooterViewModel(
@@ -163,7 +174,6 @@ export const LobbyView: FC<Props> = ({
muteStates,
devices,
backgroundEffectsState$,
mediaVm.videoTrack$,
openSettings,
hangup,
// Logo and header are connected: only show the logo in SPA with header.
@@ -178,7 +188,6 @@ export const LobbyView: FC<Props> = ({
backgroundEffectsState$,
hangup,
hideHeader,
mediaVm,
muteStates,
openSettings,
]);
+1 -2
View File
@@ -8,7 +8,6 @@ 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";
@@ -40,7 +39,7 @@ test("ringtone plays on loop while ringing", () => {
userId: alice.userId,
displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined),
intent: RTC_CALL_INTENT_AUDIO,
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&no-inline";
import ringtoneOgg from "../sound/ringtone.ogg?url&no-inline";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
import { type UseAudioContext, useAudioContext } from "../useAudioContext";
import { useLatest } from "../useLatest";
-101
View File
@@ -1,101 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { 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>,
);
}
+4 -7
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 { useClient, useClientSessionState } from "../ClientContext";
import { useClientLegacy } from "../ClientContext";
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView";
import { CallView } from "./CallView";
@@ -42,12 +42,8 @@ export const RoomPage: FC = (): ReactNode => {
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false);
const { client } = useClient();
const session = useClientSessionState();
const loading = session.state === "loading";
const passwordlessUser =
session.state === "signedIn" && session.passwordlessUser;
const authenticated = client !== undefined;
const { loading, authenticated, client, error, passwordlessUser } =
useClientLegacy();
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
@@ -188,6 +184,7 @@ 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 />;
+9 -16
View File
@@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useEffect, useMemo, useRef, type FC, type ReactNode } from "react";
import { useMemo, type FC, type ReactNode } from "react";
import useMeasure from "react-use-measure";
import { constant, type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
@@ -15,9 +15,8 @@ import { TileAvatar } from "../tile/TileAvatar";
import styles from "./VideoPreview.module.css";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import videoPlaceholder from "../graphics/video-placeholder.gif";
import { useBehavior } from "../useBehavior";
const noFacingMode = constant(undefined);
import { useAttachedTrack } from "../useAttachedTrack";
import { sdkCamera } from "../tracks/cameraTrack";
export type MatrixInfo = {
userId: string;
@@ -46,15 +45,9 @@ export const VideoPreview: FC<Props> = ({
const { t } = useTranslation();
const [previewRef, previewBounds] = useMeasure();
const videoEl = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const element = videoEl.current;
if (!element || !videoTrack) return;
videoTrack.attach(element);
return (): void => videoTrack.detach(element);
}, [videoTrack]);
const facingMode = useBehavior(videoTrack?.facingMode$ ?? noFacingMode);
const { videoRef, mirrored } = useAttachedTrack(
videoTrack && sdkCamera(videoTrack),
);
const cameraIsStarting = useMemo(
() => videoEnabled && !videoTrack,
@@ -64,8 +57,8 @@ export const VideoPreview: FC<Props> = ({
return (
<div className={classNames(styles.preview)} ref={previewRef}>
<video
className={facingMode === "user" ? styles.mirror : undefined}
ref={videoEl}
className={mirrored ? styles.mirror : undefined}
ref={videoRef}
muted
playsInline
// There's no reason for this to be focusable
+16 -2
View File
@@ -20,7 +20,6 @@ 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";
@@ -75,7 +74,22 @@ function renderWithMockClient(
} as unknown as MatrixClient);
render(
<ClientContextProvider value={hostClientSession(client)}>
<ClientContextProvider
value={{
state: "valid",
disconnected: false,
supportedFeatures: {
reactions: true,
},
setClient: vi.fn(),
authenticated: {
client,
isPasswordlessUser: true,
changePassword: vi.fn(),
logout: vi.fn(),
},
}}
>
<TestComponent
sendLogs={sendLogs}
getRageshakeSubmitUrl={getRageshakeSubmitUrl}
+26 -7
View File
@@ -34,6 +34,23 @@ 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({
@@ -43,16 +60,18 @@ describe("prefetchSounds", () => {
});
it("leaves out a sound that fails to load, keeping the others", async () => {
// Fetched in the order given
fetchSpy
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValueOnce(
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
);
fetchSpy.mockRejectedValue(new TypeError("Failed to fetch"));
const sounds = await prefetchSounds({
beep: { ogg: "https://example.org/beep.ogg" },
boop: { ogg: "https://example.org/boop.ogg" },
boop: { ogg: "data:audio/ogg;base64,T2dnUw==" },
});
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({});
});
});
+27 -1
View File
@@ -32,12 +32,38 @@ 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.
* @param url Where the sound is: an ordinary URL to fetch, or a `data:` URL.
* @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();
+12 -18
View File
@@ -7,11 +7,7 @@ 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 {
RTC_CALL_INTENT_AUDIO,
RTC_CALL_INTENT_VIDEO,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
import type { Controls, OutputDevice } from "../controls";
@@ -121,7 +117,7 @@ describe("Default selection", () => {
const controlledAudioOutput = new AndroidControlledAudioOutput(
of(BASE_DEVICE_LIST),
testScope,
RTC_CALL_INTENT_VIDEO,
"video",
mockControls,
);
@@ -146,7 +142,7 @@ describe("Default selection", () => {
const controlledAudioOutput = new AndroidControlledAudioOutput(
of(BASE_DEVICE_LIST),
testScope,
RTC_CALL_INTENT_AUDIO,
"audio",
mockControls,
);
@@ -167,7 +163,7 @@ describe("Default selection", () => {
});
});
[RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => {
["audio", "video"].forEach((callIntent) => {
it(`Default to BT headset for ${callIntent} calls if present`, async () => {
const controlledAudioOutput = new AndroidControlledAudioOutput(
of(BT_HEADSET_BASE_DEVICE_LIST),
@@ -194,7 +190,7 @@ describe("Default selection", () => {
});
});
[RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => {
["audio", "video"].forEach((callIntent) => {
it(`Default to wired headset for ${callIntent} calls if present`, async () => {
const controlledAudioOutput = new AndroidControlledAudioOutput(
of(WIRED_HEADSET_BASE_DEVICE_LIST),
@@ -304,7 +300,7 @@ describe("Test select a device", () => {
b: BT_HEADSET_BASE_DEVICE_LIST,
}),
testScope,
RTC_CALL_INTENT_AUDIO,
"audio",
mockControls,
);
@@ -344,7 +340,7 @@ describe("Test select a device", () => {
c: BASE_DEVICE_LIST,
}),
testScope,
RTC_CALL_INTENT_AUDIO,
"audio",
mockControls,
);
@@ -397,7 +393,7 @@ describe("Available device changes", () => {
});
it("When a BT headset is added, control should switch to use it", () => {
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
createAudioControlledOutput("video");
// Emit the base device list, the speaker should be selected
availableSource$.next(BASE_DEVICE_LIST);
@@ -423,9 +419,7 @@ 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(
RTC_CALL_INTENT_VIDEO,
);
const controlledAudioOutput = createAudioControlledOutput("video");
// Emit the base device list, the speaker should be selected
availableSource$.next(BASE_DEVICE_LIST);
@@ -452,7 +446,7 @@ describe("Available device changes", () => {
});
it("When the active bt headset is removed on audio call, control should switch to earpiece", () => {
createAudioControlledOutput(RTC_CALL_INTENT_AUDIO);
createAudioControlledOutput("audio");
// Emit the BT headset device list, the BT headset should be selected
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
@@ -478,7 +472,7 @@ describe("Available device changes", () => {
});
it("When the active bt headset is removed on video call, control should switch to speaker", () => {
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
createAudioControlledOutput("video");
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
@@ -503,7 +497,7 @@ describe("Available device changes", () => {
});
it("Do not repeatidly set the same device", () => {
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
createAudioControlledOutput("video");
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
+3 -6
View File
@@ -22,10 +22,7 @@ import {
type SelectedAudioOutputDevice,
} from "./MediaDevices.ts";
import type { ObservableScope } from "./ObservableScope.ts";
import {
RTC_CALL_INTENT_AUDIO,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { type Controls, type OutputDevice } from "../controls.ts";
import { type Behavior } from "./Behavior.ts";
@@ -105,7 +102,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. "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 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 controls - The controls provided by the hosting application to control the audio routing and notify of user actions.
*/
public constructor(
@@ -319,7 +316,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
this.logger.debug(
`Android routing logic intent: ${this.initialIntent} finding best default...`,
);
if (this.initialIntent === RTC_CALL_INTENT_AUDIO) {
if (this.initialIntent === "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,7 +5,6 @@ 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";
@@ -23,14 +22,8 @@ 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,
) {}
}
+19 -41
View File
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BehaviorSubject, distinctUntilChanged, map, of } from "rxjs";
import { BehaviorSubject, distinctUntilChanged, map } from "rxjs";
import { type BackgroundProcessorWrapper } from "@livekit/track-processors";
import {
@@ -47,28 +47,23 @@ function fakePipeline(): {
};
}
/** One letter per state: idle, preparing, waiting for a frame, attached. */
/** One letter per state: idle, waiting for a frame, attached. */
function letter(state: BackgroundEffectsState): string {
if (state.supported === false) return "x";
if (state.wait === "preparing") return "p";
if (state.processor === undefined) return "i";
return state.wait === "settling" ? "w" : "a";
return state.settling ? "w" : "a";
}
describe("the pipeline's state", () => {
function testState({
effect,
answer = "-y",
firstFrame = "",
expected,
}: {
effect: string;
/** When the trial build answers, from being asked. */
answer?: string;
firstFrame?: string;
expected: string;
}): void {
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
const transformer = {
onFirstFrame: undefined as (() => void) | undefined,
};
@@ -77,7 +72,6 @@ describe("the pipeline's state", () => {
effect$: behavior(effect, { n: "none", b: "blur" }),
setEffect: vi.fn(),
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
canSegment: () => cold(answer, { y: true, n: false }),
pipeline: fakePipeline().pipeline,
transformer,
});
@@ -90,21 +84,15 @@ describe("the pipeline's state", () => {
it("defaults to no effect", () => testState({ effect: "n", expected: "i" }));
it("attaches only once it is known the pipeline builds", () =>
testState({ effect: "nb", answer: "--y", expected: "ip-w" }));
it("offers none where the pipeline fails to build", () =>
testState({ effect: "nb", answer: "-n", expected: "ipx" }));
it("waits from the first effect until a frame carries it", () =>
testState({
effect: " nb---n-b",
firstFrame: "---f",
expected: " ipwa",
effect: " nb-n-b",
firstFrame: " --f",
expected: " iwa",
}));
it("attaches on first use and stays attached", () =>
testState({ effect: "nb--n", firstFrame: "---f", expected: "ipwa" }));
testState({ effect: "nb-n", firstFrame: "--f", expected: "iwa" }));
});
describe("background effects", () => {
@@ -121,7 +109,6 @@ describe("background effects", () => {
effect$,
setEffect,
added$,
canSegment: () => of(true),
pipeline: fake.pipeline,
transformer: { onFirstFrame: undefined },
...options,
@@ -131,6 +118,8 @@ describe("background effects", () => {
effect$.next(raw);
await flushPromises();
};
const blur = async (on: boolean): Promise<void> =>
choose(on ? "blur" : "none");
beforeEach(() => {
effect$ = new BehaviorSubject("none");
@@ -139,17 +128,6 @@ describe("background effects", () => {
fake = fakePipeline();
});
it("offers none for the session where the pipeline fails to build", async () => {
const effects = build({ canSegment: () => of(false) });
await choose("blur");
expect(effects.state$.value.supported).toBe(false);
expect(effects.state$.value.processor).toBeUndefined();
expect(effects.state$.value.wait).toBeUndefined();
// A browser update may fix it, so the next session asks again.
expect(effect$.value).toBe("blur");
});
it("puts a shipped background on as that picture", async () => {
build();
await choose(`image:${shippedBackgrounds[0].id}`);
@@ -163,10 +141,10 @@ describe("background effects", () => {
it("switches in place rather than reattaching", async () => {
const effects = build();
await choose("blur");
await blur(true);
const pipeline = effects.state$.value.processor;
await choose("none");
await choose("blur");
await blur(false);
await blur(true);
expect(effects.state$.value.processor).toBe(pipeline);
expect(fake.switches).toEqual([
@@ -179,18 +157,18 @@ describe("background effects", () => {
it("keeps switching after a switch fails", async () => {
vi.mocked(fake.pipeline.switchTo).mockRejectedValueOnce(new Error("lost"));
build();
await choose("blur");
await choose("none");
await blur(true);
await blur(false);
expect(fake.switches).toEqual([{ mode: "disabled" }]);
});
it("switches one at a time, skipping those overtaken", async () => {
const finished = fake.holdNext();
build();
await choose("blur");
await choose("none");
await choose("blur");
await choose("none");
await blur(true);
await blur(false);
await blur(true);
await blur(false);
expect(fake.switches).toHaveLength(1);
finished();
+17 -45
View File
@@ -8,15 +8,9 @@ Please see LICENSE in the repository root for full details.
import {
combineLatest,
distinctUntilChanged,
first,
from,
map,
Observable,
type ObservableInput,
of,
scan,
startWith,
switchMap,
} from "rxjs";
import {
type BackgroundProcessorWrapper,
@@ -38,18 +32,17 @@ import {
parseEffect,
} from "../tracks/backgroundEffectCatalog";
import { type AddedBackground } from "../tracks/backgroundImages";
import { SyncedCameraTrack } from "../tracks/cameraTrack";
const blurRadius = 15;
export interface BackgroundEffectsState {
supported: boolean | undefined;
processor: undefined | VideoProcessor;
/**
* The effect is not ready:
* preparing, asking whether the pipeline can be built.
* settling, once it is attached until a frame carrying the effect
*/
wait?: "preparing" | "settling";
/** From the first effect chosen until a frame carrying it is drawn. */
settling?: boolean;
/** The camera this provider's pipeline is synced to. */
cameraTrack?: SyncedCameraTrack;
}
export interface BackgroundEffectsOptions {
@@ -61,8 +54,6 @@ export interface BackgroundEffectsOptions {
setEffect: (id: EffectId) => void;
/** The backgrounds the device keeps, undefined until it has said. */
added$: Behavior<AddedBackground[] | undefined>;
/** Builds a segmenter on trial, and answers whether it could. */
canSegment: () => ObservableInput<boolean>;
/** The background processor pipeline to be switched between effects. */
pipeline: BackgroundProcessorWrapper;
/** Tells, once, that a frame carrying an effect has been drawn. */
@@ -82,19 +73,18 @@ export function createBackgroundEffects(
effect$,
setEffect,
added$,
canSegment,
pipeline,
transformer,
}: BackgroundEffectsOptions,
): BackgroundEffects {
// Wrapped once, so the call and the preview see one processor throughout.
const processor = fromLivekitProcessor(pipeline);
// The camera the pipeline is synced to.
const cameraTrack = new SyncedCameraTrack();
const choice$ = effect$.pipe(map(parseEffect));
const wanted$ = scope.behavior(
choice$.pipe(
map((effect) => effect.kind !== "none"),
distinctUntilChanged(),
),
const wanted$ = choice$.pipe(
map((effect) => effect.kind !== "none"),
distinctUntilChanged(),
);
combineLatest([choice$, added$])
@@ -110,18 +100,6 @@ export function createBackgroundEffects(
setEffect("none");
});
// Asked once an effect is first wanted. A browser update may fix what
// fails, so the choice is kept for the next session rather than cleared.
const buildable$ = scope.behavior<boolean | undefined>(
supported
? wanted$.pipe(
first(Boolean),
switchMap(() => from(canSegment())),
startWith(undefined),
)
: of(undefined),
);
const drewAFrame$ = scope.behavior(
new Observable<boolean>((subscriber) => {
subscriber.next(false);
@@ -132,25 +110,19 @@ export function createBackgroundEffects(
}),
);
// In one step, so that nothing sees the pipeline neither preparing nor
// attached.
const state$ = scope.behavior(
combineLatest([wanted$, buildable$, drewAFrame$]).pipe(
scan<[boolean, boolean | undefined, boolean], BackgroundEffectsState>(
(previous, [wanted, buildable, drewAFrame]) => {
combineLatest([wanted$, drewAFrame$]).pipe(
scan<[boolean, boolean], BackgroundEffectsState>(
(previous, [wanted, drewAFrame]) => {
// Attached the first time an effect is wanted and never detached
// after, so someone who never turns one on pays for none of it.
const enable =
previous.processor !== undefined || (wanted && buildable === true);
const preparing = supported && wanted && buildable === undefined;
previous.processor !== undefined || (supported && wanted);
return {
supported: supported && buildable !== false,
supported,
processor: enable ? processor : undefined,
wait: preparing
? "preparing"
: enable && !drewAFrame
? "settling"
: undefined,
settling: enable && !drewAFrame,
cameraTrack,
};
},
{ supported, processor: undefined },
@@ -12,11 +12,7 @@ import {
MatrixEvent,
type Room,
} from "matrix-js-sdk";
import {
RTC_CALL_INTENT_AUDIO,
RTC_CALL_INTENT_VIDEO,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
import { withTestScheduler } from "../../utils/test";
@@ -37,12 +33,10 @@ 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;
@@ -134,25 +128,7 @@ test("ring attempt times out after nobody joins", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
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: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
});
});
@@ -172,7 +148,7 @@ test("ring attempt is accepted once recipient joins", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "accept" },
});
});
@@ -190,7 +166,7 @@ test("ring attempt is immediately accepted if recipient is already joined", () =
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", {
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "accept" },
});
});
@@ -225,7 +201,7 @@ test("ring attempt can be declined", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "decline" },
});
});
@@ -260,7 +236,7 @@ test("ring attempt times out if recipient declines too late", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
});
});
@@ -295,7 +271,7 @@ test("decline event relating to wrong event is ignored (times out)", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
});
});
@@ -330,7 +306,7 @@ test("decline event from wrong sender is ignored (times out)", () => {
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
});
});
@@ -9,9 +9,7 @@ import {
type IRTCNotificationContent,
type MatrixRTCSession,
MatrixRTCSessionEvent,
RTC_CALL_INTENT_AUDIO,
type RTCCallIntent,
toStableCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import {
filter,
@@ -157,10 +155,7 @@ export function createCallNotificationLifecycle$({
);
return of({
// The notification still carries the legacy intent values
intent:
toStableCallIntent(notificationEvent["m.call.intent"]) ??
RTC_CALL_INTENT_AUDIO,
intent: notificationEvent["m.call.intent"] ?? "audio",
recipient,
outcome$: race(timeout$, accept$, decline$).pipe(
take(1),
+8 -96
View File
@@ -21,7 +21,6 @@ import {
type CallMembership,
type IRTCNotificationContent,
MatrixRTCSessionEvent,
RTC_CALL_INTENT_AUDIO,
} from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
@@ -71,11 +70,9 @@ import {
} from "./CallViewModel.ts";
import { type Handler, MuteState } from "../MuteStates.ts";
import {
type LocalVideoMediaTrack,
type RTCParticipation,
type VideoProcessor,
} from "@element-hq/matrixrtc-sdk";
import { type BackgroundEffectsState } from "../BackgroundEffects";
initializeWidget();
@@ -1560,7 +1557,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
b: expect.objectContaining({
type: "ringing",
userId: alice.userId,
intent: RTC_CALL_INTENT_AUDIO,
intent: "audio",
}),
});
// Layout should show placeholder media for the participant we're
@@ -1606,7 +1603,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
b: expect.objectContaining({
type: "ringing",
userId: alice.userId,
intent: RTC_CALL_INTENT_AUDIO,
intent: "audio",
}),
});
// Layout should show placeholder media for the participant we're
@@ -1695,113 +1692,28 @@ describe("a camera with a video processor", () => {
muteStates,
mockMediaDevices({}),
{},
{ supported: true, processor },
processor,
).find((request) => request.source === "camera"),
).toMatchObject({ processor });
});
it("leaves the camera out of the join while the processor is prepared", () => {
const muteStates = mockMuteStates();
vi.spyOn(muteStates.video.enabled$, "value", "get").mockReturnValue(true);
const sources = (wait?: "preparing"): string[] =>
initialPublishRequests(
muteStates,
mockMediaDevices({}),
{},
{
supported: true,
processor: undefined,
wait,
},
).map((request) => request.source);
expect(sources("preparing")).not.toContain("camera");
expect(sources()).toContain("camera");
});
it("waits to make a camera track until it is known the processor builds", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "preparing",
});
let participation: RTCParticipation | undefined;
withCallViewModel(
{},
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ backgroundEffectsState$: effects$ },
);
const camera = participation!.localMember$.value?.tracks$.value?.find(
(track) => track.source === "camera",
) as LocalVideoMediaTrack;
const turnedOn = cameraHandler(setHandler)(true);
await flushPromises();
expect(camera.setEnabled).not.toHaveBeenCalledWith(true);
effects$.next({ supported: true, processor });
await turnedOn;
expect(camera.setEnabled).toHaveBeenCalledWith(true);
expect(participation!.publish).not.toHaveBeenCalled();
});
it("turns the camera on only once its processor is on the track", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "preparing",
});
let participation: RTCParticipation | undefined;
withCallViewModel(
{},
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ backgroundEffectsState$: effects$ },
);
const camera = participation!.localMember$.value?.tracks$.value?.find(
(track) => track.source === "camera",
) as LocalVideoMediaTrack;
// Attaching takes its time, as the pipeline is built on the track
let attached!: () => void;
vi.mocked(camera.setProcessor).mockReturnValueOnce(
new Promise<void>((resolve) => {
attached = resolve;
}),
);
const turnedOn = cameraHandler(setHandler)(true);
effects$.next({ supported: true, processor });
await flushPromises();
expect(camera.setProcessor).toHaveBeenCalledWith(processor);
expect(camera.setEnabled).not.toHaveBeenCalledWith(true);
attached();
await turnedOn;
expect(camera.setEnabled).toHaveBeenCalledWith(true);
});
// The SDK builds the processor on the camera's restart, before the room
// sees a frame, so the track is unmuted rather than published anew
it("turns on a camera turned off before the processor was chosen", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
});
const videoProcessor$ = new BehaviorSubject<VideoProcessor | undefined>(
undefined,
);
let participation: RTCParticipation | undefined;
withCallViewModel(
{},
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ backgroundEffectsState$: effects$ },
{ videoProcessor$ },
);
effects$.next({ supported: true, processor });
videoProcessor$.next(processor);
await cameraHandler(setHandler)(true);
await flushPromises();
+8 -62
View File
@@ -37,7 +37,6 @@ import {
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
fromEvent,
map,
merge,
@@ -78,7 +77,6 @@ import {
type ReactionInfo,
type ReactionOption,
} from "../../reactions";
import { type BackgroundEffectsState } from "../BackgroundEffects";
import { shallowEquals as arrayShallowEquals } from "../../utils/array";
import { type MediaDevices } from "../MediaDevices";
import { type MuteStates } from "../MuteStates";
@@ -172,16 +170,13 @@ export function initialPublishRequests(
muteStates: MuteStates,
mediaDevices: MediaDevices,
capture: CaptureSettings,
effects?: BackgroundEffectsState,
processor?: VideoProcessor,
): PublishRequest[] {
const requests: PublishRequest[] = [];
if (muteStates.audio.enabled$.value)
requests.push(publishRequest("microphone", mediaDevices, capture));
// Left out while the processor is prepared; the call publishes it after
if (muteStates.video.enabled$.value && effects?.wait !== "preparing")
requests.push(
publishRequest("camera", mediaDevices, capture, effects?.processor),
);
if (muteStates.video.enabled$.value)
requests.push(publishRequest("camera", mediaDevices, capture, processor));
return requests;
}
@@ -192,8 +187,8 @@ export interface CallViewModelOptions {
* defaults otherwise.
*/
capture?: CaptureSettings;
/** The background effects state (preparing, supported...) including the processor itself. */
backgroundEffectsState$?: Behavior<BackgroundEffectsState>;
/** Background blur and the like, applied to the camera as it changes. */
videoProcessor$?: Behavior<VideoProcessor | undefined>;
/**
* The application hosting Element Call, which can ask it to hang up and wants
* to know when the user joins or leaves. Defaults to no host.
@@ -495,21 +490,7 @@ export function createCallViewModel$(
sentCallNotification$ = of(null),
} = options;
const {
capture = {},
backgroundEffectsState$ = constant<BackgroundEffectsState>({
supported: false,
processor: undefined,
}),
} = options;
const videoProcessor$ = scope.behavior(
backgroundEffectsState$.pipe(map(({ processor }) => processor)),
);
// Resolves once it is known whether the processor builds
const processorKnown = async (): Promise<unknown> =>
firstValueFrom(
backgroundEffectsState$.pipe(filter(({ wait }) => wait !== "preparing")),
);
const { capture = {}, videoProcessor$ = constant(undefined) } = options;
const localTracks$ = scope.behavior<
(LocalAudioMediaTrack | LocalVideoMediaTrack)[]
>(
@@ -533,19 +514,6 @@ export function createCallViewModel$(
"screenShare",
) as Behavior<LocalVideoMediaTrack | undefined>;
const processorAttached = followProcessor(
scope,
camera$,
videoProcessor$,
logger,
);
// A camera turned on waits for its processor to be known, then to be on
// the track: LiveKit restarts the track with whatever processor it has then.
const cameraReady = async (): Promise<void> => {
await processorKnown();
await processorAttached();
};
const supply = (source: "microphone" | "camera"): TrackSupply => ({
publish: async () =>
rtcParticipation.publish(
@@ -556,7 +524,6 @@ export function createCallViewModel$(
source === "camera" ? videoProcessor$.value : undefined,
),
),
ready: source === "camera" ? cameraReady : undefined,
});
followMuteState(
scope,
@@ -583,29 +550,6 @@ export function createCallViewModel$(
);
followDevice(scope, camera$, mediaDevices.videoInput, "camera", logger);
// A camera wanted at the join was left out of it while the processor was
// prepared (see initialPublishRequests), and is published once that is known
if (
backgroundEffectsState$.value.wait === "preparing" &&
muteStates.video.enabled$.value
)
processorKnown()
.then(async () => {
if (camera$.value !== undefined || !muteStates.video.enabled$.value)
return;
await rtcParticipation.publish(
publishRequest(
"camera",
mediaDevices,
capture,
videoProcessor$.value,
),
);
})
.catch((e) => {
logger.error("Failed to publish the camera", e);
});
// The output device follows the selection onto the client the same way. A
// host that routes audio itself picks the output outside the browser, so the
// client is left alone
@@ -619,6 +563,8 @@ export function createCallViewModel$(
});
});
followProcessor(scope, camera$, videoProcessor$, logger);
const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>(
combineLatest(
[rtcParticipation.localMember$, rtcParticipation.remoteMembers$],

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