mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-13 01:12:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ca4d7e0f | ||
|
|
a2e3a2c13f | ||
|
|
8d5d482771 | ||
|
|
87f2726dd8 | ||
|
|
5fb9d6a81e | ||
|
|
ba6bcdc4eb | ||
|
|
56d73d1dfc | ||
|
|
89ad1ec936 | ||
|
|
b9a3cc6678 | ||
|
|
99f48ff987 | ||
|
|
6846c8dd3e | ||
|
|
28ee707ace | ||
|
|
3100c1c410 | ||
|
|
fc1e91f54c | ||
|
|
96949a57da | ||
|
|
2e05ef77a0 | ||
|
|
73826ec4f9 | ||
|
|
1135c02e6f | ||
|
|
9dc425b5f8 | ||
|
|
c1b94b62dd | ||
|
|
fa6efd9dad | ||
|
|
6906404a64 | ||
|
|
98e8343d0b | ||
|
|
88659d6498 | ||
|
|
704cbd65a6 | ||
|
|
5f59283b4a | ||
|
|
07dc08cd1c | ||
|
|
60d6029184 | ||
|
|
f41bc2589a | ||
|
|
ceeb26b271 | ||
|
|
e822f967c1 | ||
|
|
ec46b04b71 | ||
|
|
4f3dc9af75 | ||
|
|
cfd8312e46 | ||
|
|
38edc47f64 | ||
|
|
ab4f0f0f77 | ||
|
|
9411a56afc | ||
|
|
a5374791e4 | ||
|
|
a0601876d8 | ||
|
|
eef86fc06e | ||
|
|
cd5c367ce9 | ||
|
|
190902eec0 | ||
|
|
88f748ec21 | ||
|
|
9639effd28 | ||
|
|
f2f67019b4 | ||
|
|
51fac690f4 | ||
|
|
797a86815b | ||
|
|
1ae57e9647 | ||
|
|
4b05ab503e | ||
|
|
ea977cecd9 | ||
|
|
aadbda39b1 | ||
|
|
53c1e4c30f | ||
|
|
6b56c7e683 | ||
|
|
fb4cbc72e2 | ||
|
|
bac9811829 | ||
|
|
a2cfc2af93 | ||
|
|
dcb2b9e8f6 | ||
|
|
373b1e0fc0 | ||
|
|
1bc372b587 | ||
|
|
82ba51ea6a | ||
|
|
07aaa326d2 | ||
|
|
e9cffb60dd | ||
|
|
741dab6dbb | ||
|
|
4f184f504d | ||
|
|
ecf2cbc71e | ||
|
|
fb38a1e6e0 | ||
|
|
06f07fb808 | ||
|
|
eee101ffba | ||
|
|
4e779ac4f5 | ||
|
|
7fb222ce60 | ||
|
|
5fdb920ea8 | ||
|
|
813dd4a49e | ||
|
|
21b54501de | ||
|
|
3d57920556 | ||
|
|
11e143c8ab | ||
|
|
d68e761f7a | ||
|
|
cc994abeb3 | ||
|
|
33f4d5043f | ||
|
|
1e17a6411c |
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
prevent-blocked:
|
||||
name: Prevent blocked
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
|
||||
@@ -17,7 +17,7 @@ env:
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
name: Build & publish docker
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write # needed for publishing packages to GHCR
|
||||
|
||||
@@ -29,7 +29,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build Element Call
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
# Deploy main branch to call.element.dev after build completes
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build_full_element_call
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Deploy to call.element.dev
|
||||
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
build_storybook:
|
||||
name: Build Storybook
|
||||
if: contains(github.event.pull_request.labels.*.name, 'storybook build')
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pr-changelog-label:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Review
|
||||
# Only run on labelling and skip forks.
|
||||
if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -32,7 +32,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
deployments: write
|
||||
environment: Netlify
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint, format & type check
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
pr_number: ${{ steps.prdetails.outputs.pr_id }}
|
||||
pr_data_json: ${{ steps.prdetails.outputs.data }}
|
||||
|
||||
@@ -17,7 +17,7 @@ permissions: {}
|
||||
jobs:
|
||||
versioning:
|
||||
name: Versioning
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
|
||||
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
publish_npm:
|
||||
needs: versioning
|
||||
name: Publish NPM
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
needs: [versioning, publish_npm]
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
name: Update release notes
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
# Shared with publish-embedded-packages.yaml, which appends to the same release: two
|
||||
# appends at once would each write back the body without the other's section
|
||||
concurrency:
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
jobs:
|
||||
versioning:
|
||||
name: Versioning
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
DRY_RUN: ${{ steps.dry_run.outputs.DRY_RUN }}
|
||||
PREFIXED_VERSION: ${{ steps.prefixed_version.outputs.PREFIXED_VERSION }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish tarball
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
steps:
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish NPM
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish Android AAR
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
needs: [build_element_call, versioning]
|
||||
if: always()
|
||||
name: Publish SwiftPM Library
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
|
||||
permissions:
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
needs: [versioning, publish_npm, publish_android, publish_ios]
|
||||
if: always()
|
||||
name: Update release notes
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
# Shared with publish-component.yaml, which appends to the same release: two appends
|
||||
# at once would each write back the body without the other's section
|
||||
concurrency:
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
needs: build_element_call
|
||||
if: always()
|
||||
name: Publish tarball
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
add_docker_release_note:
|
||||
needs: publish_docker
|
||||
name: Add docker release note
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Add release note
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
jobs:
|
||||
vitest:
|
||||
name: Run unit tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
@@ -71,10 +71,27 @@ jobs:
|
||||
flags: unittests
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests
|
||||
name: Run end-to-end tests (${{ matrix.part }})
|
||||
if: ${{ !inputs.disable_e2e_tests }}
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
# We manually shard the tests across runners. Playwright's own shards cut
|
||||
# the list of tests into equal counts in file order, which would leave the
|
||||
# widget suite, the slowest tests by far, together at the end.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- part: chromium-1
|
||||
args: --project=chromium --shard=1/2
|
||||
- part: chromium-2
|
||||
args: --project=chromium --shard=2/2
|
||||
- part: widget-1
|
||||
args: --project=widget --shard=1/2
|
||||
- part: widget-2
|
||||
args: --project=widget --shard=2/2
|
||||
- part: firefox-mobile
|
||||
args: --project=firefox --project=mobile
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
@@ -107,17 +124,64 @@ jobs:
|
||||
run: pnpm playwright install --with-deps
|
||||
- name: Run backend components
|
||||
run: |
|
||||
# Make sure the directory for the embedded build exists as Docker might
|
||||
# create it with wrong access rights otherwise.
|
||||
mkdir -p embedded/web/dist
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml pull
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml up -d
|
||||
docker ps
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
USE_DOCKER: 1
|
||||
run: pnpm exec playwright test
|
||||
ARGS: ${{ matrix.args }}
|
||||
# Unquoted on purpose: the part's arguments are several words
|
||||
run: pnpm exec playwright test $ARGS
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: blob-report-${{ matrix.part }}
|
||||
path: blob-report
|
||||
if-no-files-found: error
|
||||
# Only read by the merge below.
|
||||
retention-days: 1
|
||||
|
||||
e2e_report:
|
||||
# Combines the blobs into a single HTML report and blocks PRs on failure.
|
||||
name: Run end-to-end tests
|
||||
needs: playwright
|
||||
if: ${{ always() && !inputs.disable_e2e_tests }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
|
||||
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: scripts/layered.sh --ignore-pnpmfile
|
||||
env:
|
||||
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
|
||||
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
|
||||
- name: Download the parts' reports
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: blob-report-*
|
||||
path: all-blob-reports
|
||||
merge-multiple: true
|
||||
- name: Merge into one HTML report
|
||||
run: pnpm exec playwright merge-reports --reporter html ./all-blob-reports
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: html-report
|
||||
path: playwright-report
|
||||
if-no-files-found: error
|
||||
retention-days: 4
|
||||
- name: Fail on a failed part
|
||||
if: ${{ needs.playwright.result != 'success' }}
|
||||
run: exit 1
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
download:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions: {}
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor 🌈
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
|
||||
@@ -221,119 +221,68 @@ See also:
|
||||
|
||||
- [Developing with linked packages](./docs/linking.md)
|
||||
|
||||
#### Element Call as a component (experimental)
|
||||
#### Element Call as a component
|
||||
|
||||
Element Call can also be embedded directly into another React application
|
||||
rather than being loaded in an iframe as a widget. `pnpm build:component`
|
||||
builds it as a library into `component/dist` (the bundle, its stylesheet and
|
||||
type declarations), and
|
||||
Element Call can be rendered inside another React app instead of an iframe.
|
||||
The host supplies its own Matrix client, and the call stays inside the element
|
||||
it is mounted in.
|
||||
|
||||
```sh
|
||||
pnpm dev:component
|
||||
```
|
||||
|
||||
serves a harness on port 3001 that stands in for such an application: it signs
|
||||
in twice against the development backend and shows two calls side by side, in
|
||||
resizable boxes, with page furniture of its own around them. Use it to see how
|
||||
Element Call behaves when it does not own the page — the size it is given,
|
||||
whether it stays inside its container, and what it says to its host, which is
|
||||
logged along the bottom. The harness is served with the same development
|
||||
certificate as the app, so unless the development CA is trusted, the browser
|
||||
needs a certificate exception for `https://localhost:3001` as well (see the
|
||||
note under [Backend](#backend)). It reads the same `public/config.json` as
|
||||
`pnpm dev` if one exists, and runs with Element Call's defaults otherwise.
|
||||
|
||||
The call lays itself out for the size of the element it is mounted in, not the
|
||||
window: a host that shrinks the container to a corner of its page gets the
|
||||
picture-in-picture layout, just as a host that shrank the whole iframe used to.
|
||||
The breakpoints in the stylesheets the component uses are
|
||||
`@container element-call` queries against its root element for the same reason;
|
||||
for the standalone app the root is the page, so they mean what the media queries
|
||||
they replaced did. (The standalone-only views, such as the home and login pages,
|
||||
still use plain media queries, since the component never shows them.)
|
||||
|
||||
The component's stylesheet is confined to the element it is mounted in: the
|
||||
build rewrites every selector so that it matches only Element Call's root or
|
||||
what is inside it, with `html`, `body` and `:root` standing for that root (see
|
||||
`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles,
|
||||
and Element Call brings its own fonts and design tokens along.
|
||||
|
||||
The component speaks every language the app does. English is bundled in; the
|
||||
other locales are split into chunks the host's bundler loads the first time
|
||||
they are needed. It starts in the browser's language, and follows the host's
|
||||
own language setting through the `language` prop (`supportedLanguages` lists
|
||||
the tags it accepts, and anything else falls back to its base language or to
|
||||
English). The `theme` prop works the same way and takes the same values as the
|
||||
widget's `theme` URL parameter: `light`, `dark`, `light-high-contrast` or
|
||||
`dark-high-contrast`. Both can change while a call is running without
|
||||
disturbing it.
|
||||
|
||||
A host must call and await `initializeElementCall(config)` once before
|
||||
rendering the component: it loads the `Intl` polyfills, applies the
|
||||
deployment-wide `config.json`-style configuration and sets up translations.
|
||||
The component itself takes the host's `client` and the `roomId` to call in, an
|
||||
`intent` saying what the user asked for (which decides whether to show the
|
||||
lobby, ring, and so on), an optional `config` overriding what the intent
|
||||
implies, and an optional `hostBridge` through which Element Call tells the host
|
||||
that the user has joined or hung up, that it wants to stay on screen, and so
|
||||
on. The host makes its own requests (`join`, `hangUp`, `setDeviceMute`) through
|
||||
the handle exposed on `ref`. The full API is documented in the type declarations
|
||||
(`component/index.tsx` and `component/host.ts`).
|
||||
|
||||
A few things differ from the widget on purpose: the component draws a solid
|
||||
background rather than a gradient unless told otherwise, never offers to edit
|
||||
the user's profile (the account is the host's), scopes its keyboard shortcuts to
|
||||
its own root element so that several instances can share a page, and only shows
|
||||
its own post-call and error screens when the host has not supplied a `close()`
|
||||
callback; with one, it asks the host to unmount it instead. The
|
||||
[global JS controls](./docs/controls.md) on `window` are unchanged and remain
|
||||
page-wide, so with several instances on one page they apply to all of them.
|
||||
|
||||
Each release publishes the package to npm, with the same version as the release:
|
||||
##### Install
|
||||
|
||||
```sh
|
||||
npm install @element-hq/element-call-component
|
||||
```
|
||||
|
||||
To use an unreleased commit, a host can instead install it as a git dependency
|
||||
on the `component` directory of this repository,
|
||||
Releases publish the package with the same version as Element Call. For an
|
||||
unreleased commit, depend on this repository directly:
|
||||
|
||||
```json
|
||||
"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component"
|
||||
```
|
||||
|
||||
whose `prepare` script runs the build on install. That build needs pnpm (via
|
||||
Corepack) on the host's machine, runs a full `pnpm install` of this repository,
|
||||
since it inherits the `--max-old-space-size` setting of
|
||||
the app build; the host's pnpm also has to allow it to run at all
|
||||
(`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm
|
||||
project of its own for this reason, so pnpm commands run from inside that
|
||||
directory target it rather than the repository; run them from the repository
|
||||
root. The host imports the component from
|
||||
`@element-hq/element-call-component` and the stylesheet from
|
||||
`@element-hq/element-call-component/style.css`, and has to provide `react`,
|
||||
`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
|
||||
`@vector-im/compound-design-tokens` itself, since the bundle leaves them
|
||||
external.
|
||||
Compound's stylesheet are the host's to load too: the component shares
|
||||
the host's copy of Compound, (required for example for tooltips)
|
||||
This builds on install, so the host needs pnpm (via Corepack) and must allow
|
||||
the build (`allowBuilds` in its `pnpm-workspace.yaml`).
|
||||
|
||||
The component is large, and a host will usually load it lazily, only once a
|
||||
call is shown. Everything a host needs in order to talk about a call before
|
||||
then is also exported
|
||||
from `@element-hq/element-call-component/api` (a small entry point that does not
|
||||
load the component).
|
||||
##### Use
|
||||
|
||||
- props and the types they are made of
|
||||
- the handle
|
||||
- the host bridge
|
||||
- `UserIntent`
|
||||
- `HeaderStyle`
|
||||
- `BackgroundStyle` enums
|
||||
- `configurationForIntent`, the defaults each intent implies
|
||||
Import from it wherever a value such as
|
||||
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
|
||||
entry point re-exports all of it too.
|
||||
1. Provide the externals yourself: `react`, `react-dom`, `matrix-js-sdk`,
|
||||
`@vector-im/compound-web`, `@vector-im/compound-design-tokens`, and
|
||||
Compound's stylesheet.
|
||||
2. Import `@element-hq/element-call-component/style.css`.
|
||||
3. Await `initializeElementCall(config)` once before rendering.
|
||||
4. Render the component with `client`, `roomId` and an `intent`; optionally
|
||||
pass `config`, `hostBridge`, `language` and `theme` (both can change
|
||||
mid-call). Control it through the `ref` handle (`join`, `hangUp`,
|
||||
`setDeviceMute`).
|
||||
|
||||
Load the component lazily. Types, enums and `configurationForIntent` are
|
||||
available without it from `@element-hq/element-call-component/api`. The full
|
||||
API is in `component/index.tsx`, `component/api.ts` and `component/host.ts`.
|
||||
|
||||
##### How it differs from the widget
|
||||
|
||||
- It lays out for its container, not the window: a small container gets the
|
||||
picture-in-picture layout.
|
||||
- Its styles and keyboard shortcuts are scoped to its root, so the host page is
|
||||
untouched and several instances can share a page. The
|
||||
[global JS controls](./docs/controls.md) stay page-wide.
|
||||
- It draws a solid background by default and never offers profile editing.
|
||||
- If the host bridge implements `close()`, it asks to be unmounted instead of
|
||||
showing its own post-call and error screens.
|
||||
- Sounds, images and the MediaPipe runtime are separate files the host's
|
||||
bundler copies (webpack 5, Vite, Rollup); MediaPipe is fetched only when a
|
||||
background effect is first used.
|
||||
|
||||
##### Develop
|
||||
|
||||
- `pnpm build:component` builds the library into `component/dist`.
|
||||
- `pnpm dev:component` serves a test host on `https://localhost:3001`: two
|
||||
calls side by side in resizable boxes, with host messages logged at the
|
||||
bottom. It uses the development backend, `public/config.json` if present,
|
||||
and needs the same certificate exception as `pnpm dev` (see
|
||||
[Backend](#backend)).
|
||||
- Run pnpm commands from the repository root; `component/` is its own pnpm
|
||||
project.
|
||||
|
||||
### Backend
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@
|
||||
"features": {
|
||||
"feature_element_call_video_rooms": true,
|
||||
"feature_video_rooms": true,
|
||||
"feature_group_calls": true,
|
||||
"feature_release_announcement": false
|
||||
"feature_group_calls": true
|
||||
},
|
||||
"default_federate": true,
|
||||
"default_theme": "light",
|
||||
@@ -40,7 +39,10 @@
|
||||
},
|
||||
"setting_defaults": {
|
||||
"breadcrumbs": true,
|
||||
"feature_group_calls": true
|
||||
"feature_group_calls": true,
|
||||
"releaseAnnouncementData": {
|
||||
"room_list_section": true
|
||||
}
|
||||
},
|
||||
"jitsi": {
|
||||
"preferred_domain": "meet.element.io"
|
||||
|
||||
@@ -26,8 +26,7 @@
|
||||
"features": {
|
||||
"feature_element_call_video_rooms": true,
|
||||
"feature_video_rooms": true,
|
||||
"feature_group_calls": true,
|
||||
"feature_release_announcement": false
|
||||
"feature_group_calls": true
|
||||
},
|
||||
"default_federate": true,
|
||||
"default_theme": "light",
|
||||
@@ -40,7 +39,10 @@
|
||||
},
|
||||
"setting_defaults": {
|
||||
"breadcrumbs": true,
|
||||
"feature_group_calls": true
|
||||
"feature_group_calls": true,
|
||||
"releaseAnnouncementData": {
|
||||
"room_list_section": true
|
||||
}
|
||||
},
|
||||
"jitsi": {
|
||||
"preferred_domain": "meet.element.io"
|
||||
|
||||
+13
-7
@@ -73,6 +73,7 @@ import {
|
||||
} from "../src/UrlParams";
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { AppViewModel } from "../src/state/AppViewModel";
|
||||
import { hostClientSession } from "../src/state/ClientSession";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { BackgroundEffectsProvider } from "../src/tracks/BackgroundEffectsContext";
|
||||
import { Config } from "../src/config/Config";
|
||||
@@ -189,20 +190,25 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
[roomId, intent, stableConfig],
|
||||
);
|
||||
|
||||
// Created in an effect so that the scope it lives in ends when the component
|
||||
// is unmounted (or these options change), rather than keeping its device
|
||||
// observers running for the rest of the page's life. Null until then, which
|
||||
// is one render.
|
||||
const { controlledAudioDevices, callIntent } = params;
|
||||
const [vm, setVm] = useState<AppViewModel | null>(null);
|
||||
useEffect(() => {
|
||||
const scope = new ObservableScope();
|
||||
setVm(new AppViewModel(scope, { controlledAudioDevices, callIntent }));
|
||||
setVm(
|
||||
new AppViewModel(
|
||||
scope,
|
||||
{ controlledAudioDevices, callIntent },
|
||||
// The host owns the client. We create a clientSession wrapping the host client.
|
||||
hostClientSession(client),
|
||||
// The host owns the session too, so there is nothing to manage here
|
||||
null,
|
||||
),
|
||||
);
|
||||
return (): void => {
|
||||
setVm(null);
|
||||
scope.end();
|
||||
};
|
||||
}, [controlledAudioDevices, callIntent]);
|
||||
}, [controlledAudioDevices, callIntent, client]);
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
const rtcSession = useMemo(
|
||||
@@ -250,7 +256,7 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
>
|
||||
<Decoration>
|
||||
<TooltipProvider>
|
||||
<ClientProvider client={client}>
|
||||
<ClientProvider session={vm.clientSession}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider
|
||||
effects={vm.backgroundEffects}
|
||||
|
||||
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
|
||||
@@ -19,7 +19,12 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:a79801206c628a912d57fa6f355d5c5ab9c6c40c2d1b6208d4192678fb6e80c7
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:a79801206c628a912d57fa6f355d5c5ab9c6c40c2d1b6208d4192678fb6e80c7
|
||||
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
|
||||
|
||||
@@ -34,6 +34,10 @@ translations. The browser is Playwright's, so a fresh clone needs
|
||||
|
||||
## End-to-end
|
||||
|
||||
The Element Web instance `HOST1` opens the app the dev server serves. The instance
|
||||
`HOST2` (the other site) opens the embedded package as a host ships it. This package
|
||||
is built by the `build-embedded` setup project and mounted in `docker-compose-playwright.yml`.
|
||||
|
||||
- `playwright/*.spec.ts` — standalone, on Chromium and Firefox.
|
||||
- `playwright/widget/` — widget mode, where cross-context bugs surface.
|
||||
- `playwright/component/` — the component in a host page, via the harness on port
|
||||
@@ -48,6 +52,8 @@ Test what a user observes: the peer sees the change, it survives a reconnect, it
|
||||
right after a reload. Reuse `playwright/spa-helpers.ts`,
|
||||
`playwright/widget/test-helpers.ts`, `playwright/fixtures/`.
|
||||
|
||||
To mark new release announcements as viewed, add them in `backend/ew.test*.config.json`.
|
||||
|
||||
```sh
|
||||
pnpm test # unit + storybook
|
||||
pnpm backend # Synapse + LiveKit, required for e2e
|
||||
|
||||
+13
-1
@@ -3,7 +3,7 @@
|
||||
"user_menu": "User menu"
|
||||
},
|
||||
"action": {
|
||||
"blur_background": "Blur background",
|
||||
"add_background_image": "Add image",
|
||||
"close": "Close",
|
||||
"copy_link": "Copy link",
|
||||
"edit": "Edit",
|
||||
@@ -23,6 +23,13 @@
|
||||
"upload_file": "Upload file"
|
||||
},
|
||||
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <6>Cookie Policy</6>.",
|
||||
"background_effects": {
|
||||
"blur": "Blur",
|
||||
"none": "None",
|
||||
"numbered": "Background {{n}}",
|
||||
"slow": "Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
"unavailable": "Background effects are not supported on this platform."
|
||||
},
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Create account",
|
||||
"create_account_prompt": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",
|
||||
@@ -92,6 +99,10 @@
|
||||
},
|
||||
"disconnected_banner": "Connectivity to the server has been lost.",
|
||||
"error": {
|
||||
"background_animated": "Animated images cannot be used as a background",
|
||||
"background_not_an_image": "That file is not a supported image",
|
||||
"background_not_kept": "That background could not be saved",
|
||||
"background_undecodable": "That image could not be opened",
|
||||
"call_is_not_supported": "Call is not supported",
|
||||
"call_not_found": "Call not found",
|
||||
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
|
||||
@@ -212,6 +223,7 @@
|
||||
"auto_gain_control_label": "Automatic gain control",
|
||||
"background_blur_header": "Background",
|
||||
"background_blur_label": "Blur the background of the video",
|
||||
"background_effects_header": "Background effects",
|
||||
"bitrate_label": "Bitrate",
|
||||
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
|
||||
"camera_header": "Camera quality",
|
||||
|
||||
+2
-1
@@ -97,6 +97,7 @@
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"eslint-plugin-element-call": "link:eslint",
|
||||
"eslint-plugin-storybook": "^10.3.6",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"fetch-mock": "11.1.5",
|
||||
"i18next": "^26.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
@@ -106,7 +107,7 @@
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
+33
-21
@@ -26,6 +26,19 @@ process.env.NODE_EXTRA_CA_CERTS = join(
|
||||
"backend/dev_tls_local-ca.crt",
|
||||
);
|
||||
|
||||
const desktopChrome = {
|
||||
...devices["Desktop Chrome"],
|
||||
permissions: ["clipboard-write", "clipboard-read", "microphone", "camera"],
|
||||
ignoreHTTPSErrors: true,
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--mute-audio",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
@@ -43,11 +56,11 @@ export default defineConfig({
|
||||
* Reporter to use. See https://playwright.dev/docs/test-reporters
|
||||
*
|
||||
* On CI, `list` prints a line per test as it finishes, which keeps the job
|
||||
* log moving; `github` annotates failures on the PR; `html` is uploaded as
|
||||
* an artifact and must never try to open a browser there.
|
||||
* log moving; `github` annotates failures on the PR; the `blob` format is
|
||||
* uploaded by sharded CI runs and combined into a HTML report by the workflow.
|
||||
*/
|
||||
reporter: process.env.CI
|
||||
? [["list"], ["github"], ["html", { open: "never" }]]
|
||||
? [["list"], ["github"], ["blob"]]
|
||||
: [["list"], ["html"]],
|
||||
/* Stop a broken run early on CI instead of waiting out every retry. */
|
||||
maxFailures: process.env.CI ? 5 : undefined,
|
||||
@@ -63,29 +76,28 @@ export default defineConfig({
|
||||
},
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
// Builds the embedded package into the directory the other site's
|
||||
// Element Web container serves it from (see docker-compose-playwright.yml).
|
||||
name: "build-embedded",
|
||||
testMatch: "**/widget/embedded/build.setup.ts",
|
||||
},
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: "**/mobile/**",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
permissions: [
|
||||
"clipboard-write",
|
||||
"clipboard-read",
|
||||
"microphone",
|
||||
"camera",
|
||||
],
|
||||
ignoreHTTPSErrors: true,
|
||||
launchOptions: {
|
||||
args: [
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
"--mute-audio",
|
||||
],
|
||||
},
|
||||
},
|
||||
testIgnore: ["**/mobile/**", "**/widget/**"],
|
||||
use: desktopChrome,
|
||||
},
|
||||
{
|
||||
// Element Call as a widget in Element Web, on Chromium. This uses its
|
||||
// own project so that it can be sharded on CI.
|
||||
name: "widget",
|
||||
dependencies: ["build-embedded"],
|
||||
testMatch: "**/widget/**/*.@(spec|test).ts",
|
||||
use: desktopChrome,
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
dependencies: ["build-embedded"],
|
||||
testIgnore: "**/mobile/**",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
|
||||
@@ -0,0 +1,952 @@
|
||||
/*
|
||||
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, type Browser, type Page } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers.ts";
|
||||
import {
|
||||
averageColour,
|
||||
cameraColour,
|
||||
type Colour,
|
||||
distance,
|
||||
} from "./utils/colour.ts";
|
||||
|
||||
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
|
||||
// so it rightly offers none of them.
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"Background effects need WebGL2, which headless Firefox on CI does not have",
|
||||
);
|
||||
|
||||
test.describe("background effects", () => {
|
||||
test("pre-join preview shows the chosen effect", async ({ page }) => {
|
||||
test.slow();
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Effect user", "Background effects");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// The fake camera shows no person, so the whole frame is background: the
|
||||
// preview takes on the picture's colours and leaves the camera's.
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
// Long enough for the first effect of a session to be built.
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("no effect", () => {
|
||||
test("draws nothing, even after blurring", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await countDraws(page);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Idle user", "No effect after blur");
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const effects = page.getByRole("group", { name: "Background effects" });
|
||||
await effects.getByRole("menuitemradio", { name: "Blur" }).click();
|
||||
// Long enough for the first effect of a session to be built.
|
||||
await expect
|
||||
.poll(async () => drawsPerSecond(page), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0);
|
||||
const blurred = await drawsPerSecond(page);
|
||||
|
||||
await effects.getByRole("menuitemradio", { name: "None" }).click();
|
||||
// A frame already on its way may still be drawn.
|
||||
await expect.poll(async () => drawsPerSecond(page)).toBe(0);
|
||||
test.info().annotations.push({
|
||||
type: "WebGL draws per second",
|
||||
description: `blurred ${blurred}, none 0`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Counts every WebGL draw on the page, the segmenter's and the effect's. */
|
||||
async function countDraws(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const counter = { draws: 0 };
|
||||
(window as unknown as { webglDraws: typeof counter }).webglDraws = counter;
|
||||
for (const name of ["drawArrays", "drawElements"] as const) {
|
||||
const draw = WebGL2RenderingContext.prototype[name] as (
|
||||
...args: unknown[]
|
||||
) => void;
|
||||
WebGL2RenderingContext.prototype[name] = function (
|
||||
this: WebGL2RenderingContext,
|
||||
...args: unknown[]
|
||||
): void {
|
||||
counter.draws += 1;
|
||||
draw.apply(this, args);
|
||||
} as never;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** WebGL draws over the next second. */
|
||||
async function drawsPerSecond(page: Page): Promise<number> {
|
||||
return page.evaluate(async () => {
|
||||
const counter = (window as unknown as { webglDraws: { draws: number } })
|
||||
.webglDraws;
|
||||
const before = counter.draws;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return counter.draws - before;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("the camera menu's preview", () => {
|
||||
test("shows the camera with the effect in force", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Preview user", "Preview in the menu");
|
||||
const camera = await cameraColour(page.locator("video").first());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const preview = page.getByRole("menu").locator("video");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), camera), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeLessThan(30);
|
||||
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
|
||||
test("shows the call's own camera once joined", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(
|
||||
page,
|
||||
"Call preview user",
|
||||
"Preview in the call's menu",
|
||||
true,
|
||||
);
|
||||
await expect(page.getByTestId("incall_videomute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const camera = await cameraColour(page.locator("video").first());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const preview = page.getByRole("menu").locator("video");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), camera), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeLessThan(30);
|
||||
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("background effects section", () => {
|
||||
test("section stays within the call area at every size", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Sizes user", "Background effect sizes");
|
||||
|
||||
for (const size of [
|
||||
{ width: 1280, height: 800 },
|
||||
{ width: 800, height: 600 },
|
||||
{ width: 600, height: 420 },
|
||||
{ width: 480, height: 360 },
|
||||
{ width: 360, height: 640 },
|
||||
]) {
|
||||
await page.setViewportSize(size);
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const box = (await menu.boundingBox())!;
|
||||
const where = `at ${size.width}x${size.height}`;
|
||||
expect(box.x, where).toBeGreaterThanOrEqual(0);
|
||||
expect(box.y, where).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width, where).toBeLessThanOrEqual(size.width);
|
||||
expect(box.y + box.height, where).toBeLessThanOrEqual(size.height);
|
||||
|
||||
const last = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio")
|
||||
.last();
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
await expect(last, where).toBeInViewport({ ratio: 1 });
|
||||
await last.click();
|
||||
await expect(last, where).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
// Back to no effect for the next size, and close.
|
||||
await page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "None" })
|
||||
.click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(menu).toBeHidden();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("a pipeline that fails to build", () => {
|
||||
test("offers no effects for the session, and keeps the choice", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Failing user", "Pipeline fails");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
await page.route("**/*.tflite*", async (route) => route.abort());
|
||||
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
await expect(
|
||||
page.getByText("Background effects are not supported on this platform."),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(tile).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
// The camera and microphone carry on without it.
|
||||
expect(distance(await cameraColour(preview), camera)).toBeLessThan(30);
|
||||
await expect(page.getByTestId("incall_mute")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
|
||||
await page.unroute("**/*.tflite*");
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the chosen effect", () => {
|
||||
test("chosen effect survives leaving and rejoining", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Returning user", "Effect remembered");
|
||||
const { camera, picture } = await chooseAndWear(page, "Background 1");
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
await page.getByTestId("incall_leave").click();
|
||||
await expect(page.getByRole("heading")).toContainText(
|
||||
"your call has ended",
|
||||
);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("home_callName").fill("Effect remembered again");
|
||||
await page.getByTestId("home_go").click();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
const preview = page.locator("video").first();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
});
|
||||
|
||||
test("second session still shows the existing error", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Two tabs", "Effect in two tabs");
|
||||
await chooseAndWear(page, "Background 1");
|
||||
|
||||
const second = await page.context().newPage();
|
||||
await second.goto("/");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Opened in another tab" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
/** Chooses an effect in the lobby, and waits until the preview wears it. */
|
||||
async function chooseAndWear(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<{ camera: Colour; picture: Colour }> {
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page.getByRole("menuitemradio", { name });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
await page.keyboard.press("Escape");
|
||||
return { camera, picture };
|
||||
}
|
||||
|
||||
test.describe("a background of one's own", () => {
|
||||
test("adds an image from the device", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Adding user", "Own background");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
const red = await redImage(page);
|
||||
|
||||
await addImage(page, red);
|
||||
const mine = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" });
|
||||
await expect(mine).toBeVisible();
|
||||
await expect(mine).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await mine.click();
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), RED), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, RED) / 2);
|
||||
});
|
||||
|
||||
test("adding an image makes no upload", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Private user", "Own background kept");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
const red = await redImage(page);
|
||||
const sent: { request: string; body: Buffer | null }[] = [];
|
||||
page.on("request", (request) =>
|
||||
sent.push({
|
||||
request: `${request.method()} ${request.url()}`,
|
||||
body: request.postDataBuffer(),
|
||||
}),
|
||||
);
|
||||
|
||||
await addImage(page, red);
|
||||
await page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" })
|
||||
.click();
|
||||
// Kept, chosen and in force: anything that would send it has had its turn.
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), RED), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, RED) / 2);
|
||||
// The file as given, and the copy kept, which is encoded anew.
|
||||
const samples = [red, await keptBytes(page)].map((bytes) =>
|
||||
bytes.subarray(bytes.length - 64),
|
||||
);
|
||||
const carriesIt = sent.filter(
|
||||
({ request, body }) =>
|
||||
request.includes("/_matrix/media/") ||
|
||||
samples.some((sample) => body?.includes(sample)),
|
||||
);
|
||||
expect(carriesIt.map(({ request }) => request)).toEqual([]);
|
||||
});
|
||||
|
||||
test("added image survives rejoining and is gone after storage is cleared", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Keeping user", "Own background again");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
const red = await redImage(page);
|
||||
await addImage(page, red);
|
||||
const mine = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" });
|
||||
await mine.click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
await expect(mine).toHaveAttribute("aria-checked", "true");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), RED), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, RED) / 2);
|
||||
|
||||
await page.evaluate(
|
||||
async () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(
|
||||
"element-call-background-images",
|
||||
);
|
||||
request.onsuccess = (): void => resolve();
|
||||
request.onerror = (): void => reject(request.error);
|
||||
request.onblocked = (): void => resolve();
|
||||
}),
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("group", { name: "Background effects" }),
|
||||
).toBeVisible();
|
||||
await expect(mine).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await addImage(page, red);
|
||||
await expect(mine).toBeVisible();
|
||||
});
|
||||
|
||||
test("a removed image stays removed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(
|
||||
page,
|
||||
"Removing user",
|
||||
"Own background removed",
|
||||
);
|
||||
await addImage(page, await redImage(page));
|
||||
const mine = page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" });
|
||||
await expect(mine).toHaveAttribute("aria-keyshortcuts", "Delete");
|
||||
await mine.focus();
|
||||
await page.keyboard.press("Delete");
|
||||
await expect(mine).toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("group", { name: "Background effects" }),
|
||||
).toBeVisible();
|
||||
await expect(mine).toHaveCount(0);
|
||||
expect(await keptImages(page)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("what can be added", () => {
|
||||
test("refuses an animated image", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Refused user", "Animated refused");
|
||||
await addImage(page, ANIMATED_GIF, {
|
||||
name: "moving.gif",
|
||||
type: "image/gif",
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByText("Animated images cannot be used as a background"),
|
||||
).toBeVisible();
|
||||
const section = page.getByRole("group", { name: "Background effects" });
|
||||
await expect(
|
||||
section.getByRole("menuitemradio", { name: "Background 3" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
section.getByRole("menuitemradio", { name: "None" }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
expect(await keptImages(page)).toEqual([]);
|
||||
});
|
||||
|
||||
test("reduces an oversized image", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Large user", "Oversized reduced");
|
||||
await addImage(page, await pngImage(page, { width: 4000, height: 3000 }));
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
const [kept] = await keptImages(page);
|
||||
expect(kept).toMatchObject({ width: 1920, height: 1440 });
|
||||
});
|
||||
|
||||
test("lays every image on an opaque ground", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Logo user", "Transparent made opaque");
|
||||
await addImage(
|
||||
page,
|
||||
await pngImage(page, { width: 320, height: 180, transparent: true }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 3" }),
|
||||
).toBeVisible();
|
||||
const [kept] = await keptImages(page);
|
||||
expect(kept.minAlpha).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
/** Two frames of one pixel, red then blue. */
|
||||
const ANIMATED_GIF = Buffer.from(
|
||||
"R0lGODlhAQABAPEAAP8AAAAA/wAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQACgAAACwAAAAAAQABAAACAkQBACH5BAAKAAAALAAAAAABAAEAAAICTAEAOw==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
/** A red PNG of the given size; transparent leaves its right half empty. */
|
||||
async function pngImage(
|
||||
page: Page,
|
||||
size: { width: number; height: number; transparent?: boolean },
|
||||
): Promise<Buffer> {
|
||||
const dataUrl = await page.evaluate(({ width, height, transparent }) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.fillStyle = "rgb(255, 0, 0)";
|
||||
context.fillRect(0, 0, transparent ? width / 2 : width, height);
|
||||
return canvas.toDataURL("image/png");
|
||||
}, size);
|
||||
return Buffer.from(dataUrl.split(",")[1], "base64");
|
||||
}
|
||||
|
||||
/** The images the device keeps, as it keeps them. */
|
||||
async function keptImages(
|
||||
page: Page,
|
||||
): Promise<{ width: number; height: number; minAlpha: number }[]> {
|
||||
return page.evaluate(async () => {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("element-call-background-images");
|
||||
request.onsuccess = (): void => resolve(request.result);
|
||||
request.onerror = (): void => reject(request.error);
|
||||
});
|
||||
if (!db.objectStoreNames.contains("backgrounds")) return [];
|
||||
const kept = await new Promise<{ image: Blob }[]>((resolve, reject) => {
|
||||
const request = db
|
||||
.transaction("backgrounds")
|
||||
.objectStore("backgrounds")
|
||||
.getAll();
|
||||
request.onsuccess = (): void => resolve(request.result);
|
||||
request.onerror = (): void => reject(request.error);
|
||||
});
|
||||
db.close();
|
||||
return Promise.all(
|
||||
kept.map(async ({ image }) => {
|
||||
const bitmap = await createImageBitmap(image);
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const { data } = context.getImageData(
|
||||
0,
|
||||
0,
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
);
|
||||
let minAlpha = 255;
|
||||
for (let i = 3; i < data.length; i += 4)
|
||||
minAlpha = Math.min(minAlpha, data[i]);
|
||||
return { width: bitmap.width, height: bitmap.height, minAlpha };
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The bytes of the one image the device keeps. */
|
||||
async function keptBytes(page: Page): Promise<Buffer> {
|
||||
const base64 = await page.evaluate(
|
||||
async () =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const open = indexedDB.open("element-call-background-images");
|
||||
open.onerror = (): void => reject(open.error);
|
||||
open.onsuccess = (): void => {
|
||||
const all = open.result
|
||||
.transaction("backgrounds")
|
||||
.objectStore("backgrounds")
|
||||
.getAll();
|
||||
all.onerror = (): void => reject(all.error);
|
||||
all.onsuccess = (): void => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (): void =>
|
||||
resolve((reader.result as string).split(",")[1]);
|
||||
reader.readAsDataURL((all.result[0] as { image: Blob }).image);
|
||||
};
|
||||
};
|
||||
}),
|
||||
);
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
const RED: Colour = [255, 0, 0];
|
||||
|
||||
/** A solid red PNG, unlike the camera and the shipped pictures. */
|
||||
async function redImage(page: Page): Promise<Buffer> {
|
||||
const dataUrl = await page.evaluate(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 180;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.fillStyle = "rgb(255, 0, 0)";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL("image/png");
|
||||
});
|
||||
return Buffer.from(dataUrl.split(",")[1], "base64");
|
||||
}
|
||||
|
||||
/** Adds an image from the camera menu's add tile, leaving the menu open. */
|
||||
async function addImage(
|
||||
page: Page,
|
||||
image: Buffer,
|
||||
{ name, type } = { name: "mine.png", type: "image/png" },
|
||||
): Promise<void> {
|
||||
if (!(await page.getByRole("menu").isVisible()))
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("menuitem", { name: "Add image" }).click();
|
||||
await (await chooser).setFiles({ name, mimeType: type, buffer: image });
|
||||
}
|
||||
|
||||
test.describe("joining with a background chosen", () => {
|
||||
test("publishes no frame of the room", async ({ browser }) => {
|
||||
// Two first builds of the pipeline, one of them held back.
|
||||
test.setTimeout(180_000);
|
||||
const hostContext = await browser.newContext();
|
||||
const host = await hostContext.newPage();
|
||||
await recordIncomingFrames(host);
|
||||
await host.goto("/");
|
||||
await SpaHelpers.createCall(host, "Host", "Join with a background", true);
|
||||
await expect(host.getByTestId("incall_videomute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(host);
|
||||
await host.keyboard.press("Escape");
|
||||
|
||||
const guestContext = await browser.newContext();
|
||||
const guest = await guestContext.newPage();
|
||||
await guest.goto(inviteLink);
|
||||
await guest.getByTestId("joincall_displayName").fill("Guest");
|
||||
await guest.getByTestId("joincall_joincall").click();
|
||||
const preview = guest.locator("video").first();
|
||||
await expect(preview).toBeVisible();
|
||||
const camera = await cameraColour(preview);
|
||||
await guest.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = guest.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
// Were the pipeline built again for the call's track, it would take as
|
||||
// long as on the slower route. Routing bypasses the cache, so it waits.
|
||||
let heldBack = 0;
|
||||
await guest.route("**/*.tflite*", async (route) => {
|
||||
heldBack += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
||||
await route.continue();
|
||||
});
|
||||
await guest.keyboard.press("Escape");
|
||||
await guest.getByTestId("lobby_joinCall").click();
|
||||
|
||||
const guestTile = host
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: host.getByTestId("name_tag").getByText("Guest") });
|
||||
await expect(guestTile.locator("video")).toBeVisible({ timeout: 60_000 });
|
||||
// Visible can come before the stream is attached.
|
||||
let trackId: string | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
trackId = await guestTile
|
||||
.locator("video")
|
||||
.evaluate(
|
||||
(video: HTMLVideoElement) =>
|
||||
(video.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id,
|
||||
);
|
||||
return trackId;
|
||||
})
|
||||
.toBeDefined();
|
||||
await expectNoFrameOfTheRoom(host, trackId!, 0, camera, picture);
|
||||
// The join adopts the lobby's pipeline, so the model is never fetched again.
|
||||
expect(heldBack).toBe(0);
|
||||
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("the first build", () => {
|
||||
test("survives the camera turned off and on while it runs", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const failures: string[] = [];
|
||||
page.on("console", (m) => {
|
||||
if (m.text().includes("Failed to attach video processor"))
|
||||
failures.push(m.text());
|
||||
});
|
||||
await page.goto("/");
|
||||
await SpaHelpers.createCall(page, "Toggling user", "Toggle while building");
|
||||
const preview = page.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
|
||||
const held = await holdBackTheModel(page);
|
||||
await page.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await page.keyboard.press("Escape");
|
||||
// New preview tracks, each attached while the first is still building.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.getByTestId("incall_videomute").click();
|
||||
await page.getByTestId("incall_videomute").click();
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
expect(failures).toEqual([]);
|
||||
expect(held()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("turning the camera on with a background chosen", () => {
|
||||
test("publishes no frame of the room when the camera started off", async ({
|
||||
browser,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { host, guest, close } = await hostAndGuest(browser);
|
||||
const camera = await cameraColour(guest.locator("video").first());
|
||||
await guest.getByTestId("incall_videomute").click();
|
||||
await guest.getByTestId("lobby_joinCall").click();
|
||||
|
||||
const picture = await chooseBackground(guest);
|
||||
const held = await holdBackTheModel(guest);
|
||||
await guest.getByTestId("incall_videomute").click();
|
||||
|
||||
const trackId = await incomingTrackId(host);
|
||||
await expectNoFrameOfTheRoom(host, trackId, 0, camera, picture);
|
||||
expect(held()).toBeGreaterThan(0);
|
||||
await close();
|
||||
});
|
||||
|
||||
test("publishes no frame of the room when the camera was turned off", async ({
|
||||
browser,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { host, guest, close } = await hostAndGuest(browser);
|
||||
const camera = await cameraColour(guest.locator("video").first());
|
||||
await guest.getByTestId("lobby_joinCall").click();
|
||||
const trackId = await incomingTrackId(host);
|
||||
await expect
|
||||
.poll(async () => (await framesOf(host, trackId)).length, {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
await guest.getByTestId("incall_videomute").click();
|
||||
|
||||
const picture = await chooseBackground(guest);
|
||||
const held = await holdBackTheModel(guest);
|
||||
const before = (await framesOf(host, trackId)).length;
|
||||
await guest.getByTestId("incall_videomute").click();
|
||||
|
||||
await expectNoFrameOfTheRoom(host, trackId, before, camera, picture);
|
||||
expect(held()).toBeGreaterThan(0);
|
||||
await close();
|
||||
});
|
||||
});
|
||||
|
||||
/** A host in a call, recording what it receives, and a guest in its lobby. */
|
||||
async function hostAndGuest(browser: Browser): Promise<{
|
||||
host: Page;
|
||||
guest: Page;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const hostContext = await browser.newContext();
|
||||
const host = await hostContext.newPage();
|
||||
await recordIncomingFrames(host);
|
||||
await host.goto("/");
|
||||
await SpaHelpers.createCall(host, "Host", "Camera with a background", true);
|
||||
await expect(host.getByTestId("incall_videomute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const inviteLink = await SpaHelpers.getCallInviteLink(host);
|
||||
await host.keyboard.press("Escape");
|
||||
|
||||
const guestContext = await browser.newContext();
|
||||
const guest = await guestContext.newPage();
|
||||
await guest.goto(inviteLink);
|
||||
await guest.getByTestId("joincall_displayName").fill("Guest");
|
||||
await guest.getByTestId("joincall_joincall").click();
|
||||
await expect(guest.locator("video").first()).toBeVisible();
|
||||
return {
|
||||
host,
|
||||
guest,
|
||||
close: async (): Promise<void> => {
|
||||
await hostContext.close();
|
||||
await guestContext.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Chooses the first shipped picture in the call, and answers its colour. */
|
||||
async function chooseBackground(guest: Page): Promise<Colour> {
|
||||
// The lobby has a camera button of its own, so wait for the call's.
|
||||
await expect(guest.getByTestId("incall_leave")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(guest.getByTestId("incall_videomute")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await guest.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = guest.getByRole("menuitemradio", { name: "Background 1" });
|
||||
await tile.click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await guest.keyboard.press("Escape");
|
||||
return picture;
|
||||
}
|
||||
|
||||
/** Makes the pipeline's first build as slow as on the slower route. */
|
||||
/** Answers how often it held the model back, which a check should see. */
|
||||
async function holdBackTheModel(page: Page): Promise<() => number> {
|
||||
let held = 0;
|
||||
// Routing bypasses the cache, so it waits.
|
||||
await page.route("**/*.tflite*", async (route) => {
|
||||
held += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||
await route.continue();
|
||||
});
|
||||
return () => held;
|
||||
}
|
||||
|
||||
/** The id of the guest's camera track, as the host receives it. */
|
||||
async function incomingTrackId(host: Page): Promise<string> {
|
||||
const guestTile = host
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: host.getByTestId("name_tag").getByText("Guest") });
|
||||
await expect(guestTile.locator("video")).toBeVisible({ timeout: 60_000 });
|
||||
// Visible can come before the stream is attached.
|
||||
let trackId: string | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
trackId = await guestTile
|
||||
.locator("video")
|
||||
.evaluate(
|
||||
(video: HTMLVideoElement) =>
|
||||
(video.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id,
|
||||
);
|
||||
return trackId;
|
||||
})
|
||||
.toBeDefined();
|
||||
return trackId!;
|
||||
}
|
||||
|
||||
/** Waits for the picture to arrive, then finds no frame of the room since. */
|
||||
async function expectNoFrameOfTheRoom(
|
||||
host: Page,
|
||||
trackId: string,
|
||||
since: number,
|
||||
camera: Colour,
|
||||
picture: Colour,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const frames = (await framesOf(host, trackId)).slice(since);
|
||||
// A black frame, as a track starts, is nearer the picture than the
|
||||
// camera, but shows neither.
|
||||
return frames.some(
|
||||
(f) =>
|
||||
distance(f, picture) <
|
||||
Math.min(distance(f, camera), distance(f, [0, 0, 0])),
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
const frames = (await framesOf(host, trackId)).slice(since);
|
||||
expect(
|
||||
frames.filter((f) => distance(f, camera) < distance(f, picture)),
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the mean colour of every frame of every video track the page
|
||||
* receives, from the moment it arrives: a tile may attach it later than its
|
||||
* first frame.
|
||||
*/
|
||||
async function recordIncomingFrames(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const frames: Record<string, [number, number, number][]> = {};
|
||||
(window as unknown as { receivedFrames: typeof frames }).receivedFrames =
|
||||
frames;
|
||||
const record = (track: MediaStreamTrack): void => {
|
||||
if (track.kind !== "video" || frames[track.id]) return;
|
||||
const seen: [number, number, number][] = (frames[track.id] = []);
|
||||
const video = document.createElement("video");
|
||||
video.muted = true;
|
||||
video.srcObject = new MediaStream([track]);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 16;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true })!;
|
||||
const step = (): void => {
|
||||
context.drawImage(video, 0, 0, 16, 16);
|
||||
const { data } = context.getImageData(0, 0, 16, 16);
|
||||
const sum = [0, 0, 0];
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
|
||||
seen.push(sum.map((v) => v / (data.length / 4)) as Colour);
|
||||
video.requestVideoFrameCallback(step);
|
||||
};
|
||||
video.requestVideoFrameCallback(step);
|
||||
void video.play();
|
||||
};
|
||||
const Connection = window.RTCPeerConnection;
|
||||
window.RTCPeerConnection = class extends Connection {
|
||||
public constructor(...args: ConstructorParameters<typeof Connection>) {
|
||||
super(...args);
|
||||
this.addEventListener("track", (e) => record(e.track));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function framesOf(page: Page, trackId: string): Promise<Colour[]> {
|
||||
return page.evaluate(
|
||||
(id) =>
|
||||
(
|
||||
window as unknown as {
|
||||
receivedFrames: Record<string, [number, number, number][]>;
|
||||
}
|
||||
).receivedFrames[id] ?? [],
|
||||
trackId,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, type Locator, type Page, test } from "@playwright/test";
|
||||
|
||||
import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
|
||||
import { averageColour, cameraColour, expectWearing } from "../utils/colour.ts";
|
||||
|
||||
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
|
||||
// so it rightly offers none of them.
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"Background effects need WebGL2, which headless Firefox on CI does not have",
|
||||
);
|
||||
|
||||
/**
|
||||
* Background effects with Element Call as a component in a host page: the
|
||||
* pipeline's model and WebAssembly have to load from the component's bundle,
|
||||
* and the portalled menu has to be sized against the call, not the window.
|
||||
* Driven from the lobby, which builds the same menu and pipeline.
|
||||
*/
|
||||
|
||||
// Sign-in, crypto setup and sync happen twice before anything shows.
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("puts a picture on the call's preview", async ({ page }) => {
|
||||
const { username, roomId } = await createUserAndRoom("effects");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
await resizeContainer(pane.getByTestId("call-container"), {
|
||||
width: 900,
|
||||
height: 480,
|
||||
});
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
const preview = pane.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
|
||||
const tile = await openEffect(page, pane, "Background 1");
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await tile.click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
await expectWearing(preview, picture, camera);
|
||||
});
|
||||
|
||||
test("keeps every effect reachable in a small container", async ({ page }) => {
|
||||
const { username, roomId } = await createUserAndRoom("effectsreach");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
const container = pane.getByTestId("call-container");
|
||||
await resizeContainer(container, { width: 400, height: 360 });
|
||||
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const last = await openEffect(page, pane, "Background 2");
|
||||
const list = page.locator("[role='menu'] div[role='none']").first();
|
||||
expect((await list.boundingBox())!.height).toBeLessThanOrEqual(
|
||||
(await container.boundingBox())!.height,
|
||||
);
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
await expect(last).toBeInViewport({ ratio: 1 });
|
||||
await last.click();
|
||||
await expect(last).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
/** Opens a component's camera menu and returns one of its effects. */
|
||||
async function openEffect(
|
||||
page: Page,
|
||||
pane: Locator,
|
||||
name: string,
|
||||
): Promise<Locator> {
|
||||
await pane
|
||||
.getByRole("button", { name: "Camera", exact: true })
|
||||
.click({ timeout: 60_000 });
|
||||
// Portalled out of the pane, so found on the page.
|
||||
return page
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name });
|
||||
}
|
||||
@@ -29,47 +29,10 @@ export interface MyFixtures {
|
||||
addUser: (username: string, host: string) => Promise<UserBaseFixture>;
|
||||
}
|
||||
|
||||
// Minimal config.json for the local element-web instance
|
||||
const CONFIG_JSON = {
|
||||
default_server_config: {
|
||||
"m.homeserver": {
|
||||
base_url: "https://synapse.m.localhost",
|
||||
server_name: "synapse.m.localhost",
|
||||
},
|
||||
},
|
||||
|
||||
element_call: {
|
||||
participant_limit: 8,
|
||||
brand: "Element Call",
|
||||
},
|
||||
|
||||
// The default language is set here for test consistency
|
||||
setting_defaults: {
|
||||
language: "en-GB",
|
||||
feature_group_calls: true,
|
||||
},
|
||||
|
||||
// the location tests want a map style url.
|
||||
map_style_url:
|
||||
"https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx",
|
||||
|
||||
features: {
|
||||
// We don't want to go through the feature announcement during the e2e test
|
||||
feature_release_announcement: false,
|
||||
feature_element_call_video_rooms: true,
|
||||
feature_video_rooms: true,
|
||||
feature_group_calls: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const widgetTest = test.extend<MyFixtures>({
|
||||
// allow per-test override: `widgetTest.use({ callType: "dm" })`
|
||||
callType: ["room", { option: true }],
|
||||
asWidget: async ({ browser, context, callType }, pUse) => {
|
||||
await context.route(`http://localhost:8081/config.json*`, async (route) => {
|
||||
await route.fulfill({ json: CONFIG_JSON });
|
||||
});
|
||||
|
||||
asWidget: async ({ browser, callType }, pUse) => {
|
||||
const brooksDisplayName = `brooks_${Date.now()}`;
|
||||
const whistlerDisplayName = `whistler_${Date.now()}`;
|
||||
|
||||
@@ -109,15 +72,7 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
|
||||
|
||||
// Accept the invite
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await expect(
|
||||
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
|
||||
).toBeVisible();
|
||||
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
|
||||
await TestHelpers.openRoomInvite(ewPage2, "Welcome Room");
|
||||
await ewPage2.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
ewPage2
|
||||
@@ -125,17 +80,7 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
.getByRole("heading", { name: "Welcome Room" }),
|
||||
).toBeVisible();
|
||||
} else if (callType === "dm") {
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage1,
|
||||
"Introducing Sections",
|
||||
);
|
||||
|
||||
await ewPage1
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
|
||||
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
|
||||
await TestHelpers.chooseFromRoomListNewMenu(ewPage1, "Start chat");
|
||||
await ewPage1.getByRole("textbox", { name: "Search" }).click();
|
||||
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
|
||||
await ewPage1.getByRole("button", { name: "Go" }).click();
|
||||
@@ -157,12 +102,7 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
).toBeVisible();
|
||||
|
||||
// Accept the DM invite from brooks
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
|
||||
await TestHelpers.openRoomInvite(ewPage2, brooksDisplayName);
|
||||
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
|
||||
}
|
||||
|
||||
@@ -187,13 +127,14 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
* Provide a way to add additional users within a test.
|
||||
* The returned user will be registered on the default homeserver, the name will be made unique by appending a timestamp.
|
||||
*/
|
||||
addUser: async ({ browser }, use) => {
|
||||
addUser: async ({ browser }, use, testInfo) => {
|
||||
await use(
|
||||
async (
|
||||
username: string,
|
||||
host: string = HOST1,
|
||||
): Promise<UserBaseFixture> => {
|
||||
const uniqueSuffix = Date.now();
|
||||
// Tests in parallel workers can reach this in the same millisecond.
|
||||
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
|
||||
const { page, clientHandle, mxId } = await TestHelpers.registerUser(
|
||||
browser,
|
||||
`${username.toLowerCase()}_${uniqueSuffix}`,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
export type Colour = [number, number, number];
|
||||
|
||||
/** The camera's colour, once the preview is showing it rather than nothing. */
|
||||
export async function cameraColour(preview: Locator): Promise<Colour> {
|
||||
await expect
|
||||
.poll(async () => Math.max(...(await averageColour(preview))))
|
||||
.toBeGreaterThan(40);
|
||||
return averageColour(preview);
|
||||
}
|
||||
|
||||
/** The mean colour of a video's current frame or an image, drawn small. */
|
||||
export async function averageColour(element: Locator): Promise<Colour> {
|
||||
return element.evaluate(
|
||||
async (source: HTMLVideoElement | HTMLImageElement) => {
|
||||
if (source instanceof HTMLImageElement) await source.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 32;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(source, 0, 0, 32, 32);
|
||||
const { data } = context.getImageData(0, 0, 32, 32);
|
||||
const sum = [0, 0, 0];
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
|
||||
const pixels = data.length / 4;
|
||||
return sum.map((s) => s / pixels) as Colour;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function distance(a: Colour, b: Colour): number {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
}
|
||||
|
||||
/** Waits until a preview shows a picture rather than the camera. */
|
||||
export async function expectWearing(
|
||||
preview: Locator,
|
||||
picture: Colour,
|
||||
camera: Colour,
|
||||
): Promise<void> {
|
||||
// Long enough for the first effect of a session to be built.
|
||||
await expect
|
||||
.poll(async () => distance(await averageColour(preview), picture), {
|
||||
timeout: 60_000,
|
||||
})
|
||||
.toBeLessThan(distance(camera, picture) / 2);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { TestHelpers } from "./test-helpers.ts";
|
||||
import { averageColour, cameraColour, expectWearing } from "../utils/colour.ts";
|
||||
|
||||
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
|
||||
// so it rightly offers none of them.
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"Background effects need WebGL2, which headless Firefox on CI does not have",
|
||||
);
|
||||
|
||||
widgetTest(
|
||||
"puts a picture on the preview inside Element Web",
|
||||
async ({ asWidget }) => {
|
||||
test.slow();
|
||||
const { brooks } = asWidget;
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
|
||||
const frame = brooks.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
const preview = frame.locator("video").first();
|
||||
const camera = await cameraColour(preview);
|
||||
|
||||
await frame.getByRole("button", { name: "Camera", exact: true }).click();
|
||||
const tile = frame
|
||||
.getByRole("group", { name: "Background effects" })
|
||||
.getByRole("menuitemradio", { name: "Background 1" });
|
||||
const picture = await averageColour(tile.locator("img"));
|
||||
await tile.click();
|
||||
await expect(tile).toHaveAttribute("aria-checked", "true");
|
||||
await expectWearing(preview, picture, camera);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test as setup } from "@playwright/test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const EMBEDDED_DIST = "embedded/web/dist";
|
||||
|
||||
setup("build the embedded package", () => {
|
||||
setup.setTimeout(10 * 60 * 1000);
|
||||
mkdirSync(EMBEDDED_DIST, { recursive: true });
|
||||
execFileSync("pnpm", ["build:embedded", "--outDir", EMBEDDED_DIST], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, type Route } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../../fixtures/widget-user.ts";
|
||||
import { HOST2, TestHelpers } from "../test-helpers.ts";
|
||||
|
||||
/** Where the other site's Element Web serves the embedded package from */
|
||||
const CONFIG_JSON = "**/widgets/element-call/config.json";
|
||||
|
||||
const HOST_ANSWERS: Record<string, (route: Route) => Promise<void>> = {
|
||||
"a 404": async (route) => route.fulfill({ status: 404 }),
|
||||
"a failed fetch": async (route) => route.abort("failed"),
|
||||
"an HTML page": async (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: "<!doctype html><title>Element</title>",
|
||||
}),
|
||||
};
|
||||
|
||||
widgetTest.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"The widget suite does not run on Firefox, see the other specs here",
|
||||
);
|
||||
|
||||
for (const [answer, respond] of Object.entries(HOST_ANSWERS)) {
|
||||
widgetTest(
|
||||
`reaches the lobby when config.json gets ${answer}`,
|
||||
async ({ addUser }) => {
|
||||
const { page } = await addUser("Brooks", HOST2);
|
||||
await TestHelpers.createRoom("Config Room", page);
|
||||
|
||||
let answered = 0;
|
||||
await page.route(CONFIG_JSON, async (route) => {
|
||||
answered++;
|
||||
await respond(route);
|
||||
});
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(page);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByTestId("lobby_joinCall"),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
expect(answered).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -144,7 +144,11 @@ export class TestHelpers {
|
||||
|
||||
await this.dismissStartupToasts(page);
|
||||
|
||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||
// The other site's Element Web opens the embedded package it serves itself
|
||||
// (see docker-compose-playwright.yml), not the dev server's app.
|
||||
if (host !== HOST2) {
|
||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||
}
|
||||
|
||||
const clientHandle = await page.evaluateHandle(() =>
|
||||
window.mxMatrixClientPeg.get(),
|
||||
@@ -153,29 +157,40 @@ export class TestHelpers {
|
||||
return { page, clientHandle, mxId };
|
||||
}
|
||||
|
||||
// Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
|
||||
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
|
||||
// dismiss (if part of expected toats)
|
||||
// Dismisses the toasts a fresh device gets after sign-in.
|
||||
//
|
||||
// Toasts stack and only the top one can be dismissed, so look
|
||||
// at what is on top each time.
|
||||
public static async dismissStartupToasts(page: Page): Promise<void> {
|
||||
const expectedToasts = [
|
||||
const expectedToasts: {
|
||||
title: string;
|
||||
button: string;
|
||||
confirm?: string;
|
||||
}[] = [
|
||||
{ title: "Failed to load service worker", button: "OK" },
|
||||
{ title: "Verify this device", button: "Later" },
|
||||
{ title: "Back up your chats", button: "Not now" },
|
||||
{ title: "Turn on key storage", button: "Dismiss" },
|
||||
{
|
||||
title: "Turn on key storage",
|
||||
button: "Dismiss",
|
||||
confirm: "Yes, dismiss",
|
||||
},
|
||||
{ title: "Notifications", button: "Dismiss" },
|
||||
{ title: "Element does not support this browser", button: "Dismiss" },
|
||||
];
|
||||
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
const toastTitle = toast.locator(".mx_Toast_title h2");
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
await toast.waitFor({ state: "visible", timeout: 700 });
|
||||
const title = await toast.locator(".mx_Toast_title h2").textContent();
|
||||
const title = await toastTitle.textContent();
|
||||
if (!title) break;
|
||||
|
||||
// Find the matching toast config
|
||||
const toastConfig = expectedToasts.find((t) =>
|
||||
title?.includes(t.title),
|
||||
);
|
||||
const toastConfig = expectedToasts.find((t) => title.includes(t.title));
|
||||
|
||||
if (toastConfig) {
|
||||
// Bounded so that a renamed button leaves the toast in place and the
|
||||
@@ -183,6 +198,16 @@ export class TestHelpers {
|
||||
await toast
|
||||
.getByRole("button", { name: toastConfig.button })
|
||||
.click({ timeout: 5000 });
|
||||
if (toastConfig.confirm) {
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog
|
||||
.getByRole("button", { name: toastConfig.confirm })
|
||||
.click({ timeout: 5000 });
|
||||
await expect(dialog).toBeHidden({ timeout: 5000 });
|
||||
}
|
||||
// The same element shows the next toast a moment after the click, so
|
||||
// reading it straight away acts on the dismissed one a second time
|
||||
await expect(toastTitle).not.toHaveText(title, { timeout: 5000 });
|
||||
} else {
|
||||
// Unknown toast. We don't want to act on unknown toasts
|
||||
break;
|
||||
@@ -194,20 +219,6 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
public static async closeReleaseAnnouncement(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
.getByRole("dialog", { name })
|
||||
.getByRole("button", { name: "OK" })
|
||||
.click({ timeout: 2000 });
|
||||
} catch {
|
||||
// Announcement not shown; nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
public static async expandAllSections(page: Page): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
@@ -223,16 +234,7 @@ export class TestHelpers {
|
||||
page: Page,
|
||||
andInvite: string[] = [],
|
||||
): Promise<void> {
|
||||
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
|
||||
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.click();
|
||||
|
||||
await page.getByRole("menuitem", { name: "New Room" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
await TestHelpers.chooseFromRoomListNewMenu(page, "New Room");
|
||||
await page.getByRole("textbox", { name: "Name" }).fill(name);
|
||||
await page.getByRole("button", { name: "Create room" }).click();
|
||||
await expect(page.getByText("You created this room.")).toBeVisible({
|
||||
@@ -259,6 +261,24 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the room list's "New" menu and picks the given item.
|
||||
*
|
||||
* Driven by keyboard, as a toast arriving late sits over the button and
|
||||
* swallows clicks.
|
||||
*/
|
||||
public static async chooseFromRoomListNewMenu(
|
||||
page: Page,
|
||||
itemName: string,
|
||||
): Promise<void> {
|
||||
await TestHelpers.dismissStartupToasts(page);
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New", exact: true })
|
||||
.press("Enter");
|
||||
await page.getByRole("menuitem", { name: itemName }).press("Enter");
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a room invite using the room name.
|
||||
* Locatest the invite in the room list.
|
||||
@@ -268,9 +288,7 @@ export class TestHelpers {
|
||||
roomName: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
|
||||
await TestHelpers.openRoomInvite(page, roomName);
|
||||
await page.getByRole("button", { name: "Accept" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -376,6 +394,28 @@ export class TestHelpers {
|
||||
return page.locator(`[aria-label^="Open room ${roomName}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a pending invite from the room list by its room name.
|
||||
*
|
||||
* Element Web collapses the Invites section every time it appears, so it is
|
||||
* expanded here once the invite has arrived.
|
||||
*/
|
||||
public static async openRoomInvite(
|
||||
page: Page,
|
||||
roomName: string,
|
||||
): Promise<void> {
|
||||
const invitesSection = page.getByRole("button", {
|
||||
name: "Toggle Invites section",
|
||||
});
|
||||
await expect(invitesSection).toBeVisible({ timeout: 10000 });
|
||||
// Toasts sit over the top of the room list, where this header is.
|
||||
await TestHelpers.dismissStartupToasts(page);
|
||||
if ((await invitesSection.getAttribute("aria-expanded")) !== "true") {
|
||||
await invitesSection.click();
|
||||
}
|
||||
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
|
||||
Generated
+48
-26
@@ -8,7 +8,7 @@ overrides:
|
||||
'@livekit/components-core>rxjs': ^7.8.1
|
||||
'@livekit/track-processors>@mediapipe/tasks-vision': ^0.10.18
|
||||
tar: ^7.5.11
|
||||
minimatch: ^10.2.3
|
||||
minimatch: ^11.0.0
|
||||
glob: ^10.5.0
|
||||
qs: ^6.15.2
|
||||
js-yaml: ^4.2.0
|
||||
@@ -160,6 +160,9 @@ importers:
|
||||
eslint-plugin-storybook:
|
||||
specifier: ^10.3.6
|
||||
version: 10.6.2(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
|
||||
fake-indexeddb:
|
||||
specifier: ^6.2.5
|
||||
version: 6.2.5
|
||||
fetch-mock:
|
||||
specifier: 11.1.5
|
||||
version: 11.1.5(node-fetch@2.7.0)
|
||||
@@ -188,8 +191,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
specifier: github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.20.0
|
||||
@@ -1142,6 +1145,9 @@ packages:
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@isaacs/nonrecursive-glob-match@1.0.3':
|
||||
resolution: {integrity: sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==}
|
||||
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0':
|
||||
resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==}
|
||||
peerDependencies:
|
||||
@@ -3527,8 +3533,8 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -4048,6 +4054,10 @@ packages:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fake-indexeddb@6.2.5:
|
||||
resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -4724,8 +4734,8 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
resolution: {gitHosted: true, integrity: sha512-Ldt/VkCzBtre6m1cn0FqgrzRV0GrgWIoARgQUQO58ef2Edu2ERFCINPzp/psFS5RwoN3RIy9Rm4oPVc1ATkEWA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a}
|
||||
version: 45.0.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
@@ -4761,9 +4771,9 @@ packages:
|
||||
minimalistic-crypto-utils@1.0.1:
|
||||
resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==}
|
||||
|
||||
minimatch@10.2.6:
|
||||
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
minimatch@11.0.4:
|
||||
resolution: {integrity: sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
@@ -4936,8 +4946,8 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-retry@8.0.1:
|
||||
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
|
||||
p-retry@9.0.0:
|
||||
resolution: {integrity: sha512-HuEats8gYn/UUYa5uyveyQGv75mQmwdFtuMgnZlS12dj/7bdnJSO6keaTjgcZUhvkANTj5EO3lACUmV3xmgj0Q==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
p-try@2.2.0:
|
||||
@@ -5308,6 +5318,10 @@ packages:
|
||||
randomfill@1.0.4:
|
||||
resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==}
|
||||
|
||||
re2js@2.9.1:
|
||||
resolution: {integrity: sha512-JIT3bEEUZTOR2YS1walNEIfH4cF/cQeZcBUFq7ldqO6lR6g0X5CBq/S8rsa9omzZcsNf8qc36jCkWpKKtncrZg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
react-docgen-typescript@2.4.0:
|
||||
resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
|
||||
peerDependencies:
|
||||
@@ -6895,7 +6909,7 @@ snapshots:
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.3.1
|
||||
minimatch: 10.2.6
|
||||
minimatch: 11.0.4
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -6974,7 +6988,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
minimatch: 10.2.6
|
||||
minimatch: 11.0.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7110,6 +7124,8 @@ snapshots:
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
|
||||
'@isaacs/nonrecursive-glob-match@1.0.3': {}
|
||||
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
glob: 10.5.0
|
||||
@@ -8769,7 +8785,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.69.0
|
||||
'@typescript-eslint/visitor-keys': 8.69.0
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
minimatch: 10.2.6
|
||||
minimatch: 11.0.4
|
||||
semver: 7.8.5
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
@@ -8784,7 +8800,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.72.0
|
||||
'@typescript-eslint/visitor-keys': 8.72.0
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
minimatch: 10.2.6
|
||||
minimatch: 11.0.4
|
||||
semver: 7.8.5
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
@@ -9109,7 +9125,7 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
brace-expansion@5.0.12:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -9654,7 +9670,7 @@ snapshots:
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
levn: 0.4.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 10.2.6
|
||||
minimatch: 11.0.4
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
strip-ansi: 6.0.1
|
||||
@@ -9712,6 +9728,8 @@ snapshots:
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
fake-indexeddb@6.2.5: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
@@ -9772,7 +9790,7 @@ snapshots:
|
||||
|
||||
filelist@1.0.6:
|
||||
dependencies:
|
||||
minimatch: 10.2.6
|
||||
minimatch: 11.0.4
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
@@ -9872,7 +9890,7 @@ snapshots:
|
||||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 10.2.6
|
||||
minimatch: 11.0.4
|
||||
minipass: 7.1.3
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
@@ -9997,7 +10015,7 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
jsonc-parser: 3.3.1
|
||||
magic-string: 1.2.3
|
||||
minimatch: 10.2.6
|
||||
minimatch: 11.0.4
|
||||
ora: 9.4.1
|
||||
react: 19.2.8
|
||||
react-i18next: 17.0.13(i18next@26.4.1(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
|
||||
@@ -10395,7 +10413,7 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
@@ -10405,7 +10423,8 @@ snapshots:
|
||||
loglevel: 1.9.2
|
||||
matrix-events-sdk: 0.0.1
|
||||
matrix-widget-api: 1.20.0
|
||||
p-retry: 8.0.1
|
||||
p-retry: 9.0.0
|
||||
re2js: 2.9.1
|
||||
sdp-transform: 3.0.0
|
||||
unhomoglyph: 1.0.6
|
||||
|
||||
@@ -10440,9 +10459,10 @@ snapshots:
|
||||
|
||||
minimalistic-crypto-utils@1.0.1: {}
|
||||
|
||||
minimatch@10.2.6:
|
||||
minimatch@11.0.4:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
'@isaacs/nonrecursive-glob-match': 1.0.3
|
||||
brace-expansion: 5.0.12
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -10726,7 +10746,7 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-retry@8.0.1:
|
||||
p-retry@9.0.0:
|
||||
dependencies:
|
||||
is-network-error: 1.3.2
|
||||
|
||||
@@ -11150,6 +11170,8 @@ snapshots:
|
||||
randombytes: 2.1.0
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
re2js@2.9.1: {}
|
||||
|
||||
react-docgen-typescript@2.4.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ overrides:
|
||||
# Security fixes:
|
||||
# - https://github.com/advisories/GHSA-7r86-cg39-jmmj
|
||||
# - https://github.com/advisories/GHSA-23c5-xmqv-rm74
|
||||
"minimatch": "^10.2.3"
|
||||
"minimatch": "^11.0.0"
|
||||
# Security fix: https://github.com/element-hq/element-call/security/dependabot/109
|
||||
"glob": "^10.5.0"
|
||||
# Security fixes:
|
||||
|
||||
@@ -162,6 +162,7 @@ describe("createLocalMedia", () => {
|
||||
media.release("camera");
|
||||
expect(trackBehind(camera).stop).toHaveBeenCalled();
|
||||
expect(media.tracks$.value).toEqual([microphone]);
|
||||
expect(media.requests.map((r) => r.source)).toEqual(["microphone"]);
|
||||
|
||||
media.stop();
|
||||
expect(trackBehind(microphone).stop).toHaveBeenCalled();
|
||||
|
||||
@@ -107,6 +107,7 @@ export function createLocalMedia(
|
||||
if (source !== "microphone" && source !== "camera") return;
|
||||
capturedTracks.get(source)?.stop();
|
||||
capturedTracks.delete(source);
|
||||
requests.delete(source);
|
||||
publishCapturedTracks();
|
||||
};
|
||||
|
||||
|
||||
@@ -184,6 +184,7 @@ export function createRTCParticipation(
|
||||
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
|
||||
matrixRTCMode,
|
||||
sendNotificationType: options.sendNotificationType,
|
||||
callIntent: options.callIntent,
|
||||
applicationData: options.applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
|
||||
+8
-4
@@ -12,6 +12,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
type Transport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -103,11 +104,14 @@ export interface RTCParticipationOptions {
|
||||
* to a notification (ringing, timeouts, declines) is the application's job.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* The kind of call the local member intends to take part in: `"m.audio"`
|
||||
* or `"m.video"`. Carried by the membership as a field of its own.
|
||||
*/
|
||||
callIntent?: RTCCallIntent;
|
||||
/**
|
||||
* Whatever the application wants to say about itself in the membership,
|
||||
* under namespaced keys. Opaque to the SDK, apart from `m.call.intent`
|
||||
* (`"audio"` or `"video"`), which the membership format carries as a
|
||||
* field of its own.
|
||||
* under namespaced keys. Opaque to the SDK.
|
||||
*/
|
||||
applicationData?: Record<string, unknown>;
|
||||
/** Timings the host has configured; the defaults otherwise. */
|
||||
@@ -287,7 +291,7 @@ export interface RTCMember {
|
||||
memberId: string;
|
||||
/** Which transport this member is on; undefined when the membership has none. */
|
||||
transport$: Behavior<TransportMetadata | undefined>;
|
||||
/** What the member's application says about itself, e.g. `m.call.intent`. */
|
||||
/** What the member's application says about itself, e.g. `intent`. */
|
||||
applicationData$: Behavior<Record<string, unknown>>;
|
||||
|
||||
displayName$: Behavior<string>;
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("joinJsSdkSession", () => {
|
||||
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transportWithAlias], {
|
||||
notificationType: "ring",
|
||||
callIntent: undefined,
|
||||
applicationData: {},
|
||||
applicationData: undefined,
|
||||
manageMediaKeys: true,
|
||||
delayedLeaveEventRestartMs: 300_000,
|
||||
delayedLeaveEventDelayMs: 3_600_000,
|
||||
@@ -95,13 +95,14 @@ describe("joinJsSdkSession", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
|
||||
it("passes the call intent and application data through and sends sticky events in Matrix 2.0 mode", () => {
|
||||
const s = session();
|
||||
|
||||
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
@@ -110,7 +111,7 @@ describe("joinJsSdkSession", () => {
|
||||
ownMemberMock,
|
||||
[transportWithAlias],
|
||||
expect.objectContaining({
|
||||
callIntent: "audio",
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
@@ -124,7 +125,8 @@ describe("joinJsSdkSession", () => {
|
||||
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||
applicationData: { "m.call.intent": "audio", custom: 1 },
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
timings: defaultSessionTimings,
|
||||
delayedLeave: defaultSessionTimings.delayedLeave,
|
||||
});
|
||||
@@ -133,7 +135,7 @@ describe("joinJsSdkSession", () => {
|
||||
ownMemberMock,
|
||||
[transport],
|
||||
expect.objectContaining({
|
||||
callIntent: "audio",
|
||||
callIntent: "m.audio",
|
||||
applicationData: { custom: 1 },
|
||||
unstableSendStickyEvents: true,
|
||||
}),
|
||||
|
||||
@@ -23,6 +23,7 @@ interface Options {
|
||||
encryptMedia: boolean;
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
callIntent?: RTCCallIntent;
|
||||
applicationData?: Record<string, unknown>;
|
||||
timings: SessionTimings;
|
||||
/** The delayed leave timings to use: delegated to the SFU or not. */
|
||||
@@ -43,15 +44,12 @@ export function joinJsSdkSession(
|
||||
encryptMedia,
|
||||
matrixRTCMode,
|
||||
sendNotificationType,
|
||||
callIntent,
|
||||
applicationData,
|
||||
timings,
|
||||
delayedLeave,
|
||||
}: Options,
|
||||
): void {
|
||||
// The membership format carries the intent as a field of its own
|
||||
const { "m.call.intent": intent, ...rest } = applicationData ?? {};
|
||||
const callIntent =
|
||||
typeof intent === "string" ? (intent as RTCCallIntent) : undefined;
|
||||
// Give up on the network as soon as either the sync has been down for the
|
||||
// grace period or the delayed leave has probably been sent, whichever is
|
||||
// sooner
|
||||
@@ -78,7 +76,7 @@ export function joinJsSdkSession(
|
||||
{
|
||||
notificationType: sendNotificationType,
|
||||
callIntent,
|
||||
applicationData: rest,
|
||||
applicationData,
|
||||
manageMediaKeys: encryptMedia,
|
||||
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
|
||||
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
|
||||
|
||||
@@ -225,6 +225,51 @@ describe("createLivekitMediaTrack", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// LiveKit's media stream track is the processed one while a processor runs,
|
||||
// and that one names no device; the camera behind it does
|
||||
it.each([
|
||||
{ deviceId: "cam-1", switches: false },
|
||||
{ deviceId: "cam-2", switches: true },
|
||||
])(
|
||||
"leaves a processed camera alone unless its device changes: $deviceId",
|
||||
async ({ deviceId, switches }) => {
|
||||
vi.stubGlobal("MediaStreamTrack", class {});
|
||||
onTestFinished(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
const camera = Object.assign(Object.create(LocalVideoTrack.prototype), {
|
||||
source: Track.Source.Camera,
|
||||
_mediaStreamTrack: {
|
||||
readyState: "live",
|
||||
label: "",
|
||||
getSettings: () => ({ deviceId: "cam-1" }),
|
||||
},
|
||||
// What LiveKit shows as the track, with no device of its own
|
||||
processor: {
|
||||
processedTrack: { label: "", getSettings: () => ({}) },
|
||||
},
|
||||
}) as LocalVideoTrack;
|
||||
const switchActiveDevice = vi.fn().mockResolvedValue(true);
|
||||
const { participant, publication, room } = fakes({
|
||||
track: camera as unknown as FakeTrack,
|
||||
});
|
||||
const track = createLocalLivekitMediaTrack(
|
||||
scope,
|
||||
Object.assign(participant, {
|
||||
isLocal: true,
|
||||
}) as unknown as LocalParticipant,
|
||||
publication as unknown as TrackPublication,
|
||||
Object.assign(room, { switchActiveDevice }),
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
await track.setDevice(deviceId);
|
||||
expect(switchActiveDevice.mock.calls).toEqual(
|
||||
switches ? [["videoinput", deviceId, false]] : [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// LiveKit stops a camera that is turned off; the processor is built when it
|
||||
// is turned on again
|
||||
it.each([
|
||||
|
||||
@@ -199,11 +199,13 @@ export function createLocalLivekitMediaTrack(
|
||||
throw new MatrixRTCError(`A ${base.source} track has no device`);
|
||||
// LiveKit decides whether to restart by the constraints it keeps, which
|
||||
// a browser that does not echo the device id back (Firefox) leaves
|
||||
// without one; a track already on the device is left alone here
|
||||
// without one; a track already on the device is left alone here. Asked
|
||||
// of the camera track itself: with a processor on, LiveKit's media
|
||||
// stream track is the processed one, which names no device
|
||||
const current = publication.track;
|
||||
if (
|
||||
current instanceof LocalTrack &&
|
||||
current.mediaStreamTrack.getSettings().deviceId === deviceId
|
||||
(await current.getDeviceId(false)) === deviceId
|
||||
)
|
||||
return;
|
||||
await room.switchActiveDevice(
|
||||
|
||||
+9
-22
@@ -23,14 +23,13 @@ import {
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { HomePage } from "./home/HomePage";
|
||||
import { LoginPage } from "./auth/LoginPage";
|
||||
import { RegisterPage } from "./auth/RegisterPage";
|
||||
import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
@@ -131,22 +130,9 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
.catch(logger.error);
|
||||
});
|
||||
|
||||
// As a widget, the client comes from the host over the widget API. Standalone,
|
||||
// Element Call finds one itself, so there is nothing to wait for here.
|
||||
const [widgetClient, setWidgetClient] = useState<MatrixClient | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (widget === null) return;
|
||||
widget.client
|
||||
.then(setWidgetClient)
|
||||
.catch((e) => logger.error("Failed to obtain the host's client", e));
|
||||
}, [widget]);
|
||||
const clientReady = widget === null || widgetClient !== undefined;
|
||||
|
||||
const content =
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient}>
|
||||
const content = loaded ? (
|
||||
<ClientProvider session={vm.clientSession}>
|
||||
<ClientManagerProvider value={vm.clientManager}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
|
||||
<Sentry.ErrorBoundary
|
||||
@@ -161,10 +147,11 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
</Sentry.ErrorBoundary>
|
||||
</BackgroundEffectsProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
</ClientManagerProvider>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
|
||||
+2
-16
@@ -12,6 +12,7 @@ import { type FC, type PropsWithChildren } from "react";
|
||||
|
||||
import { ClientContextProvider } from "./ClientContext";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { hostClientSession } from "./state/ClientSession";
|
||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||
import {
|
||||
type HostBridge,
|
||||
@@ -26,22 +27,7 @@ const TestComponent: FC<
|
||||
}>
|
||||
> = ({ client, hostBridge = nullHostBridge, children }) => {
|
||||
return (
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
state: "valid",
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
reactions: true,
|
||||
},
|
||||
setClient: vi.fn(),
|
||||
authenticated: {
|
||||
client,
|
||||
isPasswordlessUser: true,
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ClientContextProvider value={hostClientSession(client)}>
|
||||
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
|
||||
</ClientContextProvider>
|
||||
);
|
||||
|
||||
+6
-10
@@ -15,7 +15,7 @@ import {
|
||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { useClientState } from "./ClientContext";
|
||||
import { useClient } from "./ClientContext";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
|
||||
export enum Size {
|
||||
@@ -74,7 +74,7 @@ export const Avatar: FC<Props> = ({
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const clientState = useClientState();
|
||||
const { client } = useClient();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const sizePx = useMemo(
|
||||
@@ -87,7 +87,7 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra media
|
||||
// In theory, a change in `client` or `sizePx` could run extra media
|
||||
// downloads, but in practice they should be stable long before this code runs.
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
@@ -99,12 +99,8 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
if (hostBridge.downloadMedia) {
|
||||
blob = hostBridge.downloadMedia(src);
|
||||
} else if (
|
||||
clientState?.state === "valid" &&
|
||||
clientState.authenticated?.client &&
|
||||
sizePx
|
||||
) {
|
||||
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
|
||||
} else if (client && sizePx) {
|
||||
blob = getAvatarFromServer(client, src, sizePx);
|
||||
} else {
|
||||
setAvatarUrl(undefined);
|
||||
return;
|
||||
@@ -133,7 +129,7 @@ export const Avatar: FC<Props> = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [clientState, hostBridge, src, sizePx]);
|
||||
}, [client, hostBridge, src, sizePx]);
|
||||
|
||||
return (
|
||||
<CompoundAvatar
|
||||
|
||||
+93
-60
@@ -6,12 +6,25 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { act, render, renderHook } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC } from "react";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ClientProvider, useClientState } from "./ClientContext";
|
||||
import {
|
||||
ClientManagerProvider,
|
||||
ClientProvider,
|
||||
useClientManager,
|
||||
useClientManagerIfAny,
|
||||
useClientSessionState,
|
||||
} from "./ClientContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
hostClientSession,
|
||||
} from "./state/ClientSession";
|
||||
|
||||
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
({
|
||||
@@ -24,67 +37,47 @@ const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
|
||||
/** Reports what the context says, so a test can assert on it. */
|
||||
const ShowClientState: FC = () => {
|
||||
const state = useClientState();
|
||||
if (state === undefined) return <span>loading</span>;
|
||||
if (state.state === "error") return <span>error</span>;
|
||||
return (
|
||||
<span>{state.authenticated?.client.getUserId() ?? "unauthenticated"}</span>
|
||||
);
|
||||
const state = useClientSessionState();
|
||||
switch (state.state) {
|
||||
case "signedIn":
|
||||
return <span>{state.client.getUserId()}</span>;
|
||||
case "signedOut":
|
||||
return <span>unauthenticated</span>;
|
||||
default:
|
||||
return <span>{state.state}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const client = mockClient();
|
||||
|
||||
const { container } = render(
|
||||
const renderProvider = (
|
||||
session: ClientSession,
|
||||
children = <ShowClientState />,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={client}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
<ClientProvider session={session}>{children}</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const { container } = renderProvider(hostClientSession(mockClient()));
|
||||
|
||||
// Available on the very first render: a supplied client needs no session
|
||||
// restoring, so there is no loading state to pass through.
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
});
|
||||
|
||||
test("does not claim exclusive use of storage when given a client", () => {
|
||||
// The channel is created when the module loads, so spy on the prototype
|
||||
// rather than trying to replace the global.
|
||||
const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage");
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={mockClient()}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// The broadcast shuts down other instances to protect Element Call's own
|
||||
// stores. A host's client brings its own, so there is nothing to protect.
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
|
||||
postMessage.mockRestore();
|
||||
});
|
||||
|
||||
test("follows the client when the host swaps it", () => {
|
||||
const first = mockClient();
|
||||
const second = mockClient("@bob:example.org");
|
||||
|
||||
const { container, rerender } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={first}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
const { container, rerender } = renderProvider(
|
||||
hostClientSession(mockClient()),
|
||||
);
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
|
||||
// A host that re-authenticates hands us a new client on a mounted component
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={second}>
|
||||
<ClientProvider
|
||||
session={hostClientSession(mockClient("@bob:example.org"))}
|
||||
>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
@@ -93,23 +86,63 @@ test("follows the client when the host swaps it", () => {
|
||||
expect(container.textContent).toBe("@bob:example.org");
|
||||
});
|
||||
|
||||
test("finds a client of its own when the host supplies none", async () => {
|
||||
const client = mockClient();
|
||||
vi.doMock("./utils/spa", () => ({
|
||||
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
test("follows a session of its own as it is restored", () => {
|
||||
const state$ = new BehaviorSubject<ClientSessionState>({ state: "loading" });
|
||||
const { container } = renderProvider({ state$ });
|
||||
|
||||
// Nothing to show until a session has been restored or created
|
||||
expect(container.textContent).toBe("loading");
|
||||
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
|
||||
act(() =>
|
||||
state$.next({
|
||||
state: "signedIn",
|
||||
client: mockClient(),
|
||||
passwordlessUser: true,
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
});
|
||||
|
||||
vi.doUnmock("./utils/spa");
|
||||
test("has no one signed in when the session says so", () => {
|
||||
const { container } = renderProvider({
|
||||
state$: new BehaviorSubject<ClientSessionState>({ state: "signedOut" }),
|
||||
});
|
||||
expect(container.textContent).toBe("unauthenticated");
|
||||
});
|
||||
|
||||
test("shows the session's error in place of its children", () => {
|
||||
const { container } = renderProvider({
|
||||
state$: new BehaviorSubject<ClientSessionState>({
|
||||
state: "error",
|
||||
error: new Error("Opened elsewhere"),
|
||||
}),
|
||||
});
|
||||
// The error page, not the children
|
||||
expect(container.textContent).not.toContain("error");
|
||||
expect(container.textContent).toContain("Something went wrong");
|
||||
});
|
||||
|
||||
test("hands the session's manager to views that need one", () => {
|
||||
const manager: ClientManager = {
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
};
|
||||
const { result } = renderHook(
|
||||
() => [useClientManager(), useClientManagerIfAny()],
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<ClientManagerProvider value={manager}>
|
||||
<>{children}</>
|
||||
</ClientManagerProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
expect(result.current).toEqual([manager, manager]);
|
||||
});
|
||||
|
||||
test("has no manager for a host's session", () => {
|
||||
// The component and the widget provide none; a view that insists on one is
|
||||
// in the wrong build
|
||||
expect(renderHook(() => useClientManagerIfAny()).result.current).toBeNull();
|
||||
expect(() => renderHook(() => useClientManager())).toThrow();
|
||||
});
|
||||
|
||||
+51
-353
@@ -5,30 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
createContext,
|
||||
use,
|
||||
useRef,
|
||||
useMemo,
|
||||
type JSX,
|
||||
} from "react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC, useEffect, createContext, use, type JSX } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { ErrorPage } from "./FullScreenView";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
|
||||
import { useBehavior } from "./useBehavior";
|
||||
import { constant } from "./state/Behavior";
|
||||
import {
|
||||
PosthogAnalytics,
|
||||
RegistrationType,
|
||||
} from "./analytics/PosthogAnalytics";
|
||||
import { useEventTarget } from "./useEvents";
|
||||
import { OpenElsewhereError } from "./RichError";
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
} from "./state/ClientSession";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -37,360 +25,70 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export type ClientState = ValidClientState | ErrorState;
|
||||
|
||||
export type ValidClientState = {
|
||||
state: "valid";
|
||||
authenticated?: AuthenticatedClient;
|
||||
// 'Disconnected' rather than 'connected' because it tracks specifically
|
||||
// whether the client is supposed to be connected but is not
|
||||
disconnected: boolean;
|
||||
supportedFeatures: {
|
||||
reactions: boolean;
|
||||
};
|
||||
setClient: (client: MatrixClient, session: Session) => void;
|
||||
// Without a provider, no one is signed in and there is no session to act on
|
||||
const noClientSession: ClientSession = {
|
||||
state$: constant({ state: "signedOut" }),
|
||||
};
|
||||
|
||||
export type AuthenticatedClient = {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
changePassword: (password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
export type ErrorState = {
|
||||
state: "error";
|
||||
error: Error;
|
||||
};
|
||||
|
||||
const ClientContext = createContext<ClientState | undefined>(undefined);
|
||||
const ClientContext = createContext<ClientSession>(noClientSession);
|
||||
|
||||
export const ClientContextProvider = ClientContext.Provider;
|
||||
|
||||
export const useClientState = (): ClientState | undefined => use(ClientContext);
|
||||
export const useClientSessionState = (): ClientSessionState =>
|
||||
useBehavior(use(ClientContext).state$);
|
||||
|
||||
export function useClient(): {
|
||||
client?: MatrixClient;
|
||||
setClient?: (client: MatrixClient, session: Session) => void;
|
||||
} {
|
||||
let client;
|
||||
let setClient;
|
||||
|
||||
const clientState = useClientState();
|
||||
if (clientState?.state === "valid") {
|
||||
client = clientState.authenticated?.client;
|
||||
setClient = clientState.setClient;
|
||||
}
|
||||
|
||||
return { client, setClient };
|
||||
export function useClient(): { client?: MatrixClient } {
|
||||
const state = useClientSessionState();
|
||||
return { client: state.state === "signedIn" ? state.client : undefined };
|
||||
}
|
||||
|
||||
// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
|
||||
export function useClientLegacy(): {
|
||||
client?: MatrixClient;
|
||||
setClient?: (client: MatrixClient, session: Session) => void;
|
||||
passwordlessUser: boolean;
|
||||
loading: boolean;
|
||||
authenticated: boolean;
|
||||
logout?: () => void;
|
||||
error?: Error;
|
||||
} {
|
||||
const clientState = useClientState();
|
||||
// Null where the session is a host's: the component, and the widget
|
||||
const ClientManagerContext = createContext<ClientManager | null>(null);
|
||||
|
||||
let client;
|
||||
let setClient;
|
||||
let passwordlessUser = false;
|
||||
let loading = true;
|
||||
let error;
|
||||
let authenticated = false;
|
||||
let logout;
|
||||
export const ClientManagerProvider = ClientManagerContext.Provider;
|
||||
|
||||
if (clientState?.state === "valid") {
|
||||
client = clientState.authenticated?.client;
|
||||
setClient = clientState.setClient;
|
||||
passwordlessUser = clientState.authenticated?.isPasswordlessUser ?? false;
|
||||
loading = false;
|
||||
authenticated = client !== undefined;
|
||||
logout = clientState.authenticated?.logout;
|
||||
} else if (clientState?.state === "error") {
|
||||
error = clientState.error;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
setClient,
|
||||
passwordlessUser,
|
||||
loading,
|
||||
authenticated,
|
||||
logout,
|
||||
error,
|
||||
};
|
||||
/**
|
||||
* The manager of a session that is Element Call's own, for views that only
|
||||
* the standalone app mounts: the login and registration pages. Throws
|
||||
* elsewhere, as those views have no business being there.
|
||||
*/
|
||||
export function useClientManager(): ClientManager {
|
||||
const manager = use(ClientManagerContext);
|
||||
if (manager === null)
|
||||
throw new Error("This view needs a session of Element Call's own");
|
||||
return manager;
|
||||
}
|
||||
|
||||
const loadChannel =
|
||||
"BroadcastChannel" in window ? new BroadcastChannel("load") : null;
|
||||
/**
|
||||
* The session's manager, or null where the session is a host's. For views
|
||||
* shared with the widget, which can offer to sign out only when that is ours
|
||||
* to do.
|
||||
*/
|
||||
export const useClientManagerIfAny = (): ClientManager | null =>
|
||||
use(ClientManagerContext);
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
/**
|
||||
* The client Element Call should use.
|
||||
*
|
||||
* An application hosting Element Call as a component already has a client,
|
||||
* and owns the user's session; supplying it here means Element Call neither
|
||||
* authenticates anyone nor manages their session. Left out, Element Call finds a client
|
||||
* itself — from the widget API, or by restoring or creating a session of its
|
||||
* own.
|
||||
*/
|
||||
client?: MatrixClient;
|
||||
/** a session from Element Call SPA, from a widget or the host (EW) */
|
||||
session: ClientSession;
|
||||
}
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
// null = signed out, undefined = loading
|
||||
const [initClientState, setInitClientState] = useState<
|
||||
InitResult | null | undefined
|
||||
>(
|
||||
client === undefined
|
||||
? undefined
|
||||
: // A supplied client belongs to the host, so there is no session of ours
|
||||
// to restore and nothing to wait for.
|
||||
{ client, passwordlessUser: false },
|
||||
);
|
||||
|
||||
const initializing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (client !== undefined) {
|
||||
// Nothing to load, but a host may hand us a different client later — on
|
||||
// re-authenticating, say — so follow whichever one it has given us.
|
||||
setInitClientState((current) =>
|
||||
current?.client === client
|
||||
? current
|
||||
: { client, passwordlessUser: false },
|
||||
);
|
||||
// Analytics still need to follow the user's choices.
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
return;
|
||||
}
|
||||
// In case the component is mounted, unmounted, and remounted quickly (as
|
||||
// React does in strict mode), we need to make sure not to doubly initialize
|
||||
// the client.
|
||||
if (initializing.current) return;
|
||||
initializing.current = true;
|
||||
|
||||
loadClient()
|
||||
.then((initResult) => {
|
||||
setInitClientState(initResult);
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
})
|
||||
.catch((err) => logger.error(err))
|
||||
.finally(() => (initializing.current = false));
|
||||
}, [client]);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
const session = loadSession();
|
||||
if (!initClientState?.client || !session) {
|
||||
return;
|
||||
}
|
||||
|
||||
await initClientState.client.setPassword(
|
||||
{
|
||||
type: "m.login.password",
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: session.user_id,
|
||||
},
|
||||
user: session.user_id,
|
||||
password: session.tempPassword,
|
||||
},
|
||||
password,
|
||||
);
|
||||
|
||||
saveSession({ ...session, passwordlessUser: false });
|
||||
|
||||
setInitClientState({
|
||||
...initClientState,
|
||||
passwordlessUser: false,
|
||||
});
|
||||
},
|
||||
[initClientState],
|
||||
);
|
||||
|
||||
const setClient = useCallback(
|
||||
(client: MatrixClient, session: Session) => {
|
||||
const oldClient = initClientState?.client;
|
||||
if (oldClient && oldClient !== client) {
|
||||
oldClient.stopClient();
|
||||
}
|
||||
|
||||
saveSession(session);
|
||||
setInitClientState({
|
||||
client,
|
||||
passwordlessUser: session.passwordlessUser,
|
||||
});
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
},
|
||||
[initClientState?.client],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const client = initClientState?.client;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.logout(true);
|
||||
await client.clearStores();
|
||||
clearSession();
|
||||
setInitClientState(null);
|
||||
leaveToHome?.();
|
||||
PosthogAnalytics.instance.logout();
|
||||
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
|
||||
}, [leaveToHome, initClientState?.client]);
|
||||
|
||||
// To protect against multiple sessions writing to the same storage
|
||||
// simultaneously, we send a broadcast message that shuts down all other
|
||||
// running instances of the app. Element Call only has storage of its own to
|
||||
// protect when it created the session itself; given a client — by a host, or
|
||||
// over the widget API — it is mostly stateless.
|
||||
const ownsSession = client === undefined;
|
||||
useEffect(() => {
|
||||
if (ownsSession) loadChannel?.postMessage({});
|
||||
}, [ownsSession]);
|
||||
|
||||
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEventTarget(
|
||||
loadChannel,
|
||||
"message",
|
||||
useCallback(() => {
|
||||
initClientState?.client.stopClient();
|
||||
setAlreadyOpenedErr(new OpenElsewhereError());
|
||||
}, [initClientState?.client, setAlreadyOpenedErr]),
|
||||
);
|
||||
|
||||
const [isDisconnected, setIsDisconnected] = useState(false);
|
||||
const [supportsReactions, setSupportsReactions] = useState(false);
|
||||
|
||||
const state: ClientState | undefined = useMemo(() => {
|
||||
if (alreadyOpenedErr) {
|
||||
return { state: "error", error: alreadyOpenedErr };
|
||||
}
|
||||
|
||||
if (initClientState === undefined) return undefined;
|
||||
|
||||
const authenticated =
|
||||
initClientState === null
|
||||
? undefined
|
||||
: {
|
||||
client: initClientState.client,
|
||||
isPasswordlessUser: initClientState.passwordlessUser,
|
||||
changePassword,
|
||||
logout,
|
||||
};
|
||||
|
||||
return {
|
||||
state: "valid",
|
||||
authenticated,
|
||||
setClient,
|
||||
disconnected: isDisconnected,
|
||||
supportedFeatures: {
|
||||
reactions: supportsReactions,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
alreadyOpenedErr,
|
||||
changePassword,
|
||||
initClientState,
|
||||
logout,
|
||||
setClient,
|
||||
isDisconnected,
|
||||
supportsReactions,
|
||||
]);
|
||||
|
||||
const onSync = useCallback(
|
||||
(state: SyncState, _old: SyncState | null, data?: ISyncStateData) => {
|
||||
setIsDisconnected(clientIsDisconnected(state, data));
|
||||
},
|
||||
[],
|
||||
);
|
||||
export const ClientProvider: FC<Props> = ({ children, session }) => {
|
||||
const state = useBehavior(session.state$);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initClientState) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.matrixclient = initClientState.client;
|
||||
window.passwordlessUser = initClientState.passwordlessUser;
|
||||
if (state.state !== "signedIn") return;
|
||||
window.matrixclient = state.client;
|
||||
window.passwordlessUser = state.passwordlessUser;
|
||||
|
||||
if (PosthogAnalytics.hasInstance())
|
||||
PosthogAnalytics.instance.onLoginStatusChanged();
|
||||
// Analytics follow the user's choices whoever the client belongs to
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
}, [state]);
|
||||
|
||||
if (initClientState.client) {
|
||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||
}
|
||||
if (state.state === "error") return <ErrorPage error={state.error} />;
|
||||
|
||||
if (!hostBridge.supportsReactions)
|
||||
logger.warn("The host does not permit reactions");
|
||||
setSupportsReactions(hostBridge.supportsReactions);
|
||||
|
||||
return (): void => {
|
||||
if (initClientState.client) {
|
||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||
}
|
||||
};
|
||||
}, [initClientState, onSync, hostBridge]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage error={alreadyOpenedErr} />;
|
||||
}
|
||||
|
||||
return <ClientContext value={state}>{children}</ClientContext>;
|
||||
return <ClientContext value={session}>{children}</ClientContext>;
|
||||
};
|
||||
|
||||
export type InitResult = {
|
||||
client: MatrixClient;
|
||||
passwordlessUser: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own. Only reached when no
|
||||
* client was supplied for it to use.
|
||||
*/
|
||||
async function loadClient(): Promise<InitResult | null> {
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
access_token: string;
|
||||
passwordlessUser: boolean;
|
||||
tempPassword?: string;
|
||||
}
|
||||
|
||||
const clearSession = (): void => localStorage.removeItem("matrix-auth-store");
|
||||
const saveSession = (s: Session): void =>
|
||||
localStorage.setItem("matrix-auth-store", JSON.stringify(s));
|
||||
const loadSession = (): Session | undefined => {
|
||||
const data = localStorage.getItem("matrix-auth-store");
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return JSON.parse(data);
|
||||
};
|
||||
|
||||
const clientIsDisconnected = (
|
||||
syncState: SyncState,
|
||||
syncData?: ISyncStateData,
|
||||
): boolean =>
|
||||
syncState === "ERROR" && syncData?.error?.name === "ConnectionError";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
import { hostClientSession } from "./state/ClientSession";
|
||||
|
||||
test("shows while the client's sync fails to connect", () => {
|
||||
const client = {
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => "@alice:example.org",
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
const { container } = render(
|
||||
<ClientProvider session={hostClientSession(client)}>
|
||||
<DisconnectedBanner />
|
||||
</ClientProvider>,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
|
||||
const [, onSync] = vi.mocked(client.on).mock.calls[0] as [
|
||||
unknown,
|
||||
(state: string, old: null, data?: { error?: Error }) => void,
|
||||
];
|
||||
const connectionError = Object.assign(new Error("offline"), {
|
||||
name: "ConnectionError",
|
||||
});
|
||||
act(() => onSync("ERROR", null, { error: connectionError }));
|
||||
expect(container.textContent).not.toBe("");
|
||||
// Any other failure is not a connection problem
|
||||
act(() => onSync("ERROR", null, { error: new Error("rate limited") }));
|
||||
expect(container.textContent).toBe("");
|
||||
act(() => onSync("SYNCING", null));
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
@@ -6,30 +6,60 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import { type FC, type HTMLAttributes, type ReactNode } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
|
||||
import styles from "./DisconnectedBanner.module.css";
|
||||
import { type ValidClientState, useClientState } from "./ClientContext";
|
||||
import { useClient } from "./ClientContext";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLElement> {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown while the signed-in client is supposed to be connected but is not.
|
||||
*/
|
||||
export const DisconnectedBanner: FC<Props> = ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const clientState = useClientState();
|
||||
let shouldShowBanner = false;
|
||||
const { client } = useClient();
|
||||
// Tagged with the client it describes, so that a swapped client does not
|
||||
// inherit the old one's verdict before its first sync
|
||||
const [disconnected, setDisconnected] = useState<
|
||||
[MatrixClient, boolean] | null
|
||||
>(null);
|
||||
|
||||
if (clientState?.state === "valid") {
|
||||
const validClientState = clientState as ValidClientState;
|
||||
shouldShowBanner = validClientState.disconnected;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (client === undefined) return;
|
||||
const onSync = (
|
||||
syncState: SyncState,
|
||||
_old: SyncState | null,
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
const clientIsDisconnected =
|
||||
syncState === "ERROR" && data?.error?.name === "ConnectionError";
|
||||
setDisconnected([client, clientIsDisconnected]);
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
return (): void => {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const shouldShowBanner =
|
||||
disconnected !== null && disconnected[0] === client && disconnected[1];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+10
-8
@@ -18,6 +18,8 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -179,40 +181,40 @@ export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
preset.callIntent = RTC_CALL_INTENT_VIDEO;
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
preset.callIntent = RTC_CALL_INTENT_VIDEO;
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
preset.callIntent = RTC_CALL_INTENT_AUDIO;
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
preset.callIntent = preset.callIntent ?? RTC_CALL_INTENT_VIDEO;
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
|
||||
+10
-6
@@ -21,6 +21,10 @@ import {
|
||||
UserIntent,
|
||||
} from "../src/UrlParams";
|
||||
import { mockConfig } from "./utils/test";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
const ROOM_NAME = "roomNameHere";
|
||||
const ROOM_ID = "!d45f138fsd";
|
||||
@@ -275,7 +279,7 @@ describe("UrlParams", () => {
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
skipLobby: false,
|
||||
callIntent: "video",
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,7 +326,7 @@ describe("UrlParams", () => {
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
skipLobby: false,
|
||||
callIntent: "audio",
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,7 +337,7 @@ describe("UrlParams", () => {
|
||||
),
|
||||
).toMatchObject({
|
||||
...joinExistingCallDefaults("desktop"),
|
||||
callIntent: "audio",
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,7 +352,7 @@ describe("UrlParams", () => {
|
||||
sendNotificationType: "ring",
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: true,
|
||||
callIntent: "audio",
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -363,7 +367,7 @@ describe("UrlParams", () => {
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: "video",
|
||||
callIntent: RTC_CALL_INTENT_VIDEO,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -377,7 +381,7 @@ describe("UrlParams", () => {
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: "audio",
|
||||
callIntent: RTC_CALL_INTENT_AUDIO,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ClientManagerProvider, ClientProvider } from "./ClientContext";
|
||||
import { LeaveToHomeProvider } from "./LeaveToHomeContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSession,
|
||||
type ClientSessionState,
|
||||
hostClientSession,
|
||||
} from "./state/ClientSession";
|
||||
import { UserMenuContainer } from "./UserMenuContainer";
|
||||
import { flushPromises } from "./utils/test";
|
||||
|
||||
vi.mock("./settings/SettingsModal", () => ({
|
||||
defaultSettingsTab: "audio",
|
||||
SettingsModal: (): null => null,
|
||||
}));
|
||||
|
||||
const mockClient = (): MatrixClient =>
|
||||
({
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => "@alice:example.org",
|
||||
getUserIdLocalpart: () => "alice",
|
||||
getDeviceId: () => "AAAA",
|
||||
getUser: () => null,
|
||||
stopClient: vi.fn(),
|
||||
}) as Partial<MatrixClient> as MatrixClient;
|
||||
|
||||
test("leaves for home after logging out of the session", async () => {
|
||||
const logout = vi.fn().mockResolvedValue(undefined);
|
||||
const leaveToHome = vi.fn();
|
||||
renderMenu(
|
||||
{
|
||||
state$: new BehaviorSubject<ClientSessionState>({
|
||||
state: "signedIn",
|
||||
client: mockClient(),
|
||||
passwordlessUser: false,
|
||||
}),
|
||||
},
|
||||
{ login: vi.fn(), register: vi.fn(), logout },
|
||||
leaveToHome,
|
||||
);
|
||||
|
||||
await selectSignOut();
|
||||
expect(logout).toHaveBeenCalled();
|
||||
expect(leaveToHome).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot end a session that is not ours", async () => {
|
||||
const leaveToHome = vi.fn();
|
||||
renderMenu(hostClientSession(mockClient()), null, leaveToHome);
|
||||
|
||||
await selectSignOut();
|
||||
expect(leaveToHome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function selectSignOut(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("usermenu_open"));
|
||||
await user.click(await screen.findByTestId("usermenu_logout"));
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
function renderMenu(
|
||||
session: ClientSession,
|
||||
manager: ClientManager | null,
|
||||
leaveToHome: (() => void) | null = null,
|
||||
): void {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<LeaveToHomeProvider value={leaveToHome}>
|
||||
<ClientProvider session={session}>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<UserMenuContainer />
|
||||
</ClientManagerProvider>
|
||||
</ClientProvider>
|
||||
</LeaveToHomeProvider>
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,12 @@ import { type FC, useCallback, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useClientLegacy } from "./ClientContext";
|
||||
import {
|
||||
useClient,
|
||||
useClientManagerIfAny,
|
||||
useClientSessionState,
|
||||
} from "./ClientContext";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import { useProfile } from "./profile/useProfile";
|
||||
import { defaultSettingsTab, SettingsModal } from "./settings/SettingsModal";
|
||||
import { UserMenu } from "./UserMenu";
|
||||
@@ -21,7 +26,13 @@ interface Props {
|
||||
export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { client, logout, authenticated, passwordlessUser } = useClientLegacy();
|
||||
const { client } = useClient();
|
||||
const session = useClientSessionState();
|
||||
const passwordlessUser =
|
||||
session.state === "signedIn" && session.passwordlessUser;
|
||||
// Absent when the session is a host's, which Element Call cannot end
|
||||
const logout = useClientManagerIfAny()?.logout;
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const { displayName, avatarUrl } = useProfile(client);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const onDismissSettingsModal = useCallback(
|
||||
@@ -43,7 +54,9 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
setSettingsModalOpen(true);
|
||||
break;
|
||||
case "logout":
|
||||
logout?.();
|
||||
logout?.()
|
||||
.then(() => leaveToHome?.())
|
||||
.catch((e) => logger.error("Failed to log out", e));
|
||||
break;
|
||||
case "login":
|
||||
navigate("/login", { state: { from: location } })?.catch((error) =>
|
||||
@@ -52,7 +65,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigate, location, logout, setSettingsModalOpen],
|
||||
[navigate, location, logout, leaveToHome, setSettingsModalOpen],
|
||||
);
|
||||
|
||||
const userName = client?.getUserIdLocalpart() ?? "";
|
||||
@@ -60,7 +73,7 @@ export const UserMenuContainer: FC<Props> = ({ preventNavigation = false }) => {
|
||||
<>
|
||||
<UserMenu
|
||||
preventNavigation={preventNavigation}
|
||||
isAuthenticated={authenticated}
|
||||
isAuthenticated={client !== undefined}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
avatarUrl={avatarUrl}
|
||||
onAction={onAction}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type FC } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { LoginPage } from "./LoginPage";
|
||||
import { ClientManagerProvider } from "../ClientContext";
|
||||
import { type ClientManager } from "../state/ClientSession";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("signs in with the session's manager and goes home", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.login).mockResolvedValue({} as MatrixClient);
|
||||
renderLogin(manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.login).toHaveBeenCalledWith(
|
||||
"https://hs.example.org",
|
||||
"alice",
|
||||
"hunter2",
|
||||
);
|
||||
expect(await screen.findByText("at /")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows why signing in failed", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.login).mockRejectedValue(new Error("Wrong password"));
|
||||
renderLogin(manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(await screen.findByText("Wrong password")).toBeVisible();
|
||||
expect(screen.getByText("at /login")).toBeVisible();
|
||||
});
|
||||
|
||||
async function submit(username: string, password: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("login_username"), username);
|
||||
await user.type(screen.getByTestId("login_password"), password);
|
||||
await user.click(screen.getByTestId("login_login"));
|
||||
}
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
const WhereAmI: FC = () => <span>at {useLocation().pathname}</span>;
|
||||
|
||||
function renderLogin(manager: ClientManager): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<LoginPage />
|
||||
<WhereAmI />
|
||||
</ClientManagerProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
+4
-13
@@ -11,10 +11,9 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
import Logo from "../icons/LogoLarge.svg?react";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { useClientManager } from "../ClientContext";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import styles from "./LoginPage.module.css";
|
||||
import { useInteractiveLogin } from "./useInteractiveLogin";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { Config } from "../config/Config";
|
||||
@@ -24,8 +23,7 @@ export const LoginPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("login_title"));
|
||||
|
||||
const { client, setClient } = useClient();
|
||||
const login = useInteractiveLogin(client);
|
||||
const { login } = useClientManager();
|
||||
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
@@ -46,15 +44,8 @@ export const LoginPage: FC = () => {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
login(homeserver, usernameRef.current.value, passwordRef.current.value)
|
||||
.then(async ([client, session]) => {
|
||||
if (!setClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClient(client, session);
|
||||
|
||||
.then(async () => {
|
||||
const locationState = location.state;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
@@ -72,7 +63,7 @@ export const LoginPage: FC = () => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[login, location, navigate, homeserver, setClient],
|
||||
[login, location, navigate, homeserver],
|
||||
);
|
||||
// we need to limit the length of the homserver name to not cover the whole loginview input with the string.
|
||||
let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type FC } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { RegisterPage } from "./RegisterPage";
|
||||
import { ClientContextProvider, ClientManagerProvider } from "../ClientContext";
|
||||
import {
|
||||
type ClientManager,
|
||||
type ClientSessionState,
|
||||
} from "../state/ClientSession";
|
||||
|
||||
vi.mock("./useRecaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
execute: vi.fn().mockResolvedValue("captcha-response"),
|
||||
reset,
|
||||
recaptchaId: "recaptcha",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./useRegistrationTerms", () => ({
|
||||
useRegistrationTerms: () => ({ recaptchaKey: "site-key" }),
|
||||
}));
|
||||
|
||||
const reset = vi.fn();
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("waits for the session to be restored", () => {
|
||||
renderRegister({ state: "loading" }, mockManager());
|
||||
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("registers an account, signs in and goes home", async () => {
|
||||
const manager = mockManager();
|
||||
const setSignedIn = vi.fn();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn,
|
||||
});
|
||||
renderRegister({ state: "signedOut" }, manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"alice",
|
||||
"hunter2",
|
||||
"alice",
|
||||
"captcha-response",
|
||||
false,
|
||||
);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
expect(await screen.findByText("at /")).toBeVisible();
|
||||
});
|
||||
|
||||
test("upgrades a passwordless user to a full account", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn: vi.fn(),
|
||||
});
|
||||
renderRegister(
|
||||
{
|
||||
state: "signedIn",
|
||||
client: { getUserId: () => "@guest:example.org" } as MatrixClient,
|
||||
passwordlessUser: true,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"alice",
|
||||
"hunter2",
|
||||
"alice",
|
||||
"captcha-response",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shows why registering failed and resets the captcha", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockRejectedValue(new Error("Name taken"));
|
||||
renderRegister({ state: "signedOut" }, manager);
|
||||
|
||||
await submit("alice", "hunter2");
|
||||
|
||||
expect(await screen.findByText("Name taken")).toBeVisible();
|
||||
expect(reset).toHaveBeenCalled();
|
||||
expect(screen.getByText("at /register")).toBeVisible();
|
||||
});
|
||||
|
||||
async function submit(username: string, password: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("register_username"), username);
|
||||
await user.type(screen.getByTestId("register_password"), password);
|
||||
await user.type(screen.getByTestId("register_confirm_password"), password);
|
||||
await user.click(screen.getByTestId("register_register"));
|
||||
}
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
const WhereAmI: FC = () => <span>at {useLocation().pathname}</span>;
|
||||
|
||||
function renderRegister(
|
||||
state: ClientSessionState,
|
||||
manager: ClientManager,
|
||||
): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/register"]}>
|
||||
<ClientContextProvider
|
||||
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
|
||||
>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<RegisterPage />
|
||||
<WhereAmI />
|
||||
</ClientManagerProvider>
|
||||
</ClientContextProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
+17
-17
@@ -22,8 +22,12 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "./useInteractiveRegistration";
|
||||
import {
|
||||
useClient,
|
||||
useClientManager,
|
||||
useClientSessionState,
|
||||
} from "../ClientContext";
|
||||
import { useRegistrationTerms } from "./useRegistrationTerms";
|
||||
import styles from "./LoginPage.module.css";
|
||||
import Logo from "../icons/LogoLarge.svg?react";
|
||||
import { LoadingPage } from "../FullScreenView";
|
||||
@@ -37,8 +41,13 @@ export const RegisterPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("action.register"));
|
||||
|
||||
const { loading, authenticated, passwordlessUser, client, setClient } =
|
||||
useClientLegacy();
|
||||
const session = useClientSessionState();
|
||||
const loading = session.state === "loading";
|
||||
const passwordlessUser =
|
||||
session.state === "signedIn" && session.passwordlessUser;
|
||||
const { client } = useClient();
|
||||
const authenticated = client !== undefined;
|
||||
const { register } = useClientManager();
|
||||
|
||||
const confirmPasswordRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -47,7 +56,7 @@ export const RegisterPage: FC = () => {
|
||||
const [error, setError] = useState<Error>();
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState("");
|
||||
const { recaptchaKey, register } = useInteractiveRegistration(client);
|
||||
const { recaptchaKey } = useRegistrationTerms();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const onSubmitRegisterForm = useCallback(
|
||||
@@ -64,7 +73,7 @@ export const RegisterPage: FC = () => {
|
||||
setRegistering(true);
|
||||
|
||||
const recaptchaResponse = await execute();
|
||||
const [newClient, session] = await register(
|
||||
const { client: newClient, setSignedIn } = await register(
|
||||
userName,
|
||||
password,
|
||||
userName,
|
||||
@@ -95,7 +104,7 @@ export const RegisterPage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
setClient?.(newClient, session);
|
||||
setSignedIn();
|
||||
PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date());
|
||||
};
|
||||
|
||||
@@ -117,16 +126,7 @@ export const RegisterPage: FC = () => {
|
||||
reset();
|
||||
});
|
||||
},
|
||||
[
|
||||
register,
|
||||
location,
|
||||
navigate,
|
||||
passwordlessUser,
|
||||
reset,
|
||||
execute,
|
||||
client,
|
||||
setClient,
|
||||
],
|
||||
[register, location, navigate, passwordlessUser, reset, execute, client],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { createClient, type MatrixClient, MatrixError } from "matrix-js-sdk";
|
||||
import type * as MatrixJsSdk from "matrix-js-sdk";
|
||||
|
||||
import { loginWithPassword, registerWithPassword } from "./interactiveAuth";
|
||||
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixJsSdk>()),
|
||||
createClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const credentials = {
|
||||
user_id: "@alice:example.org",
|
||||
device_id: "AAAA",
|
||||
access_token: "secret",
|
||||
};
|
||||
|
||||
// What interactive auth asks of the client besides the request itself
|
||||
const authClient = { generateClientSecret: () => "client-secret" };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("logs in with a password", async () => {
|
||||
const login = vi.fn().mockResolvedValue({ ...credentials, extra: "ignored" });
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
...authClient,
|
||||
login,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
|
||||
await expect(
|
||||
loginWithPassword("https://hs.example.org", "alice", "hunter2"),
|
||||
).resolves.toEqual(credentials);
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
baseUrl: "https://hs.example.org",
|
||||
});
|
||||
expect(login).toHaveBeenCalledWith("m.login.password", {
|
||||
identifier: { type: "m.id.user", user: "alice" },
|
||||
password: "hunter2",
|
||||
});
|
||||
});
|
||||
|
||||
test("registers, agreeing to the terms and answering the recaptcha", async () => {
|
||||
// The homeserver turns the first request away with the stages to complete
|
||||
// No error in it: that is the shape of a challenge rather than a refusal
|
||||
const challenge = new MatrixError(
|
||||
{
|
||||
session: "session",
|
||||
flows: [{ stages: ["m.login.terms", "m.login.recaptcha"] }],
|
||||
params: {},
|
||||
completed: [],
|
||||
},
|
||||
401,
|
||||
);
|
||||
const registerRequest = vi
|
||||
.fn()
|
||||
.mockImplementation(({ auth }: { auth?: { type: string } }) => {
|
||||
if (auth === undefined) throw challenge;
|
||||
challenge.data.completed = [
|
||||
...(challenge.data.completed as string[]),
|
||||
auth.type,
|
||||
];
|
||||
if (challenge.data.completed.length < 2) throw challenge;
|
||||
return credentials;
|
||||
});
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
...authClient,
|
||||
registerRequest,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
|
||||
await expect(
|
||||
registerWithPassword(
|
||||
"https://hs.example.org",
|
||||
"alice",
|
||||
"hunter2",
|
||||
"captcha",
|
||||
),
|
||||
).resolves.toEqual(credentials);
|
||||
|
||||
expect(registerRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: "alice",
|
||||
password: "hunter2",
|
||||
auth: expect.objectContaining({ type: "m.login.terms" }),
|
||||
}),
|
||||
);
|
||||
expect(registerRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auth: expect.objectContaining({
|
||||
type: "m.login.recaptcha",
|
||||
response: "captcha",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
createClient,
|
||||
InteractiveAuth,
|
||||
type LoginResponse,
|
||||
type RegisterResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type StoredSession } from "../state/ClientSession";
|
||||
|
||||
/** What the homeserver hands out for a new session. */
|
||||
export type Credentials = Pick<
|
||||
StoredSession,
|
||||
"user_id" | "device_id" | "access_token"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Logs in to the given homeserver with a password.
|
||||
*/
|
||||
export async function loginWithPassword(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Credentials> {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (): Promise<LoginResponse> =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
}),
|
||||
stateUpdated: (): void => {},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "" }),
|
||||
});
|
||||
return credentials(await interactiveAuth.attemptAuth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an account on the given homeserver, accepting its terms and
|
||||
* answering its recaptcha challenge.
|
||||
*/
|
||||
export async function registerWithPassword(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
recaptchaResponse: string,
|
||||
): Promise<Credentials> {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (auth): Promise<RegisterResponse> =>
|
||||
authClient.registerRequest({
|
||||
username,
|
||||
password,
|
||||
auth: auth || undefined,
|
||||
}),
|
||||
stateUpdated: (nextStage, status): void => {
|
||||
if (status.error) {
|
||||
throw new Error(status.error);
|
||||
}
|
||||
|
||||
if (nextStage === "m.login.terms") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.terms",
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
} else if (nextStage === "m.login.recaptcha") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.recaptcha",
|
||||
response: recaptchaResponse,
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
}
|
||||
},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "dummy" }),
|
||||
});
|
||||
return credentials(await interactiveAuth.attemptAuth());
|
||||
}
|
||||
|
||||
// XXX: attemptAuth claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
function credentials(authData: unknown): Credentials {
|
||||
/* eslint-disable camelcase */
|
||||
const { user_id, access_token, device_id } = authData as Credentials;
|
||||
return { user_id, access_token, device_id };
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk";
|
||||
import {
|
||||
createClient,
|
||||
type LoginResponse,
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { type Session } from "../ClientContext";
|
||||
/**
|
||||
* This provides the login method to login using user credentials.
|
||||
* @param oldClient If there is an already authenticated client it should be passed to this hook
|
||||
* this allows the interactive login to sign out the client before logging in.
|
||||
* @returns A async method that can be called/awaited to log in with the provided credentials.
|
||||
*/
|
||||
export function useInteractiveLogin(
|
||||
oldClient?: MatrixClient,
|
||||
): (
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<[MatrixClient, Session]> {
|
||||
return useCallback<
|
||||
(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<[MatrixClient, Session]>
|
||||
>(
|
||||
async (homeserver: string, username: string, password: string) => {
|
||||
const authClient = createClient({ baseUrl: homeserver });
|
||||
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient,
|
||||
doRequest: async (): Promise<LoginResponse> =>
|
||||
authClient.login("m.login.password", {
|
||||
identifier: {
|
||||
type: "m.id.user",
|
||||
user: username,
|
||||
},
|
||||
password,
|
||||
}),
|
||||
stateUpdated: (): void => {},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "" }),
|
||||
});
|
||||
|
||||
// XXX: This claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||
const { user_id, access_token, device_id } =
|
||||
(await interactiveAuth.attemptAuth()) as any;
|
||||
const session = {
|
||||
user_id,
|
||||
access_token,
|
||||
device_id,
|
||||
passwordlessUser: false,
|
||||
};
|
||||
|
||||
// To not confuse the rust crypto sessions we need to logout the old client before initializing the new one.
|
||||
await oldClient?.logout(true);
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: homeserver,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
/* eslint-enable camelcase */
|
||||
return [client, session];
|
||||
},
|
||||
[oldClient],
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { InteractiveAuth } from "matrix-js-sdk";
|
||||
import {
|
||||
createClient,
|
||||
type MatrixClient,
|
||||
type RegisterResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { type Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export const useInteractiveRegistration = (
|
||||
oldClient?: MatrixClient,
|
||||
): {
|
||||
privacyPolicyUrl?: string;
|
||||
recaptchaKey?: string;
|
||||
register: (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser: boolean,
|
||||
) => Promise<[MatrixClient, Session]>;
|
||||
} => {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const authClient = useRef<MatrixClient | undefined>(undefined);
|
||||
if (!authClient.current) {
|
||||
authClient.current = createClient({
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
authClient.current!.registerRequest({}).catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url,
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, [isWidget]);
|
||||
|
||||
const register = useCallback(
|
||||
async (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
recaptchaResponse: string,
|
||||
passwordlessUser: boolean,
|
||||
): Promise<[MatrixClient, Session]> => {
|
||||
const interactiveAuth = new InteractiveAuth({
|
||||
matrixClient: authClient.current!,
|
||||
doRequest: async (auth): Promise<RegisterResponse> =>
|
||||
authClient.current!.registerRequest({
|
||||
username,
|
||||
password,
|
||||
auth: auth || undefined,
|
||||
}),
|
||||
stateUpdated: (nextStage, status): void => {
|
||||
if (status.error) {
|
||||
throw new Error(status.error);
|
||||
}
|
||||
|
||||
if (nextStage === "m.login.terms") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.terms",
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
} else if (nextStage === "m.login.recaptcha") {
|
||||
interactiveAuth
|
||||
.submitAuthDict({
|
||||
type: "m.login.recaptcha",
|
||||
response: recaptchaResponse,
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(e);
|
||||
});
|
||||
}
|
||||
},
|
||||
requestEmailToken: async (): Promise<{ sid: string }> =>
|
||||
Promise.resolve({ sid: "dummy" }),
|
||||
});
|
||||
|
||||
// XXX: This claims to return an IAuthData which contains none of these
|
||||
// things - the js-sdk types may be wrong?
|
||||
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
|
||||
const { user_id, access_token, device_id } =
|
||||
(await interactiveAuth.attemptAuth()) as any;
|
||||
await oldClient?.logout(true);
|
||||
const client = await initClient(
|
||||
{
|
||||
baseUrl: Config.defaultHomeserverUrl()!,
|
||||
accessToken: access_token,
|
||||
userId: user_id,
|
||||
deviceId: device_id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
await client.setDisplayName(displayName);
|
||||
|
||||
const session: Session = {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
passwordlessUser,
|
||||
};
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
if (passwordlessUser) {
|
||||
session.tempPassword = password;
|
||||
}
|
||||
|
||||
const user = client.getUser(client.getUserId()!)!;
|
||||
user.setRawDisplayName(displayName);
|
||||
user.setDisplayName(displayName);
|
||||
|
||||
return [client, session];
|
||||
},
|
||||
[oldClient],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, recaptchaKey, register };
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { useRegisterPasswordlessUser } from "./useRegisterPasswordlessUser";
|
||||
import { ClientManagerProvider } from "../ClientContext";
|
||||
import { type ClientManager } from "../state/ClientSession";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
|
||||
vi.mock("./useRecaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
execute: vi.fn().mockResolvedValue("captcha-response"),
|
||||
reset,
|
||||
recaptchaId: "recaptcha",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./useRegistrationTerms", () => ({
|
||||
useRegistrationTerms: () => ({
|
||||
privacyPolicyUrl: "https://hs.example.org/privacy",
|
||||
recaptchaKey: "site-key",
|
||||
}),
|
||||
}));
|
||||
vi.mock("./generateRandomName", () => ({
|
||||
generateRandomName: () => "Random Name",
|
||||
}));
|
||||
|
||||
const reset = vi.fn();
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("registers a user under a random name and signs them in", async () => {
|
||||
const setSignedIn = vi.fn();
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockResolvedValue({
|
||||
client: {} as MatrixClient,
|
||||
setSignedIn,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, false),
|
||||
});
|
||||
await result.current.registerPasswordlessUser("Alice");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"Random Name",
|
||||
expect.stringMatching(/^.{16}$/),
|
||||
"Alice",
|
||||
"captcha-response",
|
||||
true,
|
||||
);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
expect(result.current.privacyPolicyUrl).toBe(
|
||||
"https://hs.example.org/privacy",
|
||||
);
|
||||
expect(result.current.recaptchaId).toBe("recaptcha");
|
||||
});
|
||||
|
||||
test("resets the captcha when registration fails", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockRejectedValue(new Error("Nope"));
|
||||
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, false),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow("Nope");
|
||||
expect(reset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cannot register on a session that is not ours", async () => {
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(null, false),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow("This session cannot register");
|
||||
});
|
||||
|
||||
test("never registers as a widget", async () => {
|
||||
const manager = mockManager();
|
||||
const { result } = renderHook(() => useRegisterPasswordlessUser(), {
|
||||
wrapper: wrapper(manager, true),
|
||||
});
|
||||
|
||||
await expect(
|
||||
result.current.registerPasswordlessUser("Alice"),
|
||||
).rejects.toThrow(/embedded mode/);
|
||||
expect(manager.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
function wrapper(
|
||||
manager: ClientManager | null,
|
||||
isWidget: boolean,
|
||||
): FC<PropsWithChildren> {
|
||||
const params = { ...computeUrlParams(), isWidget };
|
||||
return ({ children }) => (
|
||||
<UrlParamsProvider value={params}>
|
||||
<ClientManagerProvider value={manager}>{children}</ClientManagerProvider>
|
||||
</UrlParamsProvider>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details.
|
||||
import { useCallback } from "react";
|
||||
import { secureRandomString } from "matrix-js-sdk/lib/randomstring";
|
||||
|
||||
import { useClient } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { useClientManagerIfAny } from "../ClientContext";
|
||||
import { useRegistrationTerms } from "../auth/useRegistrationTerms";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
@@ -21,16 +21,16 @@ interface UseRegisterPasswordlessUserType {
|
||||
}
|
||||
|
||||
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
const { setClient } = useClient();
|
||||
// Null in the widget, which never registers anyone
|
||||
const register = useClientManagerIfAny()?.register;
|
||||
const { isWidget } = useUrlParams();
|
||||
const { privacyPolicyUrl, recaptchaKey, register } =
|
||||
useInteractiveRegistration();
|
||||
const { privacyPolicyUrl, recaptchaKey } = useRegistrationTerms();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const registerPasswordlessUser = useCallback(
|
||||
async (displayName: string) => {
|
||||
if (!setClient) {
|
||||
throw new Error("No client context");
|
||||
if (!register) {
|
||||
throw new Error("This session cannot register");
|
||||
}
|
||||
if (isWidget) {
|
||||
throw new Error(
|
||||
@@ -41,20 +41,20 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
try {
|
||||
const recaptchaResponse = await execute();
|
||||
const userName = generateRandomName();
|
||||
const [client, session] = await register(
|
||||
const { setSignedIn } = await register(
|
||||
userName,
|
||||
secureRandomString(16),
|
||||
displayName,
|
||||
recaptchaResponse,
|
||||
true,
|
||||
);
|
||||
setClient(client, session);
|
||||
setSignedIn();
|
||||
} catch (e) {
|
||||
reset();
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[execute, reset, register, setClient, isWidget],
|
||||
[execute, reset, register, isWidget],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type * as MatrixJsSdk from "matrix-js-sdk";
|
||||
import { createClient, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
|
||||
import { useRegistrationTerms } from "./useRegistrationTerms";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixJsSdk>()),
|
||||
createClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const registerRequest = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
vi.mocked(createClient).mockReturnValue({
|
||||
registerRequest,
|
||||
} as Partial<MatrixClient> as MatrixClient);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("reads the terms the homeserver asks for from a registration attempt", async () => {
|
||||
registerRequest.mockRejectedValue({
|
||||
data: {
|
||||
params: {
|
||||
"m.login.terms": {
|
||||
policies: {
|
||||
privacy_policy: { en: { url: "https://hs.example.org/privacy" } },
|
||||
},
|
||||
},
|
||||
"m.login.recaptcha": { public_key: "site-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(false),
|
||||
});
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
baseUrl: "https://hs.example.org",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: "https://hs.example.org/privacy",
|
||||
recaptchaKey: "site-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("has nothing to agree to where the homeserver asks for nothing", async () => {
|
||||
registerRequest.mockRejectedValue({ data: { params: {} } });
|
||||
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(false),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(registerRequest).toHaveBeenCalled());
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: undefined,
|
||||
recaptchaKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("asks the homeserver nothing as a widget", () => {
|
||||
const { result } = renderHook(() => useRegistrationTerms(), {
|
||||
wrapper: wrapper(true),
|
||||
});
|
||||
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual({
|
||||
privacyPolicyUrl: undefined,
|
||||
recaptchaKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
function wrapper(isWidget: boolean): FC<PropsWithChildren> {
|
||||
const params = { ...computeUrlParams(), isWidget };
|
||||
return ({ children }) => (
|
||||
<UrlParamsProvider value={params}>{children}</UrlParamsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createClient } from "matrix-js-sdk";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
/**
|
||||
* What the homeserver asks of a registration: its privacy policy to agree to
|
||||
* and the recaptcha key to answer a challenge with.
|
||||
*/
|
||||
export function useRegistrationTerms(): {
|
||||
privacyPolicyUrl?: string;
|
||||
recaptchaKey?: string;
|
||||
} {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [recaptchaKey, setRecaptchaKey] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
createClient({ baseUrl: Config.defaultHomeserverUrl()! })
|
||||
.registerRequest({})
|
||||
.catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en
|
||||
?.url,
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, [isWidget]);
|
||||
|
||||
return { privacyPolicyUrl, recaptchaKey };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
/* A zero floor, so a long label wraps inside its tile rather than widening
|
||||
the grid past the menu. */
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
gap: var(--cpd-space-3x);
|
||||
padding: var(--cpd-space-2x) var(--cpd-space-4x) 0;
|
||||
}
|
||||
|
||||
/* Within the grid, to outweigh the menu item styles the tiles are built on. */
|
||||
.grid .tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--cpd-space-1x);
|
||||
min-inline-size: 0;
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-2x);
|
||||
/* Not hidden: that makes the tile a scroll container, which focus scrolls
|
||||
into view without its border, and so without the focus ring. */
|
||||
overflow: clip;
|
||||
border: var(--cpd-border-width-2) solid
|
||||
var(--cpd-color-border-interactive-secondary);
|
||||
border-radius: var(--cpd-space-2x);
|
||||
background: none;
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* As the menu items highlight on hover. */
|
||||
.grid .tile[data-highlighted] {
|
||||
background: var(--cpd-color-bg-action-secondary-hovered);
|
||||
}
|
||||
|
||||
.grid .tile[aria-checked="true"] {
|
||||
border-color: var(--cpd-color-border-accent);
|
||||
}
|
||||
|
||||
.grid .tile[data-disabled] {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tile[data-disabled] .picture {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.picture {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Positioned, or the picture, positioned itself, would cover it. */
|
||||
.mark {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
color: var(--cpd-color-icon-accent-primary);
|
||||
}
|
||||
|
||||
/* On a picture the mark needs a ground of its own to be seen. */
|
||||
.picture ~ .mark {
|
||||
border-radius: 50%;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
box-shadow: 0 0 0 var(--cpd-border-width-2) var(--cpd-color-bg-canvas-default);
|
||||
}
|
||||
|
||||
.label {
|
||||
max-inline-size: 100%;
|
||||
font: var(--cpd-font-body-sm-medium);
|
||||
color: var(--cpd-color-text-primary);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.removable {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.removable > .tile {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Clear of the target for choosing the tile. */
|
||||
.remove {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
translate: 20% -20%;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 28px;
|
||||
block-size: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
color: var(--cpd-color-icon-critical-primary);
|
||||
box-shadow: 0 1.2px 2.4px 0 rgb(0 0 0 / 15%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
background: var(--cpd-color-bg-critical-subtle-hovered);
|
||||
box-shadow: 0 0 0 var(--cpd-border-width-1)
|
||||
var(--cpd-color-border-critical-subtle);
|
||||
}
|
||||
|
||||
.removable:hover .remove,
|
||||
.removable:focus-within .remove {
|
||||
display: flex;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
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, type KeyboardEvent, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { InlineSpinner, MenuItem, Tooltip } from "@vector-im/compound-web";
|
||||
import {
|
||||
BlockIcon,
|
||||
BlurIcon,
|
||||
CheckCircleSolidIcon,
|
||||
CloseIcon,
|
||||
PlusIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./BackgroundEffectGrid.module.css";
|
||||
|
||||
/**
|
||||
* One choice in the camera menu's Background effects section, as the view
|
||||
* model offers it. The grid names it: pictures are numbered in order.
|
||||
*/
|
||||
export interface BackgroundEffectGridItem {
|
||||
/** Its option id, as `backgroundEffect` gives the one in force. */
|
||||
id: string;
|
||||
kind: "none" | "blur" | "image";
|
||||
/** For kind "image". */
|
||||
imageUrl?: string;
|
||||
/** Whether it is the user's to remove. */
|
||||
removable?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
effects: BackgroundEffectGridItem[];
|
||||
selected: string | undefined;
|
||||
/** Undefined where effects can't run: all but no effect are disabled. */
|
||||
onSelect: ((id: string) => void) | undefined;
|
||||
/** Shown on the selected tile until its effect is on screen. */
|
||||
settling?: boolean;
|
||||
/** Id of the text saying what the user should know before choosing. */
|
||||
describedBy?: string;
|
||||
/** Offers to add a background of one's own. Omit to leave that tile out. */
|
||||
onAdd?: () => void;
|
||||
addLabel?: string;
|
||||
/** Removes a removable one, never the one in force. Omit to offer none. */
|
||||
onRemove?: (id: string) => void;
|
||||
removeLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The background effects as a grid of tiles: one radio choice, walked by the
|
||||
* menu's arrow keys like the device rows. Sits in a menu section, which names
|
||||
* it.
|
||||
*/
|
||||
export const BackgroundEffectGrid: FC<Props> = ({
|
||||
effects,
|
||||
selected,
|
||||
onSelect,
|
||||
settling = false,
|
||||
describedBy,
|
||||
onAdd,
|
||||
addLabel,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const choose = (id: string): void => {
|
||||
if (id !== selected) onSelect?.(id);
|
||||
};
|
||||
let pictures = 0;
|
||||
const tiles = effects.map((effect) => {
|
||||
const label =
|
||||
effect.kind === "none"
|
||||
? t("background_effects.none")
|
||||
: effect.kind === "blur"
|
||||
? t("background_effects.blur")
|
||||
: t("background_effects.numbered", { n: ++pictures });
|
||||
const checked = effect.id === selected;
|
||||
const disabled = onSelect === undefined && effect.kind !== "none";
|
||||
const remove =
|
||||
effect.removable && !checked && onRemove !== undefined
|
||||
? (): void => onRemove(effect.id)
|
||||
: undefined;
|
||||
const removeProps =
|
||||
remove === undefined
|
||||
? {}
|
||||
: {
|
||||
"aria-keyshortcuts": "Delete",
|
||||
onKeyDown: (e: KeyboardEvent): void => {
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
e.preventDefault();
|
||||
remove();
|
||||
},
|
||||
};
|
||||
const content = (
|
||||
<TileContent
|
||||
effect={effect}
|
||||
label={label}
|
||||
checked={checked}
|
||||
settling={checked && settling}
|
||||
/>
|
||||
);
|
||||
const tile = (
|
||||
<MenuItem
|
||||
key={effect.id}
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
aria-busy={checked && settling}
|
||||
disabled={disabled}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
label={null}
|
||||
hideChevron
|
||||
className={styles.tile}
|
||||
// Without a handler it isn't one of the menu's items, so the keyboard
|
||||
// passes over it.
|
||||
onSelect={
|
||||
disabled
|
||||
? null
|
||||
: (e): void => {
|
||||
// Kept open, so the user sees what they chose take effect.
|
||||
e.preventDefault();
|
||||
choose(effect.id);
|
||||
}
|
||||
}
|
||||
{...removeProps}
|
||||
>
|
||||
{content}
|
||||
</MenuItem>
|
||||
);
|
||||
if (remove === undefined) return tile;
|
||||
// Beside the item, not in it: inside, the item takes the press first and
|
||||
// the tile is chosen instead.
|
||||
return (
|
||||
<div key={effect.id} className={styles.removable}>
|
||||
{tile}
|
||||
<Tooltip label={removeLabel ?? ""}>
|
||||
{/* Not a control of its own, so the menu keeps one keyboard model:
|
||||
the keyboard reaches the same action through Delete. */}
|
||||
<span aria-hidden className={styles.remove} onClick={remove}>
|
||||
<CloseIcon width={20} height={20} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// A command rather than a choice, so an item beside the radios.
|
||||
const addContent = (
|
||||
<>
|
||||
<span aria-hidden className={styles.swatch}>
|
||||
<PlusIcon width={24} height={24} />
|
||||
</span>
|
||||
<VisuallyHidden>{addLabel}</VisuallyHidden>
|
||||
</>
|
||||
);
|
||||
const addTile =
|
||||
onAdd === undefined ? null : (
|
||||
<MenuItem
|
||||
label={null}
|
||||
hideChevron
|
||||
className={styles.tile}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onAdd();
|
||||
}}
|
||||
>
|
||||
{addContent}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
// A group of its own, as the section's can't carry the description.
|
||||
<div role="group" aria-describedby={describedBy} className={styles.grid}>
|
||||
{tiles}
|
||||
{addTile}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function TileContent({
|
||||
effect,
|
||||
label,
|
||||
checked,
|
||||
settling,
|
||||
}: {
|
||||
effect: BackgroundEffectGridItem;
|
||||
/** The tile's accessible name; drawn only where the tile has no picture. */
|
||||
label: string;
|
||||
checked: boolean;
|
||||
settling: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden className={styles.swatch}>
|
||||
{effect.kind === "image" && (
|
||||
<img className={styles.picture} src={effect.imageUrl} alt="" />
|
||||
)}
|
||||
{settling ? (
|
||||
<span className={styles.mark}>
|
||||
<InlineSpinner size={20} />
|
||||
</span>
|
||||
) : checked ? (
|
||||
<CheckCircleSolidIcon
|
||||
className={styles.mark}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
) : effect.kind === "none" ? (
|
||||
<BlockIcon width={20} height={20} />
|
||||
) : effect.kind === "blur" ? (
|
||||
<BlurIcon width={20} height={20} />
|
||||
) : null}
|
||||
</span>
|
||||
{effect.kind === "image" ? (
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
) : (
|
||||
<span className={styles.label}>{label}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
|
||||
// consts for tests
|
||||
const reactionIdentifier = "@user:example.com:DEVICE";
|
||||
@@ -123,8 +124,24 @@ export const Default: Story = {
|
||||
toggleAudio: fn(),
|
||||
toggleVideo: fn(),
|
||||
toggleScreenSharing: fn(),
|
||||
toggleBlur: fn(),
|
||||
videoBlurEnabled: true,
|
||||
selectBackgroundEffect: fn(),
|
||||
backgroundEffect: "blur",
|
||||
backgroundEffects: [
|
||||
{ id: "none", kind: "none", removable: false },
|
||||
{ id: "blur", kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
addBackgroundImage: fn(),
|
||||
removeBackgroundEffect: fn(),
|
||||
cameraTrack: null,
|
||||
hangup: fn(),
|
||||
buttonSize: "lg",
|
||||
showFooter: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type FC, type JSX, type Ref, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -31,6 +32,14 @@ import { type ViewModel } from "../state/ViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
|
||||
import { LayoutSwitch } from "../room/LayoutSwitch";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import { type UnusableReason } from "../tracks/backgroundImages";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
export interface BackgroundImageRefusal {
|
||||
reason: UnusableReason | "not-kept";
|
||||
}
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
@@ -56,20 +65,37 @@ export interface FooterActions {
|
||||
toggleAudio: (() => void) | undefined;
|
||||
/** Also controls if the videoMute button is disabled */
|
||||
toggleVideo: (() => void) | undefined;
|
||||
toggleBlur: (() => void) | undefined;
|
||||
/** Undefined where background effects can't be chosen. */
|
||||
selectBackgroundEffect: ((id: string) => void) | undefined;
|
||||
/** Undefined where no background can be added. */
|
||||
addBackgroundImage: ((file: File) => void) | undefined;
|
||||
/** Removes an added background by its option id; never the one in force. */
|
||||
removeBackgroundEffect: ((id: string) => void) | undefined;
|
||||
toggleScreenSharing: (() => void) | undefined;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings: (() => void) | undefined;
|
||||
/** Also controls if the hangup button is visible */
|
||||
hangup: (() => void) | undefined;
|
||||
}
|
||||
|
||||
// we do not use any ? optional properties so that the vm type is including all fields.
|
||||
export interface FooterState {
|
||||
audioEnabled: boolean;
|
||||
audioBusy: boolean;
|
||||
videoEnabled: boolean;
|
||||
videoBusy: boolean;
|
||||
videoBlurEnabled: boolean;
|
||||
/** The background effect in force, as its option id. */
|
||||
backgroundEffect: string;
|
||||
/** Every effect on offer, in the order they are shown. */
|
||||
backgroundEffects: BackgroundEffectGridItem[];
|
||||
/** What the user is told about effects here, if anything. */
|
||||
backgroundEffectNotice: "unavailable" | "slow" | undefined;
|
||||
/** Whether the first effect chosen is still being prepared. */
|
||||
backgroundEffectSettling: boolean;
|
||||
/** Why the last file offered couldn't be kept, if it couldn't. */
|
||||
backgroundImageRefusal: BackgroundImageRefusal | undefined;
|
||||
/** The camera to preview, with the effect in force; null while it is off. */
|
||||
cameraTrack: LocalVideoMediaTrack | null;
|
||||
showFooter: boolean;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
@@ -117,12 +143,14 @@ export interface FooterProps {
|
||||
children?: JSX.Element | JSX.Element[] | false;
|
||||
vm: ViewModel<FooterSnapshot>;
|
||||
}
|
||||
|
||||
export const CallFooter: FC<FooterProps> = ({
|
||||
className,
|
||||
ref,
|
||||
children,
|
||||
vm,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
@@ -151,8 +179,29 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
|
||||
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
|
||||
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||
const toggleBlur = useBehavior(vm.toggleBlur$);
|
||||
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
|
||||
const backgroundEffect = useBehavior(vm.backgroundEffect$);
|
||||
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
|
||||
const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$);
|
||||
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
|
||||
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
|
||||
const cameraTrack = useBehavior(vm.cameraTrack$);
|
||||
const refusal = useBehavior(vm.backgroundImageRefusal$);
|
||||
const refusalMessage = useMemo(() => {
|
||||
switch (refusal?.reason) {
|
||||
case undefined:
|
||||
return undefined;
|
||||
case "not-an-image":
|
||||
return { text: t("error.background_not_an_image") };
|
||||
case "animated":
|
||||
return { text: t("error.background_animated") };
|
||||
case "undecodable":
|
||||
return { text: t("error.background_undecodable") };
|
||||
case "not-kept":
|
||||
return { text: t("error.background_not_kept") };
|
||||
}
|
||||
}, [refusal, t]);
|
||||
const backgroundEffects = useBehavior(vm.backgroundEffects$);
|
||||
const buttonSize = useBehavior(vm.buttonSize$);
|
||||
const showLogo = useBehavior(vm.showLogo$);
|
||||
|
||||
@@ -214,8 +263,21 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
options={videoOptions}
|
||||
selectedOption={selectedVideo}
|
||||
onSelect={selectVideoButtonOption}
|
||||
videoBlurToggleClick={toggleBlur}
|
||||
videoBlurEnabled={videoBlurEnabled}
|
||||
backgroundEffects={backgroundEffects}
|
||||
selectedBackgroundEffect={backgroundEffect}
|
||||
onSelectBackgroundEffect={selectBackgroundEffect}
|
||||
backgroundEffectSettling={backgroundEffectSettling}
|
||||
onAddBackgroundImage={addBackgroundImage}
|
||||
onRemoveBackgroundEffect={removeBackgroundEffect}
|
||||
selfPreview={<SelfPreview track={cameraTrack} />}
|
||||
backgroundImageRefusal={refusalMessage}
|
||||
backgroundEffectNotice={
|
||||
backgroundEffectNotice === "unavailable"
|
||||
? t("background_effects.unavailable")
|
||||
: backgroundEffectNotice === "slow"
|
||||
? t("background_effects.slow")
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -5,10 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
import {
|
||||
flushPromises,
|
||||
testScope,
|
||||
mockMuteStates,
|
||||
mockMediaDevices,
|
||||
mockVideoTrack,
|
||||
} from "../utils/test";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import type { Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
@@ -18,6 +27,15 @@ import {
|
||||
createLobbyFooterViewModel,
|
||||
} from "./CallFooterViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
import type * as BackgroundImages from "../tracks/backgroundImages";
|
||||
import {
|
||||
type AddedBackground,
|
||||
addedBackgrounds,
|
||||
maxAddedBackgrounds,
|
||||
UnusableImage,
|
||||
} from "../tracks/backgroundImages";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
@@ -28,10 +46,28 @@ vi.mock("../Platform", () => ({
|
||||
|
||||
// The SDK's own check needs WebGL and canvas APIs jsdom does not have.
|
||||
const sdkSupportMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const modernRouteMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("@livekit/track-processors", () => ({
|
||||
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
|
||||
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
|
||||
}));
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
add: vi.fn<(file: Blob) => Promise<string>>(),
|
||||
remove: vi.fn<(id: string) => Promise<void>>(),
|
||||
}));
|
||||
vi.mock("../tracks/backgroundImages", async (original) => {
|
||||
const { BehaviorSubject } = await import("rxjs");
|
||||
return {
|
||||
...(await original<typeof BackgroundImages>()),
|
||||
addedBackgrounds: {
|
||||
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
|
||||
add: store.add,
|
||||
remove: store.remove,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("livekit-client", () => ({
|
||||
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
|
||||
@@ -45,6 +81,7 @@ vi.mock("livekit-client", () => ({
|
||||
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
return {
|
||||
layout$: constant(layout),
|
||||
camera$: constant(undefined),
|
||||
edgeToEdge$: constant(false),
|
||||
showHeader$: constant(false),
|
||||
hangup: (): void => {},
|
||||
@@ -76,6 +113,16 @@ 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(
|
||||
@@ -107,6 +154,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -149,6 +197,7 @@ describe("createCallFooterViewModel", () => {
|
||||
select: vi.fn(),
|
||||
},
|
||||
}),
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -167,6 +216,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(layout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -189,6 +239,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -222,6 +273,8 @@ describe("createCallFooterViewModel", () => {
|
||||
});
|
||||
|
||||
describe("background effects", () => {
|
||||
afterEach(() => backgroundEffectSetting.setValue("none"));
|
||||
|
||||
// The lobby, because in a call the whole device switcher is already
|
||||
// withheld on a phone, whatever the verdict says.
|
||||
function lobbyOn(
|
||||
@@ -232,25 +285,230 @@ describe("createCallFooterViewModel", () => {
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipelineAsTheSdkSays(),
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
}
|
||||
|
||||
it("offers nothing on a phone, where the pipeline would refuse them", () => {
|
||||
it("offers them on a phone whose browser can run them", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("ios").toggleBlur$.value).toBeUndefined();
|
||||
expect(lobbyOn("ios").selectBackgroundEffect$.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers them where the pipeline will honour them", () => {
|
||||
it("offers them on a desktop whose browser can run them", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").toggleBlur$.value).toBeDefined();
|
||||
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers nothing where the browser itself cannot run them", () => {
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").toggleBlur$.value).toBeUndefined();
|
||||
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(lobbyOn("desktop").addBackgroundImage$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("puts no effect in force where the browser cannot run them", () => {
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("none");
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("blur");
|
||||
});
|
||||
|
||||
it("stores the effect chosen", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const vm = lobbyOn("desktop");
|
||||
vm.selectBackgroundEffect$.value?.("image:arc");
|
||||
expect(vm.backgroundEffect$.value).toBe("image:arc");
|
||||
// What isn't an effect on offer is stored as none.
|
||||
vm.selectBackgroundEffect$.value?.("image:gone");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("none");
|
||||
});
|
||||
|
||||
it("says they run slowly where only the slower route exists", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
modernRouteMock.mockReturnValue(false);
|
||||
const slow = lobbyOn("desktop");
|
||||
expect(slow.backgroundEffectNotice$.value).toBe("slow");
|
||||
expect(slow.selectBackgroundEffect$.value).toBeDefined();
|
||||
modernRouteMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers every effect in order, the added ones removable", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const added$ = addedBackgrounds.added$ as BehaviorSubject<
|
||||
AddedBackground[] | undefined
|
||||
>;
|
||||
added$.next([{ id: "mine", url: "blob:mine" }]);
|
||||
expect(lobbyOn("desktop").backgroundEffects$.value).toEqual([
|
||||
{ id: "none", kind: "none", removable: false },
|
||||
{ id: "blur", kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image",
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
{
|
||||
id: "added:mine",
|
||||
kind: "image",
|
||||
imageUrl: "blob:mine",
|
||||
removable: true,
|
||||
},
|
||||
]);
|
||||
added$.next([]);
|
||||
});
|
||||
|
||||
it("says they are unavailable where they cannot be chosen", () => {
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes on the wait for the first effect", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
wait: "settling",
|
||||
});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipeline$,
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(true);
|
||||
pipeline$.next({ ...pipeline$.value, wait: undefined });
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(false);
|
||||
});
|
||||
|
||||
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipeline$,
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffect$.value).toBe("blur");
|
||||
|
||||
pipeline$.next({ supported: false, processor: undefined });
|
||||
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
expect(vm.backgroundEffect$.value).toBe("none");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("blur");
|
||||
});
|
||||
|
||||
it("adding is unavailable at the limit", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const added$ = addedBackgrounds.added$ as BehaviorSubject<
|
||||
AddedBackground[] | undefined
|
||||
>;
|
||||
const vm = lobbyOn("desktop");
|
||||
expect(vm.addBackgroundImage$.value).toBeDefined();
|
||||
added$.next(
|
||||
Array.from({ length: maxAddedBackgrounds }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
url: `blob:${i}`,
|
||||
})),
|
||||
);
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
added$.next(added$.value!.slice(1));
|
||||
expect(vm.addBackgroundImage$.value).toBeDefined();
|
||||
added$.next([]);
|
||||
});
|
||||
|
||||
it("says why each refused file was refused", async () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const vm = lobbyOn("desktop");
|
||||
store.add.mockRejectedValue(new UnusableImage("animated"));
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
|
||||
await flushPromises();
|
||||
const first = vm.backgroundImageRefusal$.value;
|
||||
expect(first).toEqual({ reason: "animated" });
|
||||
|
||||
// The same again is a refusal of its own, so it is shown again.
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "animated" });
|
||||
expect(vm.backgroundImageRefusal$.value).not.toBe(first);
|
||||
|
||||
store.add.mockRejectedValue(new Error("quota"));
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "b.png"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "not-kept" });
|
||||
|
||||
store.add.mockResolvedValue("kept");
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "c.png"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never removes the background in force", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
store.remove.mockResolvedValue();
|
||||
const vm = lobbyOn("desktop");
|
||||
backgroundEffectSetting.setValue("added:worn");
|
||||
vm.removeBackgroundEffect$.value!("added:worn");
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
vm.removeBackgroundEffect$.value!("added:spare");
|
||||
expect(store.remove).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("previews the lobby's camera while there is one", () => {
|
||||
const track = mockVideoTrack({});
|
||||
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
camera$,
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
// The lobby releases the camera when it is turned off
|
||||
camera$.next(null);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
});
|
||||
|
||||
it("previews the call's camera only while it is not muted", () => {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
const track = mockVideoTrack({ muted$ });
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
muted$.next(true);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
muted$.next(false);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
});
|
||||
|
||||
it("availability is the same before and during a call", () => {
|
||||
@@ -262,11 +520,15 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipelineAsTheSdkSays(),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
const offeredInLobby = lobby.toggleBlur$.value !== undefined;
|
||||
expect(inCall.toggleBlur$.value !== undefined).toBe(offeredInLobby);
|
||||
const offeredInLobby =
|
||||
lobby.selectBackgroundEffect$.value !== undefined;
|
||||
expect(inCall.selectBackgroundEffect$.value !== undefined).toBe(
|
||||
offeredInLobby,
|
||||
);
|
||||
expect(offeredInLobby).toBe(supported);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,16 +5,38 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, type Observable, switchMap } from "rxjs";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { supportsBackgroundProcessors } from "../tracks/backgroundProcessing";
|
||||
import {
|
||||
parseEffect,
|
||||
serializeEffect,
|
||||
shippedBackgrounds,
|
||||
} from "../tracks/backgroundEffectCatalog";
|
||||
import {
|
||||
type AddedBackground,
|
||||
addedBackgrounds,
|
||||
maxAddedBackgrounds,
|
||||
UnusableImage,
|
||||
} from "../tracks/backgroundImages";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import {
|
||||
backgroundBlur as backgroundBlurSettings,
|
||||
backgroundEffect as backgroundEffectSetting,
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
} from "../settings/settings";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
@@ -23,7 +45,8 @@ import { type MuteStates } from "../state/MuteStates";
|
||||
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { type FooterSnapshot } from "./CallFooter";
|
||||
import { type BackgroundImageRefusal, type FooterSnapshot } from "./CallFooter";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
|
||||
/**
|
||||
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
|
||||
@@ -57,13 +80,16 @@ function buildMuteBehaviors(
|
||||
|
||||
/**
|
||||
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
|
||||
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
|
||||
* needed by FooterSnapshot (options, selection, callbacks, background effect).
|
||||
*/
|
||||
function buildDeviceBehaviors(
|
||||
scope: ObservableScope,
|
||||
mediaDevices: MediaDevices,
|
||||
/** return empty arrays for audioOptions and videoOptions*/
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
/** The camera as the pipeline has it, for the menu's preview. */
|
||||
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioOptions$"
|
||||
@@ -75,8 +101,15 @@ function buildDeviceBehaviors(
|
||||
| "videoOptions$"
|
||||
| "selectedVideo$"
|
||||
| "selectVideoButtonOption$"
|
||||
| "toggleBlur$"
|
||||
| "videoBlurEnabled$"
|
||||
| "backgroundEffect$"
|
||||
| "selectBackgroundEffect$"
|
||||
| "backgroundEffects$"
|
||||
| "backgroundEffectNotice$"
|
||||
| "backgroundEffectSettling$"
|
||||
| "addBackgroundImage$"
|
||||
| "backgroundImageRefusal$"
|
||||
| "removeBackgroundEffect$"
|
||||
| "cameraTrack$"
|
||||
> {
|
||||
const options$ = (
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
@@ -93,6 +126,19 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
);
|
||||
|
||||
// Also false once the pipeline has failed to build this session.
|
||||
const supported$ = backgroundEffectsState$.pipe(
|
||||
map(({ supported }) => supported === true),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const slow = !supportsModernBackgroundProcessors();
|
||||
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
|
||||
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
|
||||
);
|
||||
// A new object for each refusal, so the same one twice is shown twice.
|
||||
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return {
|
||||
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
|
||||
selectedAudio$: scope.behavior(
|
||||
@@ -117,18 +163,93 @@ function buildDeviceBehaviors(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
|
||||
toggleBlur$: scope.behavior(
|
||||
combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe(
|
||||
map(([current, switcherDisabled]) => {
|
||||
return !switcherDisabled && supportsBackgroundProcessors()
|
||||
? (): void => {
|
||||
backgroundBlurSettings.setValue(!current);
|
||||
backgroundEffect$: scope.behavior(
|
||||
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
|
||||
map(([raw, supported]) =>
|
||||
supported ? serializeEffect(parseEffect(raw)) : "none",
|
||||
),
|
||||
),
|
||||
),
|
||||
selectBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (id: string): void =>
|
||||
backgroundEffectSetting.setValue(
|
||||
serializeEffect(parseEffect(id)),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffects$: scope.behavior(
|
||||
addedBackgrounds.added$.pipe(
|
||||
map((added) => backgroundEffectChoices(added ?? [])),
|
||||
),
|
||||
),
|
||||
backgroundEffectNotice$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
!offered ? "unavailable" : slow ? "slow" : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffectSettling$: scope.behavior(
|
||||
backgroundEffectsState$.pipe(
|
||||
map(({ wait }) => wait !== undefined),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
false,
|
||||
),
|
||||
// Kept and offered, not put on: that waits for the user to choose it.
|
||||
addBackgroundImage$: scope.behavior(
|
||||
combineLatest([offered$, addedBackgrounds.added$]).pipe(
|
||||
map(([offered, added]) =>
|
||||
offered && (added?.length ?? 0) < maxAddedBackgrounds
|
||||
? (file: File): void => {
|
||||
refusal$.next(undefined);
|
||||
addedBackgrounds.add(file).catch((e) => {
|
||||
logger.warn("Could not keep that background", e);
|
||||
refusal$.next({
|
||||
reason: e instanceof UnusableImage ? e.reason : "not-kept",
|
||||
});
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
}),
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundImageRefusal$: refusal$,
|
||||
// None while muted: the call mutes its track rather than releasing it.
|
||||
cameraTrack$: scope.behavior(
|
||||
camera$.pipe(
|
||||
switchMap((camera) =>
|
||||
camera
|
||||
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
|
||||
: of(null),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
// Never the one in force: taking it away would leave nothing chosen.
|
||||
removeBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (optionId: string): void => {
|
||||
const effect = parseEffect(optionId);
|
||||
if (effect.kind !== "added") return;
|
||||
if (backgroundEffectSetting.getValue() === optionId) return;
|
||||
addedBackgrounds
|
||||
.remove(effect.id)
|
||||
.catch((e) =>
|
||||
logger.warn("Could not remove that background", e),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
videoBlurEnabled$: backgroundBlurSettings.value$,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +260,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 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.
|
||||
@@ -149,6 +272,7 @@ export function createCallFooterViewModel(
|
||||
callModel: CallViewModel,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
reactionIdentifier: string | undefined,
|
||||
options: { showControls: boolean; header: HeaderStyle },
|
||||
): ViewModel<FooterSnapshot> {
|
||||
@@ -163,7 +287,13 @@ export function createCallFooterViewModel(
|
||||
);
|
||||
return {
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
backgroundEffectsState$,
|
||||
callModel.camera$,
|
||||
),
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
hideControls$: constant(!showControls),
|
||||
@@ -225,6 +355,9 @@ 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 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.
|
||||
@@ -233,6 +366,8 @@ export function createLobbyFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
camera$: Behavior<LocalVideoMediaTrack | null>,
|
||||
openSettings: (() => void) | undefined,
|
||||
hangup: (() => void) | undefined,
|
||||
showLogo: boolean,
|
||||
@@ -276,6 +411,33 @@ export function createLobbyFooterViewModel(
|
||||
selectVideoButtonOption: undefined,
|
||||
}),
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
backgroundEffectsState$,
|
||||
camera$,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundEffectChoices(
|
||||
added: AddedBackground[],
|
||||
): BackgroundEffectGridItem[] {
|
||||
return [
|
||||
{ id: serializeEffect({ kind: "none" }), kind: "none", removable: false },
|
||||
{ id: serializeEffect({ kind: "blur" }), kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: serializeEffect({ kind: "shipped", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
...added.map((background) => ({
|
||||
id: serializeEffect({ kind: "added", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.url,
|
||||
removable: true,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ Please see LICENSE in the repository root for full details.
|
||||
so CSS can't size it against the call. */
|
||||
.deviceList {
|
||||
max-block-size: var(--device-list-max-height);
|
||||
/* The raised headings stay raised within the list, under the menu's frame. */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Compound's heading rule heads each section; the design adds space below it. */
|
||||
@@ -57,6 +59,12 @@ Please see LICENSE in the repository root for full details.
|
||||
margin-block-start: var(--cpd-space-7x);
|
||||
}
|
||||
|
||||
/* Raised, or the positioned effect tiles scroll over the heading they sit
|
||||
under. Compound's sticky heading is the section's aria-hidden first child. */
|
||||
.deviceList [role="group"] > [aria-hidden="true"]:first-child {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Radix focuses whatever the pointer is over, so the browser's ring would
|
||||
follow the mouse. Replaced by one shown only for keyboard focus. */
|
||||
.menu [role^="menuitem"]:focus,
|
||||
@@ -68,3 +76,56 @@ Please see LICENSE in the repository root for full details.
|
||||
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
|
||||
outline-offset: calc(-1 * var(--cpd-border-width-2));
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--cpd-space-3x);
|
||||
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
|
||||
padding: var(--cpd-space-3x);
|
||||
border-radius: var(--cpd-space-2x);
|
||||
background: var(--cpd-color-bg-subtle-secondary);
|
||||
color: var(--cpd-color-text-primary);
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
}
|
||||
|
||||
.notice > svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import { useEffect, useState, type FC, type JSX, type ReactNode } from "react";
|
||||
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";
|
||||
import { RootElementProvider } from "../RootElementContext";
|
||||
@@ -58,16 +61,63 @@ 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 }> = ({ children }) => {
|
||||
const WithACallArea: FC<{ children: ReactNode; blockSize?: number }> = ({
|
||||
children,
|
||||
// A call's size: anything smaller makes a short device list scroll, and a
|
||||
// narrower one narrows the menu.
|
||||
blockSize = 720,
|
||||
}) => {
|
||||
const [callArea, setCallArea] = useState<HTMLElement | null>(null);
|
||||
return (
|
||||
<div
|
||||
ref={setCallArea}
|
||||
style={{
|
||||
// A call's size: anything smaller makes a short device list scroll, and
|
||||
// a narrower one narrows the menu.
|
||||
blockSize: 720,
|
||||
blockSize,
|
||||
inlineSize: 1024,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
@@ -133,8 +183,6 @@ export const AudioMute: Story = {
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||
],
|
||||
videoBlurEnabled: true,
|
||||
videoBlurToggleClick: fn(),
|
||||
selectedOption: "2",
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
@@ -168,11 +216,21 @@ 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} />,
|
||||
},
|
||||
};
|
||||
|
||||
const effects: BackgroundEffectGridItem[] = [
|
||||
{ id: "none", kind: "none" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
})),
|
||||
];
|
||||
|
||||
export const VideoUnmute: Story = {
|
||||
args: {
|
||||
iconsAndLabels: "video",
|
||||
@@ -181,9 +239,486 @@ export const VideoUnmute: Story = {
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||
],
|
||||
videoBlurEnabled: true,
|
||||
videoBlurToggleClick: fn(),
|
||||
selectedOption: "2",
|
||||
backgroundEffects: effects,
|
||||
selectedBackgroundEffect: "none",
|
||||
onSelectBackgroundEffect: fn(),
|
||||
onAddBackgroundImage: fn(),
|
||||
// As the footer always gives the camera menu one.
|
||||
selfPreview: <FakeCamera />,
|
||||
},
|
||||
};
|
||||
|
||||
/** One choice among no effect, blur and the shipped images, under the cameras. */
|
||||
export const BackgroundEffects: Story = {
|
||||
args: VideoUnmute.args,
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
const section = await menu.findByRole("group", {
|
||||
name: "Background effects",
|
||||
});
|
||||
const tiles = within(section).getAllByRole("menuitemradio");
|
||||
await expect(tiles.map((tile) => tile.textContent)).toEqual([
|
||||
"None",
|
||||
"Blur",
|
||||
"Background 1",
|
||||
"Background 2",
|
||||
]);
|
||||
await expect(
|
||||
within(section).getByRole("menuitemradio", { checked: true }),
|
||||
).toHaveTextContent("None");
|
||||
|
||||
const cameras = menu.getByRole("group", { name: "Camera" });
|
||||
await expect(
|
||||
cameras.compareDocumentPosition(section) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
const [first, second, third, fourth] = tiles.map((tile) =>
|
||||
tile.getBoundingClientRect(),
|
||||
);
|
||||
await expect(second.top).toBe(first.top);
|
||||
await expect(third.top).toBe(first.top);
|
||||
await expect(fourth.top).toBeGreaterThan(first.bottom);
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
for (const tile of [first, second, third])
|
||||
await expect(tile.left >= frame.left && tile.right <= frame.right).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const add = within(section).getByRole("menuitem", { name: "Add image" });
|
||||
await expect(
|
||||
tiles[3].compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
|
||||
await userEvent.click(menu.getByRole("menuitemradio", { name: "Blur" }));
|
||||
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
|
||||
await expect(menu.getByRole("menu")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** Where effects can't run: shown, disabled and explained, except no effect. */
|
||||
export const BackgroundEffectsUnavailable: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
onSelectBackgroundEffect: undefined,
|
||||
backgroundEffectNotice:
|
||||
"Background effects are not supported on this platform.",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
const section = await menu.findByRole("group", {
|
||||
name: "Background effects",
|
||||
});
|
||||
// On the tiles' own group: the section's can't carry a description.
|
||||
await expect(
|
||||
within(section).getByRole("group"),
|
||||
).toHaveAccessibleDescription(
|
||||
"Background effects are not supported on this platform.",
|
||||
);
|
||||
for (const tile of within(section).getAllByRole("menuitemradio"))
|
||||
if (tile.textContent === "None") await expect(tile).toBeEnabled();
|
||||
else await expect(tile).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
/** Where only the slower route exists: offered and choosable, with the cost said. */
|
||||
export const BackgroundEffectsSlowInThisBrowser: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
backgroundEffectNotice:
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
const section = await menu.findByRole("group", {
|
||||
name: "Background effects",
|
||||
});
|
||||
// On the tiles' own group: the section's can't carry a description.
|
||||
await expect(
|
||||
within(section).getByRole("group"),
|
||||
).toHaveAccessibleDescription(
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
);
|
||||
for (const tile of within(section).getAllByRole("menuitemradio"))
|
||||
await expect(tile).not.toHaveAttribute("aria-disabled");
|
||||
await userEvent.click(
|
||||
within(section).getByRole("menuitemradio", { name: "Background 1" }),
|
||||
);
|
||||
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith(
|
||||
"image:arc",
|
||||
);
|
||||
|
||||
const notice = document.body.querySelector(`.${styles.notice}`)!;
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(frame.width).toBeLessThanOrEqual(296);
|
||||
await expect(notice.getBoundingClientRect().right).toBeLessThanOrEqual(
|
||||
frame.right,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The first effect of a session shows its wait on its own tile. */
|
||||
export const BackgroundEffectsSettling: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
selectedBackgroundEffect: "blur",
|
||||
backgroundEffectSettling: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
const chosen = await menu.findByRole("menuitemradio", {
|
||||
name: "Blur",
|
||||
checked: true,
|
||||
});
|
||||
await expect(chosen).toHaveAttribute("aria-busy", "true");
|
||||
const marks = chosen.querySelectorAll("svg");
|
||||
await expect(marks).toHaveLength(1);
|
||||
await expect(getComputedStyle(marks[0]).animationName).not.toBe("none");
|
||||
const section = menu.getByRole("group", { name: "Background effects" });
|
||||
for (const other of within(section).getAllByRole("menuitemradio", {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/** A file that can't be used: said where it was chosen, and nothing changes. */
|
||||
export const BackgroundImageRefused: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
selectedBackgroundEffect: "blur",
|
||||
backgroundEffectNotice:
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
backgroundImageRefusal: { text: "That file is not a supported image" },
|
||||
},
|
||||
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 menu = within(document.body);
|
||||
const message = await menu.findByText("That file is not a supported image");
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
|
||||
const scrollport = list.getBoundingClientRect();
|
||||
const shown = message.getBoundingClientRect();
|
||||
await expect(shown.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
|
||||
await expect(shown.top).toBeGreaterThanOrEqual(scrollport.top - 1);
|
||||
await expect(menu.queryByText(/run slowly/)).toBeNull();
|
||||
await expect(
|
||||
menu.getByRole("menuitemradio", { name: "Blur" }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
},
|
||||
};
|
||||
|
||||
/** A flat picture, for backgrounds of one's own. */
|
||||
function swatch(colour: string): string {
|
||||
return `data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="9"><rect width="16" height="9" fill="${colour}"/></svg>`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** One's own backgrounds are removable, never the one in force. */
|
||||
export const AddedBackgroundsCanBeRemoved: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
backgroundEffects: [
|
||||
...effects,
|
||||
{
|
||||
id: "added:one",
|
||||
kind: "image",
|
||||
imageUrl: swatch("#c2410c"),
|
||||
removable: true,
|
||||
},
|
||||
{
|
||||
id: "added:two",
|
||||
kind: "image",
|
||||
imageUrl: swatch("#1d4ed8"),
|
||||
removable: true,
|
||||
},
|
||||
],
|
||||
selectedBackgroundEffect: "added:one",
|
||||
onRemoveBackgroundEffect: fn(),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const body = within(document.body);
|
||||
|
||||
const inForce = await body.findByRole("menuitemradio", {
|
||||
name: "Background 3",
|
||||
});
|
||||
await expect(inForce).not.toHaveAttribute("aria-keyshortcuts");
|
||||
|
||||
const other = body.getByRole("menuitemradio", { name: "Background 4" });
|
||||
await expect(other).toHaveAttribute("aria-keyshortcuts", "Delete");
|
||||
other.focus();
|
||||
await userEvent.keyboard("{Delete}");
|
||||
await expect(args.onRemoveBackgroundEffect).toHaveBeenCalledWith(
|
||||
"added:two",
|
||||
);
|
||||
|
||||
const wrapper = other.parentElement!;
|
||||
await userEvent.hover(wrapper);
|
||||
const cross = wrapper.querySelector<HTMLElement>(
|
||||
"[aria-hidden]:last-child",
|
||||
)!;
|
||||
const probe = document.createElement("span");
|
||||
probe.style.color = "var(--cpd-color-icon-critical-primary)";
|
||||
cross.append(probe);
|
||||
const critical = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
await expect(getComputedStyle(cross).color).toBe(critical);
|
||||
const tile = other.getBoundingClientRect();
|
||||
const box = cross.getBoundingClientRect();
|
||||
await expect(box.right).toBeGreaterThan(tile.right);
|
||||
await expect(box.top).toBeLessThan(tile.top);
|
||||
|
||||
await userEvent.hover(cross);
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
[...document.body.querySelectorAll("div")].some(
|
||||
(d) => d.textContent === "Remove" && d.offsetParent !== null,
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Removing by pointer while the selection really changes. */
|
||||
export const RemovingWithLiveSelection: Story = {
|
||||
args: AddedBackgroundsCanBeRemoved.args,
|
||||
render: function WithLiveSelection(args): JSX.Element {
|
||||
const [selected, setSelected] = useState("none");
|
||||
const [offered, setOffered] = useState(args.backgroundEffects ?? []);
|
||||
return (
|
||||
<MediaMuteAndSwitchButton
|
||||
{...args}
|
||||
backgroundEffects={offered}
|
||||
selectedBackgroundEffect={selected}
|
||||
onSelectBackgroundEffect={setSelected}
|
||||
onRemoveBackgroundEffect={(id): void =>
|
||||
setOffered((current) => current.filter((o) => o.id !== id))
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const body = within(document.body);
|
||||
|
||||
const tile = await body.findByRole("menuitemradio", {
|
||||
name: "Background 4",
|
||||
});
|
||||
await userEvent.hover(tile);
|
||||
const cross = tile.parentElement!.querySelector<HTMLElement>(
|
||||
"[aria-hidden]:last-child",
|
||||
)!;
|
||||
await expect(cross).toBeVisible();
|
||||
await userEvent.click(cross);
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
body.queryByRole("menuitemradio", { name: "Background 4" }),
|
||||
).toBeNull(),
|
||||
);
|
||||
await expect(
|
||||
body.getByRole("menuitemradio", { name: "None" }),
|
||||
).toHaveAttribute("aria-checked", "true");
|
||||
},
|
||||
};
|
||||
|
||||
/** 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: {
|
||||
...VideoUnmute.args,
|
||||
// It sits with the effects, so it must fit the same share of the call.
|
||||
backgroundEffectNotice:
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
},
|
||||
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 menu = within(document.body);
|
||||
const last = await menu.findByRole("menuitemradio", {
|
||||
name: "Background 2",
|
||||
});
|
||||
const list = document.body.querySelector<HTMLElement>(
|
||||
`.${styles.deviceList}`,
|
||||
)!;
|
||||
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
|
||||
await expect(last.getBoundingClientRect().top).toBeGreaterThan(
|
||||
list.getBoundingClientRect().bottom,
|
||||
);
|
||||
|
||||
// Scrolled under the section's heading, the tiles pass beneath it. By
|
||||
// position: Compound's heading is the section's hidden first child.
|
||||
const heading = menu.getByRole("group", { name: "Background effects" })
|
||||
.firstElementChild as HTMLElement;
|
||||
list.scrollTop = heading.offsetTop + 30;
|
||||
await waitFor(async () => {
|
||||
const box = heading.getBoundingClientRect();
|
||||
const drawn = document.elementFromPoint(
|
||||
box.left + box.width / 2,
|
||||
box.bottom - 2,
|
||||
);
|
||||
await expect(heading.contains(drawn)).toBe(true);
|
||||
});
|
||||
list.scrollTop = 0;
|
||||
|
||||
for (let i = 0; i < 10 && document.activeElement !== last; i++)
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await expect(document.activeElement).toBe(last);
|
||||
const scrollport = list.getBoundingClientRect();
|
||||
const reached = last.getBoundingClientRect();
|
||||
await expect(reached.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
|
||||
await expect(reached.top).toBeGreaterThanOrEqual(scrollport.top - 1);
|
||||
|
||||
const callArea = canvasElement
|
||||
.querySelector<HTMLElement>("[style*='block-size: 300px']")!
|
||||
.getBoundingClientRect();
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(frame.top).toBeGreaterThanOrEqual(callArea.top);
|
||||
await expect(frame.bottom).toBeLessThanOrEqual(callArea.bottom);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -648,31 +1183,23 @@ function overlapping(element: Element, overlays: Element[]): number {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** The blur toggle gets the keyboard ring too: it is the menu's child, not the list's. */
|
||||
export const FocusRingCoversTheBlurToggle: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
iconsAndLabels: "video",
|
||||
videoBlurEnabled: false,
|
||||
videoBlurToggleClick: fn(),
|
||||
},
|
||||
/** The effect tiles get the keyboard ring the device rows get. */
|
||||
export const FocusRingCoversTheEffectTiles: Story = {
|
||||
args: VideoUnmute.args,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const toggle = await within(document.body).findByRole("menuitemcheckbox", {
|
||||
name: /Blur background/,
|
||||
const tile = await within(document.body).findByRole("menuitemradio", {
|
||||
name: "Blur",
|
||||
});
|
||||
|
||||
// Arrow down past the cameras to the toggle.
|
||||
for (let i = 0; i < 6 && document.activeElement !== toggle; i++)
|
||||
for (let i = 0; i < 6 && document.activeElement !== tile; i++)
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
await expect(document.activeElement).toBe(toggle);
|
||||
// The same ring the device rows get.
|
||||
await expect(outlineWidth(toggle)).toBeGreaterThan(0);
|
||||
await expect(document.activeElement).toBe(tile);
|
||||
await expect(outlineWidth(tile)).toBeGreaterThan(0);
|
||||
|
||||
// And the pointer takes it away, with the toggle still focused.
|
||||
await userEvent.hover(toggle);
|
||||
await expect(document.activeElement).toBe(toggle);
|
||||
await expect(outlineWidth(toggle)).toBe(0);
|
||||
await userEvent.hover(tile);
|
||||
await expect(document.activeElement).toBe(tile);
|
||||
await expect(outlineWidth(tile)).toBe(0);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,10 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest";
|
||||
import { axe } from "vitest-axe";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
@@ -22,10 +23,18 @@ import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
||||
|
||||
const effects: BackgroundEffectGridItem[] = [
|
||||
{ id: "none", kind: "none" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
{ id: "image:arc", kind: "image", imageUrl: "" },
|
||||
{ id: "image:glow", kind: "image", imageUrl: "" },
|
||||
];
|
||||
|
||||
interface RenderOptions {
|
||||
requestDeviceNames: () => void;
|
||||
}
|
||||
@@ -305,30 +314,202 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders menu with toggle control and calls toggle callback", async () => {
|
||||
test("background effects are one keyboard-operable labelled choice", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const onVideoBlurToggle = vi.fn();
|
||||
const onSelectBackgroundEffect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
videoBlurToggleClick={onVideoBlurToggle}
|
||||
onSelect={onSelect}
|
||||
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
|
||||
selectedOption="cam1"
|
||||
onSelect={vi.fn()}
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={onSelectBackgroundEffect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
|
||||
const toggle = screen.getByRole("menuitemcheckbox", {
|
||||
name: "Blur background",
|
||||
const section = screen.getByRole("group", { name: "Background effects" });
|
||||
within(section).getByRole("menuitemradio", { name: "None", checked: true });
|
||||
within(section).getByRole("menuitemradio", {
|
||||
name: "Background 1",
|
||||
checked: false,
|
||||
});
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
await user.click(toggle);
|
||||
const blur = within(section).getByRole("menuitemradio", { name: "Blur" });
|
||||
for (let i = 0; i < 6 && document.activeElement !== blur; i++)
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(document.activeElement).toBe(blur);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(onVideoBlurToggle).toHaveBeenCalled();
|
||||
test("does not choose the effect already in force again", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectBackgroundEffect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
|
||||
selectedOption="cam1"
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="blur"
|
||||
onSelectBackgroundEffect={onSelectBackgroundEffect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
await user.click(screen.getByRole("menuitemradio", { name: "Blur" }));
|
||||
expect(onSelectBackgroundEffect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("added images are chosen like shipped ones", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectBackgroundEffect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
|
||||
selectedOption="cam1"
|
||||
backgroundEffects={[
|
||||
...effects,
|
||||
{
|
||||
id: "added:mine",
|
||||
kind: "image",
|
||||
imageUrl: "",
|
||||
},
|
||||
]}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={onSelectBackgroundEffect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
const section = screen.getByRole("group", { name: "Background effects" });
|
||||
await user.click(
|
||||
within(section).getByRole("menuitemradio", { name: "Background 3" }),
|
||||
);
|
||||
expect(onSelectBackgroundEffect).toHaveBeenCalledWith("added:mine");
|
||||
});
|
||||
|
||||
test("adds the file chosen from the add tile, and stays open", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddBackgroundImage = vi.fn();
|
||||
const pick = vi
|
||||
.spyOn(HTMLInputElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
try {
|
||||
const { getByRole, container } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
|
||||
selectedOption="cam1"
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={vi.fn()}
|
||||
onAddBackgroundImage={onAddBackgroundImage}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Add image" }));
|
||||
expect(pick).toHaveBeenCalled();
|
||||
// The picker takes the focus, which would otherwise close the menu.
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
|
||||
const file = new File(["x"], "mine.png", { type: "image/png" });
|
||||
// What the picker hands back; the open menu blocks pointer events.
|
||||
fireEvent.change(
|
||||
container.querySelector<HTMLInputElement>("input[type=file]")!,
|
||||
{ target: { files: [file] } },
|
||||
);
|
||||
expect(onAddBackgroundImage).toHaveBeenCalledWith(file);
|
||||
screen.getByRole("menuitemradio", { name: "None", checked: true });
|
||||
} finally {
|
||||
pick.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refusal can be dismissed and does not outlive the menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
// jsdom lays nothing out, so has nothing to scroll.
|
||||
const scroll = Element.prototype.scrollIntoView;
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
onTestFinished(() => {
|
||||
Element.prototype.scrollIntoView = scroll;
|
||||
});
|
||||
const menu = (refusal?: { text: string }): JSX.Element =>
|
||||
withProviders(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
|
||||
selectedOption="cam1"
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="blur"
|
||||
onSelectBackgroundEffect={vi.fn()}
|
||||
onAddBackgroundImage={vi.fn()}
|
||||
backgroundImageRefusal={refusal}
|
||||
/>,
|
||||
);
|
||||
const { rerender, getByRole } = render(menu());
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
|
||||
rerender(menu({ text: "That file is not a supported image" }));
|
||||
expect(
|
||||
screen.getByText("That file is not a supported image"),
|
||||
).toBeVisible();
|
||||
screen.getByRole("menuitemradio", { name: "Blur", checked: true });
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByText("That file is not a supported image")).toBeNull();
|
||||
|
||||
// The next refusal is shown, even if it says the same.
|
||||
rerender(menu({ text: "That file is not a supported image" }));
|
||||
expect(
|
||||
screen.getByText("That file is not a supported image"),
|
||||
).toBeVisible();
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
expect(screen.queryByText("That file is not a supported image")).toBeNull();
|
||||
});
|
||||
|
||||
test("offers the background effects in a phone's drawer", async () => {
|
||||
const userAgent = vi
|
||||
.spyOn(navigator, "userAgent", "get")
|
||||
.mockReturnValue("Mozilla/5.0 (Linux; Android 14)");
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
const onSelectBackgroundEffect = vi.fn();
|
||||
const { getByRole } = renderComponent(
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={true}
|
||||
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
|
||||
selectedOption="cam1"
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={onSelectBackgroundEffect}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Camera" }));
|
||||
const section = screen.getByRole("group", { name: "Background effects" });
|
||||
// A click alone: the drawer's drag handling reads layout jsdom lacks.
|
||||
fireEvent.click(
|
||||
within(section).getByRole("menuitemradio", { name: "Background 2" }),
|
||||
);
|
||||
expect(onSelectBackgroundEffect).toHaveBeenCalledWith("image:glow");
|
||||
} finally {
|
||||
userAgent.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("marks the selected menu item as checked", async () => {
|
||||
@@ -552,7 +733,9 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
]}
|
||||
selectedOption="cam1"
|
||||
onSelect={vi.fn()}
|
||||
videoBlurToggleClick={vi.fn()}
|
||||
backgroundEffects={effects}
|
||||
selectedBackgroundEffect="none"
|
||||
onSelectBackgroundEffect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -560,9 +743,10 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
|
||||
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
|
||||
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
|
||||
expect(
|
||||
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
|
||||
).toBeInTheDocument();
|
||||
within(screen.getByRole("group", { name: "Background effects" })).getByRole(
|
||||
"menuitemradio",
|
||||
{ name: "Blur" },
|
||||
);
|
||||
});
|
||||
|
||||
test("marks focus as keyboard-driven only when the keyboard moved it", async () => {
|
||||
|
||||
@@ -7,24 +7,29 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
useEffect,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
InlineSpinner,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuScrollArea,
|
||||
MenuScrollSection,
|
||||
RadioInput,
|
||||
ToggleMenuItem,
|
||||
} from "@vector-im/compound-web";
|
||||
import {
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
InfoIcon,
|
||||
SpinnerIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
@@ -41,6 +46,10 @@ import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useRootElement } from "../RootElementContext";
|
||||
import { observeElementSize$ } from "../utils/elementSize";
|
||||
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||
import {
|
||||
BackgroundEffectGrid,
|
||||
type BackgroundEffectGridItem,
|
||||
} from "./BackgroundEffectGrid";
|
||||
|
||||
export interface MenuOptions {
|
||||
label: DeviceLabel | AudioOutputDeviceLabel;
|
||||
@@ -65,8 +74,23 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
selectedOutputOption?: string;
|
||||
/** Picks an output device. Undefined disables the speaker section. */
|
||||
onSelectOutput?: (id: string) => void;
|
||||
videoBlurToggleClick?: () => void;
|
||||
videoBlurEnabled?: boolean;
|
||||
/** Camera menu only. None or an empty list leaves the section out. */
|
||||
backgroundEffects?: BackgroundEffectGridItem[];
|
||||
selectedBackgroundEffect?: string;
|
||||
/** Undefined where effects can't run: all but no effect are disabled. */
|
||||
onSelectBackgroundEffect?: (id: string) => void;
|
||||
/** What the user should know before choosing an effect, if anything. */
|
||||
backgroundEffectNotice?: string;
|
||||
/** Whether the first effect chosen is still being prepared. */
|
||||
backgroundEffectSettling?: boolean;
|
||||
/** Called with the file chosen from the add tile. Omit to leave it out. */
|
||||
onAddBackgroundImage?: (file: File) => void;
|
||||
/** Removes a removable effect, never the one in force. */
|
||||
onRemoveBackgroundEffect?: (id: string) => void;
|
||||
/** Why the last file offered couldn't be used; a new object each time. */
|
||||
backgroundImageRefusal?: { text: string };
|
||||
/** 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`
|
||||
@@ -74,8 +98,6 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
const BLUR_ID = "blur";
|
||||
|
||||
/** Id of the placeholder "Default" row, shown when the platform lists no outputs. */
|
||||
const DEFAULT_OUTPUT_ID = "default";
|
||||
|
||||
@@ -85,9 +107,16 @@ 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,
|
||||
@@ -98,8 +127,15 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
outputOptions,
|
||||
selectedOutputOption,
|
||||
onSelectOutput,
|
||||
videoBlurEnabled,
|
||||
videoBlurToggleClick,
|
||||
backgroundEffects,
|
||||
selectedBackgroundEffect,
|
||||
onSelectBackgroundEffect,
|
||||
backgroundEffectNotice,
|
||||
backgroundEffectSettling,
|
||||
onAddBackgroundImage,
|
||||
onRemoveBackgroundEffect,
|
||||
backgroundImageRefusal,
|
||||
selfPreview,
|
||||
onSelect,
|
||||
}) => {
|
||||
// Requested but not yet selected. Keyed by kind too, since Chrome uses
|
||||
@@ -109,14 +145,46 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
id: string;
|
||||
} | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const onOpenChange = useCallback((open: boolean): void => {
|
||||
setMenuOpen(open);
|
||||
// Drop a request that never arrived.
|
||||
if (!open) setPlannedSelection(null);
|
||||
// The file picker takes the focus, which the menu reads as a click
|
||||
// elsewhere; it is held open until the picker is done.
|
||||
const choosingFile = useRef(false);
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
// A refusal is about the attempt that caused it: shown until dismissed or
|
||||
// until the menu closes, and again for the next one.
|
||||
const [seenRefusal, setSeenRefusal] = useState<object>();
|
||||
const refusal =
|
||||
backgroundImageRefusal !== seenRefusal ? backgroundImageRefusal : undefined;
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean): void => {
|
||||
if (!open && choosingFile.current) return;
|
||||
setMenuOpen(open);
|
||||
// Drop a request that never arrived.
|
||||
if (!open) setPlannedSelection(null);
|
||||
if (!open) setSeenRefusal(backgroundImageRefusal);
|
||||
},
|
||||
[backgroundImageRefusal],
|
||||
);
|
||||
// Scrolled to as it appears, as the list may be scrolled away from it.
|
||||
const scrollIntoView = useCallback((element: HTMLElement | null): void => {
|
||||
element?.scrollIntoView({ block: "nearest" });
|
||||
}, []);
|
||||
const watchFileInput = useCallback(
|
||||
(input: HTMLInputElement | null): (() => void) | undefined => {
|
||||
fileInput.current = input;
|
||||
if (input === null) return;
|
||||
// Dismissing the picker fires cancel, which React doesn't type.
|
||||
const done = (): void => {
|
||||
choosingFile.current = false;
|
||||
};
|
||||
input.addEventListener("cancel", done);
|
||||
return (): void => input.removeEventListener("cancel", done);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const isBusy = busy ?? false;
|
||||
const { t } = useTranslation();
|
||||
const devices = useMediaDevices();
|
||||
const noticeId = useId();
|
||||
|
||||
/**
|
||||
* Records on the menu whether the keyboard or the pointer moved focus, for
|
||||
@@ -146,21 +214,53 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
|
||||
// Measured on the call area: CSS can't size the portalled menu against it.
|
||||
const rootElement = useRootElement();
|
||||
const [listMaxHeight, setListMaxHeight] = useState<number>();
|
||||
const [listShare, setListShare] = useState<number>();
|
||||
const [previewBlock, setPreviewBlock] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
// Followed, since a host can resize the call while the menu is open.
|
||||
const subscription = observeElementSize$(rootElement)
|
||||
.pipe(
|
||||
map(({ height }) =>
|
||||
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
|
||||
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,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(setListMaxHeight);
|
||||
.subscribe(({ height, width }) => {
|
||||
setListShare(height);
|
||||
setPreviewBlock(Math.round((width * 9) / 16) + PREVIEW_GAP);
|
||||
});
|
||||
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]);
|
||||
@@ -187,16 +287,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const toggles =
|
||||
iconsAndLabels === "video" && videoBlurToggleClick !== undefined
|
||||
? [
|
||||
{
|
||||
label: t("action.blur_background"),
|
||||
enabled: videoBlurEnabled ?? false,
|
||||
id: BLUR_ID,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const showEffects =
|
||||
iconsAndLabels === "video" && (backgroundEffects?.length ?? 0) > 0;
|
||||
|
||||
let optionsButtonLabel: string;
|
||||
let menuTitle: string;
|
||||
@@ -320,6 +412,21 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
>
|
||||
{/* The mute button lives inside */}
|
||||
{button}
|
||||
{onAddBackgroundImage !== undefined && (
|
||||
<input
|
||||
ref={watchFileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
// Cleared, so the same file can be chosen twice in a row.
|
||||
e.target.value = "";
|
||||
choosingFile.current = false;
|
||||
if (file) onAddBackgroundImage(file);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Menu
|
||||
className={styles.menu}
|
||||
// Named for screen readers only: each section has its own heading.
|
||||
@@ -344,9 +451,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
{previewPinned && preview}
|
||||
<MenuScrollArea
|
||||
ref={trackFocusSource}
|
||||
className={styles.deviceList}
|
||||
className={classNames(styles.deviceList, {
|
||||
[styles.deviceListLeadsWithPreview]: hasPreview && !previewPinned,
|
||||
})}
|
||||
data-testid="device-list"
|
||||
style={
|
||||
{
|
||||
@@ -355,6 +465,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{hasPreview && !previewPinned && preview}
|
||||
{audioMeter && speakerOptions && (
|
||||
<MenuScrollSection title={t("settings.devices.speaker")}>
|
||||
{deviceItems(
|
||||
@@ -378,19 +489,52 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
numberedLabel,
|
||||
)}
|
||||
</MenuScrollSection>
|
||||
{showEffects && (
|
||||
<MenuScrollSection title={t("settings.background_effects_header")}>
|
||||
<BackgroundEffectGrid
|
||||
effects={backgroundEffects ?? []}
|
||||
selected={selectedBackgroundEffect}
|
||||
onSelect={onSelectBackgroundEffect}
|
||||
settling={backgroundEffectSettling}
|
||||
describedBy={
|
||||
backgroundEffectNotice === undefined ? undefined : noticeId
|
||||
}
|
||||
onAdd={
|
||||
onAddBackgroundImage === undefined
|
||||
? undefined
|
||||
: (): void => {
|
||||
choosingFile.current = true;
|
||||
fileInput.current?.click();
|
||||
}
|
||||
}
|
||||
addLabel={t("action.add_background_image")}
|
||||
onRemove={onRemoveBackgroundEffect}
|
||||
removeLabel={t("action.remove")}
|
||||
/>
|
||||
{/* In the list, so the menu grows no taller for it. */}
|
||||
{refusal !== undefined && (
|
||||
<div
|
||||
ref={scrollIntoView}
|
||||
role="none"
|
||||
className={styles.refusal}
|
||||
>
|
||||
<Alert
|
||||
type="critical"
|
||||
title={refusal.text}
|
||||
onClose={(): void => setSeenRefusal(refusal)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{refusal === undefined &&
|
||||
backgroundEffectNotice !== undefined && (
|
||||
<div id={noticeId} role="none" className={styles.notice}>
|
||||
<InfoIcon width={20} height={20} />
|
||||
<span>{backgroundEffectNotice}</span>
|
||||
</div>
|
||||
)}
|
||||
</MenuScrollSection>
|
||||
)}
|
||||
</MenuScrollArea>
|
||||
{toggles.length > 0 && <hr />}
|
||||
{toggles.map((toggle) => (
|
||||
<ToggleMenuItem
|
||||
label={toggle.label}
|
||||
onSelect={(e) => {
|
||||
videoBlurToggleClick?.();
|
||||
e.preventDefault();
|
||||
}}
|
||||
checked={toggle.enabled}
|
||||
key={toggle.id}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
||||
aria-busy="false"
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_0_"
|
||||
aria-labelledby="_r_1_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -39,7 +39,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
data-state="closed"
|
||||
id="radix-_r_5_"
|
||||
id="radix-_r_6_"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
|
||||
@@ -95,6 +95,35 @@ describe("Config.initWith", () => {
|
||||
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
|
||||
it("falls back to the defaults when fetching config.json rejects", async () => {
|
||||
// A rejected promise is what a webview gives for a missing local file.
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new TypeError("Load failed"),
|
||||
);
|
||||
vi.spyOn(logger, "info").mockImplementation(() => {});
|
||||
Reflect.set(Config, "internalInstance", undefined);
|
||||
|
||||
await expect(Config.init()).resolves.toBeUndefined();
|
||||
|
||||
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" });
|
||||
|
||||
+20
-8
@@ -140,14 +140,26 @@ export function validateConfig(config: ConfigOptions): ConfigOptions {
|
||||
}
|
||||
|
||||
async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
|
||||
const response = await fetch(fetchTarget);
|
||||
|
||||
if (isFailure(response)) {
|
||||
// Lack of a config isn't an error, we should just use the defaults.
|
||||
// Also treat a blank config as no config, assuming the status code is 0, because we don't get 404s from file:
|
||||
// URIs so this is the only way we can not fail if the file doesn't exist when loading from a file:// URI.
|
||||
return DEFAULT_CONFIG;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(fetchTarget);
|
||||
} catch (e) {
|
||||
// The embedded package ships no config.json, and some webviews (Element X
|
||||
// iOS) reject the fetch of a missing local file rather than answering 404.
|
||||
logger.info(`No config at ${fetchTarget}, using defaults`, e);
|
||||
// resolveConfig merges in the defaults.
|
||||
return {};
|
||||
}
|
||||
|
||||
return response.json();
|
||||
if (isFailure(response)) {
|
||||
// resolveConfig merges in the defaults.
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
logger.warn(`Config at ${fetchTarget} is not JSON, using defaults`, e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { HomePage } from "./HomePage";
|
||||
import { ClientContextProvider } from "../ClientContext";
|
||||
import { type ClientSessionState } from "../state/ClientSession";
|
||||
|
||||
vi.mock("./RegisteredView", () => ({
|
||||
RegisteredView: ({ client }: { client: MatrixClient }) => (
|
||||
<span>registered as {client.getUserId()}</span>
|
||||
),
|
||||
}));
|
||||
vi.mock("./UnauthenticatedView", () => ({
|
||||
UnauthenticatedView: () => <span>unauthenticated</span>,
|
||||
}));
|
||||
|
||||
test("waits for the session to be restored", () => {
|
||||
renderHome({ state: "loading" });
|
||||
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows the session's error", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
renderHome({ state: "error", error: new Error("Opened elsewhere") });
|
||||
expect(screen.getByText("Something went wrong")).toBeVisible();
|
||||
});
|
||||
|
||||
test("welcomes a signed-in user", () => {
|
||||
renderHome({
|
||||
state: "signedIn",
|
||||
client: { getUserId: () => "@alice:example.org" } as MatrixClient,
|
||||
passwordlessUser: false,
|
||||
});
|
||||
expect(screen.getByText("registered as @alice:example.org")).toBeVisible();
|
||||
});
|
||||
|
||||
test("invites a signed-out user to start a call", () => {
|
||||
renderHome({ state: "signedOut" });
|
||||
expect(screen.getByText("unauthenticated")).toBeVisible();
|
||||
});
|
||||
|
||||
function renderHome(state: ClientSessionState): void {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientContextProvider
|
||||
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
|
||||
>
|
||||
<HomePage />
|
||||
</ClientContextProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
+11
-12
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type FC } from "react";
|
||||
|
||||
import { useClientState } from "../ClientContext";
|
||||
import { useClientSessionState } from "../ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "../FullScreenView";
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
import { RegisteredView } from "./RegisteredView";
|
||||
@@ -18,17 +18,16 @@ export const HomePage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t("common.home"));
|
||||
|
||||
const clientState = useClientState();
|
||||
const session = useClientSessionState();
|
||||
|
||||
if (!clientState) {
|
||||
return <LoadingPage />;
|
||||
} else if (clientState.state === "error") {
|
||||
return <ErrorPage error={clientState.error} />;
|
||||
} else {
|
||||
return clientState.authenticated ? (
|
||||
<RegisteredView client={clientState.authenticated.client} />
|
||||
) : (
|
||||
<UnauthenticatedView />
|
||||
);
|
||||
switch (session.state) {
|
||||
case "loading":
|
||||
return <LoadingPage />;
|
||||
case "error":
|
||||
return <ErrorPage error={session.error} />;
|
||||
case "signedIn":
|
||||
return <RegisteredView client={session.client} />;
|
||||
case "signedOut":
|
||||
return <UnauthenticatedView />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type FC } from "react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
import { ClientManagerProvider } from "../ClientContext";
|
||||
import { type ClientManager } from "../state/ClientSession";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
import type * as matrixUtils from "../utils/matrix";
|
||||
import { createRoom, getRelativeRoomUrl } from "../utils/matrix";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
|
||||
vi.mock("../auth/useRecaptcha", () => ({
|
||||
useRecaptcha: () => ({
|
||||
execute: vi.fn().mockResolvedValue("captcha-response"),
|
||||
reset,
|
||||
recaptchaId: "recaptcha",
|
||||
}),
|
||||
}));
|
||||
vi.mock("../auth/useRegistrationTerms", () => ({
|
||||
useRegistrationTerms: () => ({ recaptchaKey: "site-key" }),
|
||||
}));
|
||||
vi.mock("../auth/generateRandomName", () => ({
|
||||
generateRandomName: () => "Random Name",
|
||||
}));
|
||||
vi.mock("../utils/matrix", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof matrixUtils>()),
|
||||
createRoom: vi.fn(),
|
||||
}));
|
||||
vi.mock("../UserMenuContainer", () => ({ UserMenuContainer: () => null }));
|
||||
|
||||
const reset = vi.fn();
|
||||
const client = {} as MatrixClient;
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("registers a guest, creates their call and signs them in", async () => {
|
||||
const manager = mockManager();
|
||||
const setSignedIn = vi.fn();
|
||||
vi.mocked(manager.register).mockResolvedValue({ client, setSignedIn });
|
||||
vi.mocked(createRoom).mockResolvedValue({
|
||||
roomId: "!call:example.org",
|
||||
alias: "#my-call:example.org",
|
||||
password: "secret",
|
||||
});
|
||||
renderView(manager);
|
||||
|
||||
await submit("My Call", "Alice");
|
||||
|
||||
expect(manager.register).toHaveBeenCalledWith(
|
||||
"Random Name",
|
||||
expect.stringMatching(/^.{16}$/),
|
||||
"Alice",
|
||||
"captcha-response",
|
||||
true,
|
||||
);
|
||||
expect(createRoom).toHaveBeenCalledWith(
|
||||
client,
|
||||
"My Call",
|
||||
E2eeType.SHARED_KEY,
|
||||
);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
const url = getRelativeRoomUrl(
|
||||
"!call:example.org",
|
||||
{ kind: E2eeType.SHARED_KEY, secret: "secret" },
|
||||
"My Call",
|
||||
);
|
||||
expect(await screen.findByText(`at ${url}`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("signs in only once the user chooses to join a call that exists", async () => {
|
||||
const manager = mockManager();
|
||||
const setSignedIn = vi.fn();
|
||||
vi.mocked(manager.register).mockResolvedValue({ client, setSignedIn });
|
||||
vi.mocked(createRoom).mockRejectedValue({ errcode: "M_ROOM_IN_USE" });
|
||||
renderView(manager);
|
||||
|
||||
await submit("My Call", "Alice");
|
||||
|
||||
const join = await screen.findByRole("button", { name: "Yes, join call" });
|
||||
expect(setSignedIn).not.toHaveBeenCalled();
|
||||
await userEvent.click(join);
|
||||
expect(setSignedIn).toHaveBeenCalled();
|
||||
expect(await screen.findByText("at /my-call")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows why registering failed and resets the captcha", async () => {
|
||||
const manager = mockManager();
|
||||
vi.mocked(manager.register).mockRejectedValue(new Error("Rate limited"));
|
||||
renderView(manager);
|
||||
|
||||
await submit("My Call", "Alice");
|
||||
|
||||
expect(await screen.findByText("Rate limited")).toBeVisible();
|
||||
expect(reset).toHaveBeenCalled();
|
||||
expect(createRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function submit(callName: string, displayName: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByTestId("home_callName"), callName);
|
||||
await user.type(screen.getByTestId("home_displayName"), displayName);
|
||||
await user.click(screen.getByTestId("home_go"));
|
||||
}
|
||||
|
||||
function mockManager(): ClientManager {
|
||||
return { login: vi.fn(), register: vi.fn(), logout: vi.fn() };
|
||||
}
|
||||
|
||||
const WhereAmI: FC = () => {
|
||||
const { pathname, search, hash } = useLocation();
|
||||
return <span>{`at ${pathname}${search}${hash}`}</span>;
|
||||
};
|
||||
|
||||
function renderView(manager: ClientManager): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<UrlParamsProvider value={computeUrlParams()}>
|
||||
<ClientManagerProvider value={manager}>
|
||||
<UnauthenticatedView />
|
||||
<WhereAmI />
|
||||
</ClientManagerProvider>
|
||||
</UrlParamsProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { Button, Heading, Text } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useClient } from "../ClientContext";
|
||||
import { useClientManager } from "../ClientContext";
|
||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||
import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
roomAliasLocalpartFromRoomName,
|
||||
sanitiseRoomNameInput,
|
||||
} from "../utils/matrix";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { useRegistrationTerms } from "../auth/useRegistrationTerms";
|
||||
import { JoinExistingCallModal } from "./JoinExistingCallModal";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { Form } from "../form/Form";
|
||||
@@ -37,12 +37,12 @@ import { ExternalLink, Link } from "../button/Link";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export const UnauthenticatedView: FC = () => {
|
||||
const { setClient } = useClient();
|
||||
const { register } = useClientManager();
|
||||
const { header } = useUrlParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [optInAnalytics] = useOptInAnalytics();
|
||||
const { recaptchaKey, register } = useInteractiveRegistration();
|
||||
const { recaptchaKey } = useRegistrationTerms();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
|
||||
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
|
||||
@@ -67,7 +67,9 @@ export const UnauthenticatedView: FC = () => {
|
||||
setLoading(true);
|
||||
const recaptchaResponse = await execute();
|
||||
const userName = generateRandomName();
|
||||
const [client, session] = await register(
|
||||
// Signed in only once the call exists too: this view is replaced the
|
||||
// moment the session has a user, and the call is what they came for
|
||||
const { client, setSignedIn } = await register(
|
||||
userName,
|
||||
secureRandomString(16),
|
||||
displayName,
|
||||
@@ -83,15 +85,12 @@ export const UnauthenticatedView: FC = () => {
|
||||
E2eeType.SHARED_KEY,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!setClient) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (error.errcode === "M_ROOM_IN_USE") {
|
||||
setOnFinished(() => {
|
||||
setClient(client, session);
|
||||
// Wrapped once more, as a bare function would be taken for an updater
|
||||
setOnFinished(() => () => {
|
||||
setSignedIn();
|
||||
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
|
||||
navigate(`/${aliasLocalpart}`)?.catch((error) => {
|
||||
logger.error("Failed to navigate to alias localpart", error);
|
||||
@@ -106,14 +105,10 @@ export const UnauthenticatedView: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Only consider the registration successful if we managed to create the room, too
|
||||
if (!setClient) {
|
||||
throw new Error("setClient is undefined");
|
||||
}
|
||||
if (!createRoomResult.password)
|
||||
throw new Error("Failed to create room with shared secret");
|
||||
|
||||
setClient(client, session);
|
||||
setSignedIn();
|
||||
await navigate(
|
||||
getRelativeRoomUrl(
|
||||
createRoomResult.roomId,
|
||||
@@ -130,14 +125,7 @@ export const UnauthenticatedView: FC = () => {
|
||||
reset();
|
||||
});
|
||||
},
|
||||
[
|
||||
register,
|
||||
reset,
|
||||
execute,
|
||||
navigate,
|
||||
setJoinExistingCallModalOpen,
|
||||
setClient,
|
||||
],
|
||||
[register, reset, execute, navigate, setJoinExistingCallModalOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
+19
-9
@@ -20,7 +20,9 @@ import { App } from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
import { Initializer } from "./initializer";
|
||||
import { AppViewModel } from "./state/AppViewModel";
|
||||
import { pendingHostClientSession } from "./state/ClientSession";
|
||||
import { globalScope } from "./state/ObservableScope";
|
||||
import { StandaloneClientSession } from "./state/StandaloneClientSession";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
|
||||
initRageshake().catch((e) => {
|
||||
@@ -51,17 +53,25 @@ if (fatalError !== null) {
|
||||
Initializer.initBeforeReact()
|
||||
.then((widget) => {
|
||||
const { controlledAudioDevices, callIntent } = getUrlParams();
|
||||
// As a widget, the client comes from the host over the widget API, and
|
||||
// the session is the host's. Standalone, Element Call restores or creates
|
||||
// a session of its own, which it also manages.
|
||||
const options = { controlledAudioDevices, callIntent };
|
||||
let vm: AppViewModel;
|
||||
if (widget === null) {
|
||||
const session = new StandaloneClientSession(globalScope);
|
||||
vm = new AppViewModel(globalScope, options, session, session);
|
||||
} else {
|
||||
vm = new AppViewModel(
|
||||
globalScope,
|
||||
options,
|
||||
pendingHostClientSession(globalScope, widget.client),
|
||||
null,
|
||||
);
|
||||
}
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App
|
||||
vm={
|
||||
new AppViewModel(globalScope, {
|
||||
controlledAudioDevices,
|
||||
callIntent,
|
||||
})
|
||||
}
|
||||
widget={widget}
|
||||
/>
|
||||
<App vm={vm} widget={widget} />
|
||||
</StrictMode>,
|
||||
);
|
||||
})
|
||||
|
||||
+24
-24
@@ -7,30 +7,30 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type RelationType } from "matrix-js-sdk";
|
||||
|
||||
import catSoundOgg from "../sound/reactions/cat.ogg?url";
|
||||
import catSoundMp3 from "../sound/reactions/cat.mp3?url";
|
||||
import clapSoundOgg from "../sound/reactions/clap.ogg?url";
|
||||
import clapSoundMp3 from "../sound/reactions/clap.mp3?url";
|
||||
import cricketsSoundOgg from "../sound/reactions/crickets.ogg?url";
|
||||
import cricketsSoundMp3 from "../sound/reactions/crickets.mp3?url";
|
||||
import dogSoundOgg from "../sound/reactions/dog.ogg?url";
|
||||
import dogSoundMp3 from "../sound/reactions/dog.mp3?url";
|
||||
import genericSoundOgg from "../sound/reactions/generic.ogg?url";
|
||||
import genericSoundMp3 from "../sound/reactions/generic.mp3?url";
|
||||
import lightbulbSoundOgg from "../sound/reactions/lightbulb.ogg?url";
|
||||
import lightbulbSoundMp3 from "../sound/reactions/lightbulb.mp3?url";
|
||||
import partySoundOgg from "../sound/reactions/party.ogg?url";
|
||||
import partySoundMp3 from "../sound/reactions/party.mp3?url";
|
||||
import deerSoundOgg from "../sound/reactions/deer.ogg?url";
|
||||
import deerSoundMp3 from "../sound/reactions/deer.mp3?url";
|
||||
import rockSoundOgg from "../sound/reactions/rock.ogg?url";
|
||||
import rockSoundMp3 from "../sound/reactions/rock.mp3?url";
|
||||
import waveSoundOgg from "../sound/reactions/wave.ogg?url";
|
||||
import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
|
||||
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
|
||||
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
|
||||
import bellSoundOgg from "../sound/reactions/bell.ogg?url";
|
||||
import bellSoundMp3 from "../sound/reactions/bell.mp3?url";
|
||||
import catSoundOgg from "../sound/reactions/cat.ogg?url&no-inline";
|
||||
import catSoundMp3 from "../sound/reactions/cat.mp3?url&no-inline";
|
||||
import clapSoundOgg from "../sound/reactions/clap.ogg?url&no-inline";
|
||||
import clapSoundMp3 from "../sound/reactions/clap.mp3?url&no-inline";
|
||||
import cricketsSoundOgg from "../sound/reactions/crickets.ogg?url&no-inline";
|
||||
import cricketsSoundMp3 from "../sound/reactions/crickets.mp3?url&no-inline";
|
||||
import dogSoundOgg from "../sound/reactions/dog.ogg?url&no-inline";
|
||||
import dogSoundMp3 from "../sound/reactions/dog.mp3?url&no-inline";
|
||||
import genericSoundOgg from "../sound/reactions/generic.ogg?url&no-inline";
|
||||
import genericSoundMp3 from "../sound/reactions/generic.mp3?url&no-inline";
|
||||
import lightbulbSoundOgg from "../sound/reactions/lightbulb.ogg?url&no-inline";
|
||||
import lightbulbSoundMp3 from "../sound/reactions/lightbulb.mp3?url&no-inline";
|
||||
import partySoundOgg from "../sound/reactions/party.ogg?url&no-inline";
|
||||
import partySoundMp3 from "../sound/reactions/party.mp3?url&no-inline";
|
||||
import deerSoundOgg from "../sound/reactions/deer.ogg?url&no-inline";
|
||||
import deerSoundMp3 from "../sound/reactions/deer.mp3?url&no-inline";
|
||||
import rockSoundOgg from "../sound/reactions/rock.ogg?url&no-inline";
|
||||
import rockSoundMp3 from "../sound/reactions/rock.mp3?url&no-inline";
|
||||
import waveSoundOgg from "../sound/reactions/wave.ogg?url&no-inline";
|
||||
import waveSoundMp3 from "../sound/reactions/wave.mp3?url&no-inline";
|
||||
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url&no-inline";
|
||||
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url&no-inline";
|
||||
import bellSoundOgg from "../sound/reactions/bell.ogg?url&no-inline";
|
||||
import bellSoundMp3 from "../sound/reactions/bell.mp3?url&no-inline";
|
||||
|
||||
export const ElementCallReactionEventType = "io.element.call.reaction";
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { useClientState } from "../ClientContext";
|
||||
import { useHostBridge } from "../HostBridge";
|
||||
import { ElementCallReactionEventType, type ReactionOption } from ".";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
@@ -54,9 +54,7 @@ export const ReactionsSenderProvider = ({
|
||||
vm: CallViewModel;
|
||||
}): JSX.Element => {
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const clientState = useClientState();
|
||||
const supportsReactions =
|
||||
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
|
||||
const { supportsReactions } = useHostBridge();
|
||||
const room = rtcSession.room;
|
||||
const myUserId = room.client.getUserId();
|
||||
const myDeviceId = room.client.getDeviceId();
|
||||
|
||||
@@ -8,18 +8,18 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import joinCallSoundMp3 from "../sound/join_call.mp3";
|
||||
import joinCallSoundOgg from "../sound/join_call.ogg";
|
||||
import leftCallSoundMp3 from "../sound/left_call.mp3";
|
||||
import leftCallSoundOgg from "../sound/left_call.ogg";
|
||||
import handSoundOgg from "../sound/raise_hand.ogg";
|
||||
import handSoundMp3 from "../sound/raise_hand.mp3";
|
||||
import screenShareStartedOgg from "../sound/screen_share_started.ogg";
|
||||
import screenShareStartedMp3 from "../sound/screen_share_started.mp3";
|
||||
import declineMp3 from "../sound/call_declined.mp3?url";
|
||||
import declineOgg from "../sound/call_declined.ogg?url";
|
||||
import timeoutMp3 from "../sound/call_timeout.mp3?url";
|
||||
import timeoutOgg from "../sound/call_timeout.ogg?url";
|
||||
import joinCallSoundMp3 from "../sound/join_call.mp3?url&no-inline";
|
||||
import joinCallSoundOgg from "../sound/join_call.ogg?url&no-inline";
|
||||
import leftCallSoundMp3 from "../sound/left_call.mp3?url&no-inline";
|
||||
import leftCallSoundOgg from "../sound/left_call.ogg?url&no-inline";
|
||||
import handSoundOgg from "../sound/raise_hand.ogg?url&no-inline";
|
||||
import handSoundMp3 from "../sound/raise_hand.mp3?url&no-inline";
|
||||
import screenShareStartedOgg from "../sound/screen_share_started.ogg?url&no-inline";
|
||||
import screenShareStartedMp3 from "../sound/screen_share_started.mp3?url&no-inline";
|
||||
import declineMp3 from "../sound/call_declined.mp3?url&no-inline";
|
||||
import declineOgg from "../sound/call_declined.ogg?url&no-inline";
|
||||
import timeoutMp3 from "../sound/call_timeout.mp3?url&no-inline";
|
||||
import timeoutOgg from "../sound/call_timeout.ogg?url&no-inline";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
@@ -75,7 +75,6 @@ import {
|
||||
type RTCSlotOptions,
|
||||
constant,
|
||||
} from "@element-hq/matrixrtc-sdk";
|
||||
import { map } from "rxjs";
|
||||
import {
|
||||
advancedCamera,
|
||||
advancedScreenShare,
|
||||
@@ -181,7 +180,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
capture,
|
||||
backgroundEffectsState$.value.processor,
|
||||
backgroundEffectsState$.value,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -195,9 +194,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
capture,
|
||||
videoProcessor$: scope.behavior(
|
||||
backgroundEffectsState$.pipe(map((state) => state.processor)),
|
||||
),
|
||||
backgroundEffectsState$,
|
||||
hostBridge,
|
||||
autoLeaveWhenOthersLeft,
|
||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||
@@ -241,6 +238,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
vm,
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
backgroundEffectsState$,
|
||||
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
||||
{ showControls: urlParams.showControls, header: urlParams.header },
|
||||
);
|
||||
@@ -824,9 +822,7 @@ export function rtcParticipationOptions(
|
||||
const session = config.matrix_rtc_session;
|
||||
return {
|
||||
sendNotificationType: urlParams.sendNotificationType,
|
||||
applicationData: urlParams.callIntent
|
||||
? { "m.call.intent": urlParams.callIntent }
|
||||
: undefined,
|
||||
callIntent: urlParams.callIntent,
|
||||
timings: {
|
||||
syncDisconnectGracePeriodMs: config.sync_disconnect_grace_period_ms,
|
||||
networkErrorRetryMs: session.network_error_retry_ms,
|
||||
|
||||
+13
-5
@@ -16,7 +16,6 @@ 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";
|
||||
@@ -134,9 +133,7 @@ export const LobbyView: FC<Props> = ({
|
||||
muteStates,
|
||||
devices,
|
||||
captureSettings(),
|
||||
mediaScope.behavior(
|
||||
backgroundEffectsState$.pipe(map((state) => state.processor)),
|
||||
),
|
||||
backgroundEffectsState$,
|
||||
),
|
||||
);
|
||||
return (): void => {
|
||||
@@ -158,12 +155,15 @@ export const LobbyView: FC<Props> = ({
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (mediaVm === null) return;
|
||||
const footerScope = new ObservableScope();
|
||||
setFooterVm(
|
||||
createLobbyFooterViewModel(
|
||||
footerScope,
|
||||
muteStates,
|
||||
devices,
|
||||
backgroundEffectsState$,
|
||||
mediaVm.videoTrack$,
|
||||
openSettings,
|
||||
hangup,
|
||||
// Logo and header are connected: only show the logo in SPA with header.
|
||||
@@ -173,7 +173,15 @@ export const LobbyView: FC<Props> = ({
|
||||
return (): void => {
|
||||
footerScope.end();
|
||||
};
|
||||
}, [devices, hangup, hideHeader, muteStates, openSettings]);
|
||||
}, [
|
||||
devices,
|
||||
backgroundEffectsState$,
|
||||
hangup,
|
||||
hideHeader,
|
||||
mediaVm,
|
||||
muteStates,
|
||||
openSettings,
|
||||
]);
|
||||
|
||||
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||
// animations and don't have to feel bad about reusing its CSS
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { expect, type MockedFunction, test, vi } from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { RTC_CALL_INTENT_AUDIO } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { createRingingMedia } from "../state/media/RingingMediaViewModel";
|
||||
@@ -39,7 +40,7 @@ test("ringtone plays on loop while ringing", () => {
|
||||
userId: alice.userId,
|
||||
displayName$: constant("Alice"),
|
||||
mxcAvatarUrl$: constant(undefined),
|
||||
intent: "audio",
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
pickupState$,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
||||
import ringtoneOgg from "../sound/ringtone.ogg?url";
|
||||
import ringtoneMp3 from "../sound/ringtone.mp3?url&no-inline";
|
||||
import ringtoneOgg from "../sound/ringtone.ogg?url&no-inline";
|
||||
import { type UseAudioContext, useAudioContext } from "../useAudioContext";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { RoomPage } from "./RoomPage";
|
||||
import { ClientContextProvider } from "../ClientContext";
|
||||
import { type ClientSessionState } from "../state/ClientSession";
|
||||
import { computeUrlParams, UrlParamsProvider } from "../UrlParams";
|
||||
import { useLoadGroupCall } from "./useLoadGroupCall";
|
||||
|
||||
vi.mock("./useLoadGroupCall", () => ({
|
||||
useLoadGroupCall: vi.fn(() => ({ kind: "loading" })),
|
||||
CallTerminatedMessage: class extends Error {},
|
||||
}));
|
||||
vi.mock("./useRoomName", () => ({ useRoomName: () => null }));
|
||||
vi.mock("../profile/useProfile", () => ({ useProfile: () => ({}) }));
|
||||
vi.mock("../auth/useRegisterPasswordlessUser", () => ({
|
||||
useRegisterPasswordlessUser: () => ({ registerPasswordlessUser }),
|
||||
}));
|
||||
vi.mock("./RoomAuthView", () => ({
|
||||
RoomAuthView: () => <span>room auth view</span>,
|
||||
}));
|
||||
vi.mock("../home/HomePage", () => ({
|
||||
HomePage: () => <span>home page</span>,
|
||||
}));
|
||||
vi.mock("./CallView", () => ({
|
||||
CallView: ({ isPasswordlessUser }: { isPasswordlessUser: boolean }) => (
|
||||
<span>call view, passwordless: {String(isPasswordlessUser)}</span>
|
||||
),
|
||||
}));
|
||||
vi.mock("./KnockLobbyView", () => ({ KnockLobbyView: () => null }));
|
||||
|
||||
const registerPasswordlessUser = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
test("waits for the session to be restored", () => {
|
||||
renderRoom({ state: "loading" }, "/call");
|
||||
expect(screen.getByRole("heading", { name: "Loading…" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks a signed-out user to join as a guest", () => {
|
||||
renderRoom({ state: "signedOut" }, "/call");
|
||||
expect(screen.getByText("room auth view")).toBeVisible();
|
||||
expect(registerPasswordlessUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("registers a passwordless user when the URL names one", () => {
|
||||
renderRoom({ state: "signedOut" }, "/call", "Alice");
|
||||
expect(registerPasswordlessUser).toHaveBeenCalledWith("Alice");
|
||||
});
|
||||
|
||||
test("falls back to the home page without a room", () => {
|
||||
renderRoom(signedIn(false), "/");
|
||||
expect(screen.getByText("home page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("tells the call whether its user is passwordless", () => {
|
||||
vi.mocked(useLoadGroupCall).mockReturnValue({
|
||||
kind: "loaded",
|
||||
rtcSession: { room: {} },
|
||||
} as unknown as ReturnType<typeof useLoadGroupCall>);
|
||||
|
||||
renderRoom(signedIn(true), "/call");
|
||||
expect(screen.getByText("call view, passwordless: true")).toBeVisible();
|
||||
});
|
||||
|
||||
function signedIn(passwordlessUser: boolean): ClientSessionState {
|
||||
return {
|
||||
state: "signedIn",
|
||||
client: { getUserId: () => "@alice:example.org" } as MatrixClient,
|
||||
passwordlessUser,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRoom(
|
||||
state: ClientSessionState,
|
||||
path: string,
|
||||
displayName: string | null = null,
|
||||
): void {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<UrlParamsProvider value={{ ...computeUrlParams(), displayName }}>
|
||||
<ClientContextProvider
|
||||
value={{ state$: new BehaviorSubject<ClientSessionState>(state) }}
|
||||
>
|
||||
<RoomPage />
|
||||
</ClientContextProvider>
|
||||
</UrlParamsProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { UnknownSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { useClient, useClientSessionState } from "../ClientContext";
|
||||
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
||||
import { RoomAuthView } from "./RoomAuthView";
|
||||
import { CallView } from "./CallView";
|
||||
@@ -42,8 +42,12 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
|
||||
const { loading, authenticated, client, error, passwordlessUser } =
|
||||
useClientLegacy();
|
||||
const { client } = useClient();
|
||||
const session = useClientSessionState();
|
||||
const loading = session.state === "loading";
|
||||
const passwordlessUser =
|
||||
session.state === "signedIn" && session.passwordlessUser;
|
||||
const authenticated = client !== undefined;
|
||||
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
||||
|
||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||
@@ -184,7 +188,6 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
};
|
||||
|
||||
if (loading || isRegistering) return <LoadingPage />;
|
||||
if (error) return <ErrorPage error={error} />;
|
||||
if (!client) return <RoomAuthView />;
|
||||
// TODO: This doesn't belong here, the app routes need to be reworked
|
||||
if (!roomIdOrAlias) return <HomePage />;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { SettingsModal } from "./SettingsModal";
|
||||
import { backgroundEffect } from "./settings";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { mockMediaDevices } from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
|
||||
vi.mock("../tracks/BackgroundEffectsContext", () => ({
|
||||
useBackgroundEffects: (): BackgroundEffectsState => ({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Behaviors, as the device menus read them.
|
||||
const noDevices = {
|
||||
available$: constant(new Map()),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
};
|
||||
|
||||
function renderVideoTab(): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext
|
||||
value={mockMediaDevices({
|
||||
requestDeviceNames: vi.fn(),
|
||||
audioInput: noDevices,
|
||||
audioOutput: noDevices,
|
||||
videoInput: noDevices,
|
||||
} as unknown as Partial<MediaDevices>)}
|
||||
>
|
||||
<SettingsModal
|
||||
open
|
||||
onDismiss={vi.fn()}
|
||||
tab="video"
|
||||
onTabChange={vi.fn()}
|
||||
client={
|
||||
{
|
||||
getUserId: () => "@user:example.org",
|
||||
getUser: () => null,
|
||||
} as unknown as MatrixClient
|
||||
}
|
||||
/>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const blurControl = (): HTMLElement =>
|
||||
screen.getByRole("checkbox", { name: "Blur the background of the video" });
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
afterEach(() => backgroundEffect.setValue("none"));
|
||||
|
||||
it("blur control turns blur on and off", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderVideoTab();
|
||||
expect(blurControl()).not.toBeChecked();
|
||||
|
||||
await user.click(blurControl());
|
||||
expect(backgroundEffect.getValue()).toBe("blur");
|
||||
expect(blurControl()).toBeChecked();
|
||||
|
||||
await user.click(blurControl());
|
||||
expect(backgroundEffect.getValue()).toBe("none");
|
||||
expect(blurControl()).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("blur control reflects and replaces an image background", async () => {
|
||||
const user = userEvent.setup();
|
||||
backgroundEffect.setValue("image:arc");
|
||||
renderVideoTab();
|
||||
expect(blurControl()).not.toBeChecked();
|
||||
|
||||
await user.click(blurControl());
|
||||
expect(backgroundEffect.getValue()).toBe("blur");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user