Compare commits

..
Author SHA1 Message Date
Timo K.andClaude Opus 5.5 ae12c5fbd8 Run the media backend inside a combination of backends
The participation now builds its default backend by combining every backend
the SDK has, publishing on the type of the transport the host names (LiveKit
without one) and handing each remote member to the backend of its transport.
LiveKit is the only member of the combination for now. A backend serves a
list of transport types, so the combination routes every type of every
backend.

The LiveKit backend can run without a local transport, so it subscribes only
while another backend publishes, and probes the homeserver for delayed leave
delegation only when it first publishes. The combination tells the backends
that don't publish, with a null transport, and until a backend knows either
way it connects nowhere: a subscriber connection to the transport it is
about to publish on would only be replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 14:04:26 +01:00
129 changed files with 1823 additions and 4081 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
+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
-4
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
+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"],
-100
View File
@@ -106,66 +106,6 @@ async function drawsPerSecond(page: Page): Promise<number> {
});
}
test.describe("the camera menu's preview", () => {
test("shows the camera with the effect in force", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Preview user", "Preview in the menu");
const camera = await cameraColour(page.locator("video").first());
await page.getByRole("button", { name: "Camera", exact: true }).click();
const preview = page.getByRole("menu").locator("video");
await expect(preview).toBeVisible();
await expect
.poll(async () => distance(await averageColour(preview), camera), {
timeout: 20_000,
})
.toBeLessThan(30);
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
const picture = await averageColour(tile.locator("img"));
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
});
test("shows the call's own camera once joined", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(
page,
"Call preview user",
"Preview in the call's menu",
true,
);
await expect(page.getByTestId("incall_videomute")).toBeEnabled({
timeout: 10_000,
});
const camera = await cameraColour(page.locator("video").first());
await page.getByRole("button", { name: "Camera", exact: true }).click();
const preview = page.getByRole("menu").locator("video");
await expect(preview).toBeVisible();
await expect
.poll(async () => distance(await averageColour(preview), camera), {
timeout: 20_000,
})
.toBeLessThan(30);
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
const picture = await averageColour(tile.locator("img"));
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
});
});
test.describe("background effects section", () => {
test("section stays within the call area at every size", async ({ page }) => {
test.setTimeout(120_000);
@@ -211,46 +151,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);
+2 -3
View File
@@ -127,14 +127,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);
},
);
}
+1 -5
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(),
+9 -16
View File
@@ -191,8 +191,8 @@ importers:
specifier: ^1.9.1
version: 1.9.2
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#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
@@ -4734,8 +4734,8 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/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'}
@@ -4946,8 +4946,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 +5318,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:
@@ -10413,7 +10409,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 +10419,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
@@ -10746,7 +10741,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 +11165,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
+8 -4
View File
@@ -66,9 +66,13 @@ media backend: whatever carries the media, behind the `MediaBackend` interface i
`sdk/src/media-backend/api.ts`. The client is the join of the two: a member exists
once its membership does, and gets its media once the backend has something
for that membership on its transport; `state$` is the membership state combined
with the local media connection. LiveKit is the one backend today; a
cascading SFU or full mesh is another folder under `sdk/src/media-backend/`, handed
in through `options.backend`, and nothing in the left box changes.
with the local media connection. LiveKit (`livekit/`) is the one backend today,
and the client runs it inside a combination (`combineBackends.ts`). It publishes
on the backend of the type of `options.transport`, LiveKit without one, and
hands every remote member to the backend of that member's transport. A backend
nobody is on connects nowhere. A cascading SFU or full mesh is another folder
under `sdk/src/media-backend/`, added to the combination, and nothing in the
left box changes; `options.backend` replaces the combination altogether.
### How Element Call uses it
@@ -154,7 +158,7 @@ the sections below still describe `MatrixRTCClient` until that lands.
| publish request | a `PublishRequest`: a source, where to capture it from and how to encode it. Given at the join in `publish`, or later to `publish` on the participation | |
| attach, detach | handing a `<video>` or `<audio>` element to a track and taking it back. The SDK owns the stream and the observers on the element | LiveKit `Track.attach` |
| transport | an `RTCTransport`: where media is exchanged, a LiveKit service url today. Carries the raw js-sdk `Transport` from the membership and, in `resolved$`, what the backend fetched to connect to it | MSC4143 "focus" |
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation, chosen with `backend` in the join options; LiveKit under `sdk/src/media-backend/livekit/` is the one that exists | "SFU", a LiveKit room, a peer connection |
| media backend | a `MediaBackend`: what carries the media, behind `sdk/src/media-backend/api.ts`. One per participation: the SDK's backends combined, publishing on the type of `transport` in the join options, unless `backend` replaces them | "SFU", a LiveKit room, a peer connection |
Names use `RTC` in capitals, as the js-sdk does: `RTCSlot`, `RTCParticipation`,
`RTCMember`, `matrixRTCMode`.
-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();
};
+12 -4
View File
@@ -50,6 +50,7 @@ import {
type MediaBackendContext,
type MediaKey,
} from "./media-backend/api";
import { combineBackends } from "./media-backend/combineBackends";
import { createLivekitBackend } from "./media-backend/livekit/LivekitBackend";
import { findRemotePublishLivekitTransport } from "./media-backend/livekit/RemotePublishTransport";
import { createHomeserverConnected$ } from "./matrixrtc/HomeserverConnected";
@@ -184,7 +185,6 @@ export function createRTCParticipation(
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
matrixRTCMode,
sendNotificationType: options.sendNotificationType,
callIntent: options.callIntent,
applicationData: options.applicationData,
timings,
delayedLeave,
@@ -312,15 +312,23 @@ export function createRTCParticipation(
};
}
/** The host's backend, or LiveKit configured from the participation options. */
/**
* The host's backend, or every backend the SDK has, combined: we publish on
* the type of the transport the host named (LiveKit without one) and receive
* from members on any of them.
*/
function createBackend(
scope: ObservableScope,
client: MatrixClient,
{ backend, mediaQuality }: RTCParticipationOptions,
{ backend, mediaQuality, transport }: RTCParticipationOptions,
context: MediaBackendContext,
): MediaBackend {
if (backend) return backend(scope, context);
return createLivekitBackend(scope, context, { client, mediaQuality });
return combineBackends(
scope,
[createLivekitBackend(scope, context, { client, mediaQuality })],
transport?.type ?? "m.livekit",
);
}
/** The slot's per-participant keys: the ones it already holds on subscribe, then each new one. */
+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,
+8 -1
View File
@@ -125,8 +125,15 @@ export interface MediaBackend {
* session found it; the backend does whatever has to succeed before it is
* advertised in the membership. Rejects with the error the session reports
* as fatal. Called once per session.
*
* `null` tells the backend that the local member publishes on another
* backend: it only subscribes from then on. Until either call, a backend
* that would connect for its publisher connects nowhere, so that it doesn't
* subscribe to a transport it is about to publish on.
*/
prepareLocalTransport(transport: Transport): Promise<TransportCapabilities>;
prepareLocalTransport(
transport: Transport | null,
): Promise<TransportCapabilities>;
/**
* Hands the delayed leave event to the backend, which restarts it from
@@ -0,0 +1,103 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject, NEVER } from "rxjs";
import { type RTCMember, type TransportMetadata } from "../api";
import { type MemberMedia } from "../media-api";
import { constant } from "../reactive/Behavior";
import { ObservableScope } from "../reactive/ObservableScope";
import { type MediaBackend } from "./api";
import { combineBackends } from "./combineBackends";
function fakeBackend(
type: string,
...moreTypes: string[]
): MediaBackend & { ended: string[] } {
const ended: string[] = [];
return {
ended,
transportTypes: [type, ...moreTypes],
prepareLocalTransport: vi.fn(async () =>
Promise.resolve({ canDelegateDelayedLeave: false }),
),
delegateDelayedLeave: vi.fn(),
local: {} as MediaBackend["local"],
mediaFor: (scope, member): MemberMedia => {
scope.onEnd(() => ended.push(member.memberId));
return {
tracks$: constant([{ id: `${type}:${member.memberId}` }] as never),
encryptionError$: NEVER,
};
},
connections$: constant([
{ transport: { type }, state: "connected" as never, resolved: undefined },
]),
setAudioOutputDeviceId: vi.fn(),
sendData: vi.fn(),
data$: NEVER,
};
}
function transport(type: string): TransportMetadata {
return { type, id: type, raw: { type }, resolved$: constant(undefined) };
}
describe("combineBackends", () => {
it("routes each member to the backend of its transport type", () => {
const scope = new ObservableScope();
const livekit = fakeBackend("m.livekit", "livekit");
const other = fakeBackend("other");
const combined = combineBackends(scope, [livekit, other], "livekit");
expect(combined.transportTypes).toEqual(["m.livekit", "livekit"]);
expect(combined.connections$.value).toHaveLength(2);
const transport$ = new BehaviorSubject<TransportMetadata | undefined>(
transport("other"),
);
const memberScope = new ObservableScope();
const media = combined.mediaFor(memberScope, {
memberId: "m1",
transport$,
} as unknown as RTCMember);
expect(media.tracks$.value).toEqual([{ id: "other:m1" }]);
// Either type of the publishing backend stays with it
transport$.next(transport("livekit"));
expect(other.ended).toEqual(["m1"]);
expect(media.tracks$.value).toEqual([{ id: "m.livekit:m1" }]);
transport$.next(transport("m.livekit"));
expect(media.tracks$.value).toEqual([{ id: "m.livekit:m1" }]);
transport$.next(transport("unknown"));
expect(media.tracks$.value).toBeNull();
// The publishing backend keeps every member for as long as it is one
expect(livekit.ended).toEqual([]);
memberScope.end();
expect(livekit.ended).toEqual(["m1"]);
scope.end();
});
it("publishes on the backend of the publish type, telling the others", async () => {
const scope = new ObservableScope();
const livekit = fakeBackend("livekit");
const other = fakeBackend("other");
const combined = combineBackends(scope, [livekit, other], "other");
expect(combined.transportTypes).toEqual(["other"]);
const transport = { type: "other" };
await combined.prepareLocalTransport(transport);
expect(other.prepareLocalTransport).toHaveBeenCalledExactlyOnceWith(
transport,
);
expect(livekit.prepareLocalTransport).toHaveBeenCalledExactlyOnceWith(null);
expect(() => combineBackends(scope, [livekit, other], "unknown")).toThrow(
"No media backend for transport unknown",
);
scope.end();
});
});
+90
View File
@@ -0,0 +1,90 @@
/*
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 { combineLatest, map, merge, NEVER, of, switchMap } from "rxjs";
import { type RTCMember } from "../api";
import { MatrixRTCError } from "../errors";
import { type MemberMedia } from "../media-api";
import { type ObservableScope } from "../reactive/ObservableScope";
import { mapScoped } from "../utils/mapScoped";
import { type MediaBackend } from "./api";
/**
* Several backends as one: we publish on the backend of `publishType`, and
* receive from every member on a transport any of the backends serves. A
* member is handed to the backend of its current transport type. A backend
* nobody is on costs nothing: it connects only for the members handed to it.
*/
export function combineBackends(
scope: ObservableScope,
all: MediaBackend[],
publishType: string,
): MediaBackend {
const byType = new Map(
all.flatMap((b) => b.transportTypes.map((type) => [type, b] as const)),
);
const publish = byType.get(publishType);
if (publish === undefined)
throw new MatrixRTCError(`No media backend for transport ${publishType}`);
const mediaFor = (
memberScope: ObservableScope,
member: RTCMember,
): MemberMedia => {
// The publishing backend sees every member, whatever its transport, so
// that it can tell members from strangers when a backend is asked for our
// media by members on other transports. It connects nowhere for them
const publishMedia = publish.mediaFor(memberScope, member);
const type$ = memberScope.behavior(
member.transport$.pipe(map((transport) => transport?.type)),
);
const media$ = mapScoped(memberScope, type$, (s, type) =>
type !== undefined && publish.transportTypes.includes(type)
? publishMedia
: byType.get(type)?.mediaFor(s, member),
);
return {
tracks$: memberScope.behavior(
media$.pipe(switchMap((media) => media?.tracks$ ?? of(null))),
),
encryptionError$: media$.pipe(
switchMap((media) => media?.encryptionError$ ?? NEVER),
),
};
};
return {
transportTypes: publish.transportTypes,
// The others learn that they only subscribe
prepareLocalTransport: async (transport) => {
const [capabilities] = await Promise.all([
publish.prepareLocalTransport(transport),
...all
.filter((b) => b !== publish)
.map(async (b) => b.prepareLocalTransport(null)),
]);
return capabilities;
},
delegateDelayedLeave: async (delayId) =>
publish.delegateDelayedLeave(delayId),
local: publish.local,
mediaFor,
connections$: scope.behavior(
combineLatest(all.map((b) => b.connections$)).pipe(
map((lists) => lists.flat()),
),
),
setAudioOutputDeviceId: async (deviceId) => {
await Promise.all(
all.map(async (b) => b.setAudioOutputDeviceId(deviceId)),
);
},
sendData: async (topic, text) => publish.sendData(topic, text),
data$: merge(...all.map((b) => b.data$)),
};
}
+11 -4
View File
@@ -108,8 +108,11 @@ export class ConnectionMap {
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
/** The transport we publish on; the one connection with the publisher role. */
localTransport: TransportLocator;
/**
* The transport we publish on; the one connection with the publisher role.
* Null for a backend that only subscribes, while another backend publishes.
*/
localTransport: TransportLocator | null;
/** Every transport some member is on, deduplicated against the local one. */
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
logger: Logger;
@@ -135,7 +138,11 @@ export function createConnectionMap$({
scope.behavior(
remoteTransports$.pipe(
mapEpoch((remoteTransports) =>
removeDuplicateTransports([localTransport, ...remoteTransports]),
removeDuplicateTransports(
localTransport
? [localTransport, ...remoteTransports]
: remoteTransports,
),
),
tap((transports) =>
logger.debug("localAndRemoteTransports$ = ", transports),
@@ -143,7 +150,7 @@ export function createConnectionMap$({
),
);
const localTransportKey = keyFor(localTransport);
const localTransportKey = localTransport && keyFor(localTransport);
const connections$ = scope.behavior(
localAndRemoteTransports$.pipe(
@@ -475,6 +475,38 @@ describe("createLivekitBackend", () => {
]);
});
it("connects nowhere until it knows whether it publishes", async () => {
const b = backend();
const media = b.mediaFor(testScope(), bob(bothTransport, [legacyId]));
await flushPromises();
expect(media.tracks$.value).toBeNull();
// A subscriber connection now would be replaced by the publisher one
expect(connections).toHaveLength(0);
fetchMock.post(homeserverEndpoint, 404);
fetchMock.post(`${exampleServiceUrl}/delegate_delayed_leave`, 404);
await b.prepareLocalTransport(bothTransport);
await flushPromises();
expect(connections.map((c) => c.connection.role)).toEqual(["publisher"]);
});
it("only subscribes once told that another backend publishes", async () => {
const b = backend();
b.mediaFor(testScope(), bob(bothTransport, [legacyId]));
await flushPromises();
expect(connections).toHaveLength(0);
await expect(b.prepareLocalTransport(null)).resolves.toEqual({
canDelegateDelayedLeave: false,
});
await flushPromises();
expect(connections.map((c) => c.connection.role)).toEqual(["subscriber"]);
expect(connections[0].connection.transport).toEqual(bothTransport);
await expect(b.delegateDelayedLeave("id")).rejects.toThrow(
"No local transport",
);
});
it("has no media for a member without a transport, and opens no connection", async () => {
const b = await prepared();
const media = b.mediaFor(testScope(), {
+37 -13
View File
@@ -106,7 +106,11 @@ export function createLivekitBackend(
: [requestsOrMedia.requests, requestsOrMedia];
const keyProvider = createKeyProvider(encryptionSystem, mediaKeys$, logger);
const transports = createTransportRegistry(scope);
const localTransport$ = new BehaviorSubject<TransportLocator | null>(null);
// Undefined until the session says whether we publish here: null where
// another backend publishes
const localTransport$ = new BehaviorSubject<
TransportLocator | null | undefined
>(undefined);
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
undefined,
);
@@ -121,9 +125,20 @@ export function createLivekitBackend(
);
// A new map, in a scope of its own, whenever the local transport changes:
// the publisher role sits on one connection, and which one is fixed per map
// the publisher role sits on one connection, and which one is fixed per map.
// Without a local transport the map only subscribes, for when another
// backend publishes; boxed so that `mapScoped` builds a map for null too.
// None at all while it isn't known yet: a subscriber connection to the
// transport we are about to publish on would only be torn down again
const boxedLocalTransport$ = scope.behavior(
localTransport$.pipe(
map((localTransport) =>
localTransport === undefined ? undefined : { localTransport },
),
),
);
const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
mapScoped(scope, localTransport$, (mapScope, localTransport) =>
mapScoped(scope, boxedLocalTransport$, (mapScope, { localTransport }) =>
createConnectionMap$({
scope: mapScope,
connectionFactory,
@@ -138,7 +153,9 @@ export function createLivekitBackend(
const localMedia_ = createLivekitLocalMedia({
scope,
connectionMap$,
localTransport$,
localTransport$: scope.behavior(
localTransport$.pipe(map((localTransport) => localTransport ?? null)),
),
publish,
media,
audioOutputDeviceId$,
@@ -146,18 +163,25 @@ export function createLivekitBackend(
logger,
});
// Probed once: the answer cannot change within a session
const homeserverSupportsDelegation = supportsDelegation(
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
"homeserver",
logger,
);
// Probed once, when first needed: the answer cannot change within a
// session, and a backend that only subscribes never needs it
let homeserverProbe: Promise<boolean> | undefined;
const homeserverSupportsDelegation = async (): Promise<boolean> =>
(homeserverProbe ??= supportsDelegation(
`${client.baseUrl}/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`,
"homeserver",
logger,
));
const ownServerName = serverNameOf(ownMembershipIdentity.userId);
const prepareLocalTransport = async (
transport: Transport,
transport: Transport | null,
): Promise<TransportCapabilities> => {
if (transport === null) {
localTransport$.next(null);
return { canDelegateDelayedLeave: false };
}
if (!isUnstableLivekitTransport(transport))
throw new MatrixRTCError(`Not a LiveKit transport: ${transport.type}`);
const serverName = serverNameOfTransport(
@@ -182,7 +206,7 @@ export function createLivekitBackend(
`transport ${flow.serviceUrl}`,
logger,
)
: homeserverSupportsDelegation;
: homeserverSupportsDelegation();
}
localTransport$.next(locator);
// The membership is only sent once the publisher connection holds its
@@ -198,7 +222,7 @@ export function createLivekitBackend(
const delegateLeave = async (delayId: string): Promise<void> => {
const local = localTransport$.value;
if (local === null)
if (!local)
throw new MatrixRTCError("No local transport to delegate the leave to");
await delegateDelayedLeave({
client,
+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 };
}
-1
View File
@@ -141,7 +141,6 @@ export const Default: Story = {
backgroundImageRefusal: undefined,
addBackgroundImage: fn(),
removeBackgroundEffect: fn(),
cameraTrack: null,
hangup: fn(),
buttonSize: "lg",
showFooter: true,
-6
View File
@@ -33,9 +33,7 @@ import { useBehavior } from "../useBehavior";
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
import { LayoutSwitch } from "../room/LayoutSwitch";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
import { SelfPreview } from "./SelfPreview";
import { type UnusableReason } from "../tracks/backgroundImages";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
export interface BackgroundImageRefusal {
reason: UnusableReason | "not-kept";
@@ -94,8 +92,6 @@ export interface FooterState {
backgroundEffectSettling: boolean;
/** Why the last file offered couldn't be kept, if it couldn't. */
backgroundImageRefusal: BackgroundImageRefusal | undefined;
/** The camera to preview, with the effect in force; null while it is off. */
cameraTrack: LocalVideoMediaTrack | null;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
@@ -185,7 +181,6 @@ export const CallFooter: FC<FooterProps> = ({
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
const cameraTrack = useBehavior(vm.cameraTrack$);
const refusal = useBehavior(vm.backgroundImageRefusal$);
const refusalMessage = useMemo(() => {
switch (refusal?.reason) {
@@ -269,7 +264,6 @@ export const CallFooter: FC<FooterProps> = ({
backgroundEffectSettling={backgroundEffectSettling}
onAddBackgroundImage={addBackgroundImage}
onRemoveBackgroundEffect={removeBackgroundEffect}
selfPreview={<SelfPreview track={cameraTrack} />}
backgroundImageRefusal={refusalMessage}
backgroundEffectNotice={
backgroundEffectNotice === "unavailable"
+10 -94
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";
@@ -81,7 +78,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 => {},
@@ -113,16 +109,6 @@ const pipLayout: Layout = {
spotlight: {} as SpotlightTileViewModel,
};
const noPipeline = constant<BackgroundEffectsState>({
supported: false,
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(
@@ -154,7 +140,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
constant(false),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -197,7 +183,7 @@ describe("createCallFooterViewModel", () => {
select: vi.fn(),
},
}),
noPipeline,
constant(false),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -216,7 +202,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(layout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
constant(false),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -239,7 +225,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
constant(false),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -285,8 +271,7 @@ describe("createCallFooterViewModel", () => {
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipelineAsTheSdkSays(),
/* camera$ */ constant(null),
constant(false),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
@@ -371,52 +356,21 @@ describe("createCallFooterViewModel", () => {
it("passes on the wait for the first effect", () => {
platformMock.mockReturnValue("desktop");
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "settling",
});
const settling$ = new BehaviorSubject(true);
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipeline$,
/* camera$ */ constant(null),
settling$,
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.backgroundEffectSettling$.value).toBe(true);
pipeline$.next({ ...pipeline$.value, wait: undefined });
settling$.next(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 +427,6 @@ 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);
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
camera$,
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.cameraTrack$.value).toBe(track);
// The lobby releases the camera when it is turned off
camera$.next(null);
expect(vm.cameraTrack$.value).toBeNull();
});
it("previews the call's camera only while it is not muted", () => {
const muted$ = new BehaviorSubject(false);
const track = mockVideoTrack({ muted$ });
const vm = createCallFooterViewModel(
testScope(),
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.cameraTrack$.value).toBe(track);
muted$.next(true);
expect(vm.cameraTrack$.value).toBeNull();
muted$.next(false);
expect(vm.cameraTrack$.value).toBe(track);
});
it("availability is the same before and during a call", () => {
for (const supported of [true, false]) {
sdkSupportMock.mockReturnValue(supported);
@@ -520,7 +436,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipelineAsTheSdkSays(),
constant(false),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
+19 -46
View File
@@ -11,12 +11,13 @@ import {
distinctUntilChanged,
map,
type Observable,
of,
switchMap,
} from "rxjs";
import { supportsAudioOutputSelection } from "livekit-client";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
import {
supportsBackgroundProcessors,
supportsModernBackgroundProcessors,
} from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import {
@@ -32,7 +33,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 {
@@ -87,9 +87,7 @@ function buildDeviceBehaviors(
mediaDevices: MediaDevices,
/** return empty arrays for audioOptions and videoOptions*/
disableSwitcher$: Behavior<boolean>,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
/** The camera as the pipeline has it, for the menu's preview. */
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
backgroundEffectSettling$: Observable<boolean>,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions$"
@@ -109,7 +107,6 @@ function buildDeviceBehaviors(
| "addBackgroundImage$"
| "backgroundImageRefusal$"
| "removeBackgroundEffect$"
| "cameraTrack$"
> {
const options$ = (
available$: Behavior<Map<string, MenuOptions["label"]>>,
@@ -126,14 +123,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 +157,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(
@@ -195,10 +186,7 @@ function buildDeviceBehaviors(
),
),
backgroundEffectSettling$: scope.behavior(
backgroundEffectsState$.pipe(
map(({ wait }) => wait !== undefined),
distinctUntilChanged(),
),
backgroundEffectSettling$.pipe(distinctUntilChanged()),
false,
),
// Kept and offered, not put on: that waits for the user to choose it.
@@ -220,17 +208,6 @@ 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),
),
),
null,
),
// Never the one in force: taking it away would leave nothing chosen.
removeBackgroundEffect$: scope.behavior(
offered$.pipe(
@@ -260,8 +237,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 backgroundEffectSettling$ - Whether the first effect chosen is still
* being prepared.
* @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.
@@ -272,7 +249,7 @@ export function createCallFooterViewModel(
callModel: CallViewModel,
muteStates: MuteStates,
mediaDevices: MediaDevices,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
backgroundEffectSettling$: Observable<boolean>,
reactionIdentifier: string | undefined,
options: { showControls: boolean; header: HeaderStyle },
): ViewModel<FooterSnapshot> {
@@ -291,8 +268,7 @@ export function createCallFooterViewModel(
scope,
mediaDevices,
disableDeviceSwitcher$,
backgroundEffectsState$,
callModel.camera$,
backgroundEffectSettling$,
),
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
@@ -355,9 +331,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 backgroundEffectSettling$ - Whether the first effect chosen is still
* being prepared.
* @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.
@@ -366,8 +341,7 @@ export function createLobbyFooterViewModel(
scope: ObservableScope,
muteStates: MuteStates,
mediaDevices: MediaDevices,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
camera$: Behavior<LocalVideoMediaTrack | null>,
backgroundEffectSettling$: Observable<boolean>,
openSettings: (() => void) | undefined,
hangup: (() => void) | undefined,
showLogo: boolean,
@@ -415,8 +389,7 @@ export function createLobbyFooterViewModel(
scope,
mediaDevices,
constant(false),
backgroundEffectsState$,
camera$,
backgroundEffectSettling$,
),
};
}
@@ -97,35 +97,3 @@ Please see LICENSE in the repository root for full details.
.refusal {
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
}
.selfPreview {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
aspect-ratio: 16 / 9;
margin-block-end: var(--cpd-space-2x);
overflow: clip;
border-start-start-radius: var(--cpd-space-3x);
border-start-end-radius: var(--cpd-space-3x);
background: var(--cpd-color-bg-subtle-secondary);
color: var(--cpd-color-icon-secondary);
}
.selfPreview > video {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
/* Rounded itself too: a video can be drawn on a layer of its own, which the
frame's rounded clip doesn't always reach. */
border-radius: inherit;
}
/* Up to the menu's frame, over the padding at its top. */
.selfPreviewPinned,
.deviceListLeadsWithPreview {
margin-block-start: calc(-1 * var(--cpd-space-5x));
}
@@ -12,7 +12,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import styles from "./MediaMuteAndSwitchButton.module.css";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
import { SelfPreview } from "./SelfPreview";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
import meterStyles from "./MicrophoneLevelMeter.module.css";
import { MediaDevicesContext } from "../MediaDevicesContext";
@@ -61,50 +60,6 @@ const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
return <>{children}</>;
};
/** A camera for the preview: a figure against a room, drawn to a stream. */
const FakeCamera: FC = () => {
const [stream, setStream] = useState<MediaStream>();
useEffect(() => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const context = canvas.getContext("2d")!;
const draw = (): void => {
context.fillStyle = "#6b7f8e";
context.fillRect(0, 0, 640, 360);
context.fillStyle = "#c9a27e";
context.beginPath();
context.arc(320, 150, 62, 0, 2 * Math.PI);
context.fill();
context.fillStyle = "#34495e";
context.beginPath();
context.ellipse(320, 380, 150, 140, 0, Math.PI, 2 * Math.PI);
context.fill();
};
draw();
// Redrawn, so the stream keeps delivering frames.
const timer = setInterval(draw, 200);
const captured = canvas.captureStream(10);
setStream(captured);
return (): void => {
clearInterval(timer);
captured.getTracks().forEach((track) => track.stop());
};
}, []);
return (
<video
data-testid="preview-camera"
autoPlay
muted
playsInline
ref={(video): void => {
if (video && stream && video.srcObject !== stream)
video.srcObject = stream;
}}
/>
);
};
/** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
const WithACallArea: FC<{ children: ReactNode; blockSize?: number }> = ({
children,
@@ -216,8 +171,8 @@ export const VideoMute: Story = {
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedOption: "1",
selfPreview: <SelfPreview track={null} />,
},
};
@@ -244,8 +199,6 @@ export const VideoUnmute: Story = {
selectedBackgroundEffect: "none",
onSelectBackgroundEffect: fn(),
onAddBackgroundImage: fn(),
// As the footer always gives the camera menu one.
selfPreview: <FakeCamera />,
},
};
@@ -391,9 +344,6 @@ export const BackgroundEffectsSettling: Story = {
checked: false,
}))
await expect(other).toHaveAttribute("aria-busy", "false");
await expect(menu.queryByTestId("preview-camera")).toBeNull();
const frames = document.body.querySelectorAll(`.${styles.selfPreview} svg`);
await expect(frames).toHaveLength(1);
},
};
@@ -551,113 +501,6 @@ export const RemovingWithLiveSelection: Story = {
},
};
/** Where the call leaves room, the camera holds the top while the options scroll. */
export const BackgroundEffectsWithPreview: Story = {
args: {
...VideoUnmute.args,
// Enough that the list fills its share, so the preview's cost counts.
options: Array.from({ length: 8 }, (_, i) => ({
label: { type: "name" as const, name: `Camera ${i + 1}` },
id: `${i + 1}`,
})),
},
decorators: [
(Story): JSX.Element => (
<WithACallArea blockSize={640}>
<Story />
</WithACallArea>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const picture = await within(document.body).findByTestId("preview-camera");
const preview = picture.parentElement!;
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
const box = preview.getBoundingClientRect();
await expect(list.contains(preview)).toBe(false);
await expect(box.top - frame.top).toBeLessThanOrEqual(2);
await expect(frame.width - box.width).toBeLessThanOrEqual(2);
await expect(box.width / box.height).toBeCloseTo(16 / 9, 1);
await expect(box.bottom).toBeLessThanOrEqual(
list.getBoundingClientRect().top,
);
const listShare = Math.round(640 * 0.6);
// The list runs on through the menu's padding at its foot, which isn't
// part of its share.
const listEnd =
list.getBoundingClientRect().bottom -
Number.parseFloat(getComputedStyle(list).paddingBlockEnd);
await expect(listEnd - box.top).toBeLessThanOrEqual(listShare + 1);
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
list.scrollTop = list.scrollHeight;
await expect(preview.getBoundingClientRect().top).toBe(box.top);
},
};
/** With the camera off the preview says so, and effects can still be chosen. */
export const BackgroundEffectsPreviewWithTheCameraOff: Story = {
args: {
...VideoUnmute.args,
enabled: false,
selfPreview: <SelfPreview track={null} />,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const menu = within(document.body);
await menu.findByRole("group", { name: "Background effects" });
const preview = document.body.querySelector(`.${styles.selfPreview}`)!;
await expect(preview.querySelector("video")).toBeNull();
await expect(preview.querySelector("svg")).not.toBeNull();
await userEvent.click(menu.getByRole("menuitemradio", { name: "Blur" }));
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
},
};
/** In a call too short to hold it, the camera leads the options and scrolls away. */
export const BackgroundEffectsPreviewScrollsWithTheList: Story = {
args: VideoUnmute.args,
decorators: [
(Story): JSX.Element => (
<WithACallArea blockSize={300}>
<Story />
</WithACallArea>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const picture = await within(document.body).findByTestId("preview-camera");
const preview = picture.parentElement!;
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
await expect(list.contains(preview)).toBe(true);
const cameras = within(document.body).getByRole("group", {
name: "Camera",
});
await expect(
preview.compareDocumentPosition(cameras) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
list.scrollTop = list.scrollHeight;
await expect(preview.getBoundingClientRect().bottom).toBeLessThan(
list.getBoundingClientRect().top,
);
},
};
/** In a short call the effects scroll into view with the list. */
export const BackgroundEffectsScrollWhenTheyDoNotFit: Story = {
args: {
+6 -54
View File
@@ -14,12 +14,10 @@ import {
type FC,
useEffect,
type ReactElement,
type ReactNode,
} from "react";
import {
Alert,
Button,
InlineSpinner,
Menu,
MenuItem,
MenuScrollArea,
@@ -89,8 +87,6 @@ export interface MediaMuteAndSwitchButtonProps {
onRemoveBackgroundEffect?: (id: string) => void;
/** Why the last file offered couldn't be used; a new object each time. */
backgroundImageRefusal?: { text: string };
/** The user's own camera, shown at the top of the camera menu. */
selfPreview?: ReactNode;
/**
* For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
@@ -107,16 +103,9 @@ const LIST_SHARE_OF_CALL = 0.6;
/** Smallest device list height in px, so a short call still shows more than one device. */
const MIN_LIST_HEIGHT = 160;
/** The menu's width as its stylesheet sets it, to place the preview before
the menu is drawn. */
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,
@@ -135,7 +124,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
onAddBackgroundImage,
onRemoveBackgroundEffect,
backgroundImageRefusal,
selfPreview,
onSelect,
}) => {
// Requested but not yet selected. Keyed by kind too, since Chrome uses
@@ -214,53 +202,21 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
// Measured on the call area: CSS can't size the portalled menu against it.
const rootElement = useRootElement();
const [listShare, setListShare] = useState<number>();
const [previewBlock, setPreviewBlock] = useState(0);
const [listMaxHeight, setListMaxHeight] = useState<number>();
useEffect(() => {
if (!menuOpen) return;
// Followed, since a host can resize the call while the menu is open.
const subscription = observeElementSize$(rootElement)
.pipe(
map(({ width, height }) => ({
height: Math.round(height * LIST_SHARE_OF_CALL),
width: Math.min(MENU_WIDTH, Math.round(width - 2 * MENU_MARGIN)),
})),
distinctUntilChanged(
(a, b) => a.height === b.height && a.width === b.width,
map(({ height }) =>
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
),
distinctUntilChanged(),
)
.subscribe(({ height, width }) => {
setListShare(height);
setPreviewBlock(Math.round((width * 9) / 16) + PREVIEW_GAP);
});
.subscribe(setListMaxHeight);
return (): void => subscription.unsubscribe();
}, [menuOpen, rootElement]);
// Paid for out of the list's share, so a preview makes the menu no taller.
const hasPreview =
iconsAndLabels === "video" &&
selfPreview !== undefined &&
listShare !== undefined;
const previewPinned =
hasPreview && listShare - previewBlock >= MIN_LIST_HEIGHT;
const listMaxHeight =
listShare === undefined
? undefined
: Math.max(
MIN_LIST_HEIGHT,
listShare - (previewPinned ? previewBlock : 0),
);
const preview = hasPreview && (
<div
aria-hidden
className={classNames(styles.selfPreview, {
[styles.selfPreviewPinned]: previewPinned,
})}
>
{backgroundEffectSettling ? <InlineSpinner size={32} /> : selfPreview}
</div>
);
useEffect(() => {
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]);
@@ -451,12 +407,9 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/>
}
>
{previewPinned && preview}
<MenuScrollArea
ref={trackFocusSource}
className={classNames(styles.deviceList, {
[styles.deviceListLeadsWithPreview]: hasPreview && !previewPinned,
})}
className={styles.deviceList}
data-testid="device-list"
style={
{
@@ -465,7 +418,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
} as CSSProperties
}
>
{hasPreview && !previewPinned && preview}
{audioMeter && speakerOptions && (
<MenuScrollSection title={t("settings.devices.speaker")}>
{deviceItems(
-10
View File
@@ -1,10 +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.
*/
.mirrored {
transform: scaleX(-1);
}
-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();
});
});
-48
View File
@@ -1,48 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useEffect, useRef } from "react";
import { constant, type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
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);
interface Props {
/** The camera as the pipeline has it, or none while the camera is off. */
track: LocalVideoMediaTrack | null;
}
/**
* The user's own camera with the effect in force: the pipeline's own track,
* 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);
if (!track) return <VideoCallOffIcon width={32} height={32} />;
return (
<video
ref={videoRef}
muted
playsInline
className={facingMode === "user" ? 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";
+9 -4
View File
@@ -75,6 +75,7 @@ import {
type RTCSlotOptions,
constant,
} from "@element-hq/matrixrtc-sdk";
import { map } from "rxjs";
import {
advancedCamera,
advancedScreenShare,
@@ -180,7 +181,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.muteStates,
mediaDevices,
capture,
backgroundEffectsState$.value,
backgroundEffectsState$.value.processor,
),
});
@@ -194,7 +195,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",
@@ -238,7 +241,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
vm,
props.muteStates,
mediaDevices,
backgroundEffectsState$,
backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)),
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
{ showControls: urlParams.showControls, header: urlParams.header },
);
@@ -822,7 +825,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,
+5 -5
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";
@@ -133,7 +134,9 @@ export const LobbyView: FC<Props> = ({
muteStates,
devices,
captureSettings(),
backgroundEffectsState$,
mediaScope.behavior(
backgroundEffectsState$.pipe(map((state) => state.processor)),
),
),
);
return (): void => {
@@ -155,15 +158,13 @@ export const LobbyView: FC<Props> = ({
null,
);
useEffect(() => {
if (mediaVm === null) return;
const footerScope = new ObservableScope();
setFooterVm(
createLobbyFooterViewModel(
footerScope,
muteStates,
devices,
backgroundEffectsState$,
mediaVm.videoTrack$,
backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)),
openSettings,
hangup,
// Logo and header are connected: only show the logo in SPA with header.
@@ -178,7 +179,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 />;
+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();
+11 -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,
@@ -44,12 +38,8 @@ 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;
}
export interface BackgroundEffectsOptions {
@@ -61,8 +51,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,7 +70,6 @@ export function createBackgroundEffects(
effect$,
setEffect,
added$,
canSegment,
pipeline,
transformer,
}: BackgroundEffectsOptions,
@@ -90,11 +77,9 @@ export function createBackgroundEffects(
// Wrapped once, so the call and the preview see one processor throughout.
const processor = fromLivekitProcessor(pipeline);
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 +95,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 +105,18 @@ 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,
};
},
{ 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" },
});
});

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