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 | ||
|
|
797a86815b |
@@ -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
|
||||
|
||||
|
||||
+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:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
|
||||
volumes:
|
||||
# The other site opens the embedded package under test in place of the
|
||||
# copy its image bundles (the first site opens the dev server's app.
|
||||
# Built by the Playwright run (see build.setup.ts).
|
||||
- ./embedded/web/dist:/app/widgets/element-call:ro,Z
|
||||
|
||||
@@ -10,11 +10,6 @@
|
||||
- Owning a resource: a class taking the scope in its constructor — `MediaDevices`,
|
||||
`MuteStates`, `TileStore`, `Publisher`, `Connection`. Both styles are current;
|
||||
don't convert one to the other in passing.
|
||||
- A factory's positional parameters are what the thing is made of: required, no
|
||||
sensible default. Its trailing `options` bag is how the host wants it to
|
||||
behave: every field optional, each defaulting to standalone behaviour, so `{}`
|
||||
works. A value with no default is never an option; a test seam is never an
|
||||
option either — mock the dependency it comes from. See `createCallViewModel$`.
|
||||
- `foo$` is an observable. `Behavior<T>`, an observable with a current value, is
|
||||
the default for anything a view reads.
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ translations. The browser is Playwright's, so a fresh clone needs
|
||||
|
||||
## End-to-end
|
||||
|
||||
The Element Web instance `HOST1` opens the app the dev server serves. The instance
|
||||
`HOST2` (the other site) opens the embedded package as a host ships it. This package
|
||||
is built by the `build-embedded` setup project and mounted in `docker-compose-playwright.yml`.
|
||||
|
||||
- `playwright/*.spec.ts` — standalone, on Chromium and Firefox.
|
||||
- `playwright/widget/` — widget mode, where cross-context bugs surface.
|
||||
- `playwright/component/` — the component in a host page, via the harness on port
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@
|
||||
"livekit-client": "^2.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
+33
-21
@@ -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"],
|
||||
|
||||
@@ -127,13 +127,14 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
* Provide a way to add additional users within a test.
|
||||
* The returned user will be registered on the default homeserver, the name will be made unique by appending a timestamp.
|
||||
*/
|
||||
addUser: async ({ browser }, use) => {
|
||||
addUser: async ({ browser }, use, testInfo) => {
|
||||
await use(
|
||||
async (
|
||||
username: string,
|
||||
host: string = HOST1,
|
||||
): Promise<UserBaseFixture> => {
|
||||
const uniqueSuffix = Date.now();
|
||||
// Tests in parallel workers can reach this in the same millisecond.
|
||||
const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`;
|
||||
const { page, clientHandle, mxId } = await TestHelpers.registerUser(
|
||||
browser,
|
||||
`${username.toLowerCase()}_${uniqueSuffix}`,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Generated
+16
-9
@@ -191,8 +191,8 @@ importers:
|
||||
specifier: ^1.9.1
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
|
||||
specifier: github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.20.0
|
||||
@@ -4734,8 +4734,8 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
resolution: {gitHosted: true, integrity: sha512-Ldt/VkCzBtre6m1cn0FqgrzRV0GrgWIoARgQUQO58ef2Edu2ERFCINPzp/psFS5RwoN3RIy9Rm4oPVc1ATkEWA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a}
|
||||
version: 45.0.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
@@ -4946,8 +4946,8 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-retry@8.0.1:
|
||||
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
|
||||
p-retry@9.0.0:
|
||||
resolution: {integrity: sha512-HuEats8gYn/UUYa5uyveyQGv75mQmwdFtuMgnZlS12dj/7bdnJSO6keaTjgcZUhvkANTj5EO3lACUmV3xmgj0Q==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
p-try@2.2.0:
|
||||
@@ -5318,6 +5318,10 @@ packages:
|
||||
randomfill@1.0.4:
|
||||
resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==}
|
||||
|
||||
re2js@2.9.1:
|
||||
resolution: {integrity: sha512-JIT3bEEUZTOR2YS1walNEIfH4cF/cQeZcBUFq7ldqO6lR6g0X5CBq/S8rsa9omzZcsNf8qc36jCkWpKKtncrZg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
react-docgen-typescript@2.4.0:
|
||||
resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
|
||||
peerDependencies:
|
||||
@@ -10409,7 +10413,7 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.8
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
|
||||
@@ -10419,7 +10423,8 @@ snapshots:
|
||||
loglevel: 1.9.2
|
||||
matrix-events-sdk: 0.0.1
|
||||
matrix-widget-api: 1.20.0
|
||||
p-retry: 8.0.1
|
||||
p-retry: 9.0.0
|
||||
re2js: 2.9.1
|
||||
sdp-transform: 3.0.0
|
||||
unhomoglyph: 1.0.6
|
||||
|
||||
@@ -10741,7 +10746,7 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-retry@8.0.1:
|
||||
p-retry@9.0.0:
|
||||
dependencies:
|
||||
is-network-error: 1.3.2
|
||||
|
||||
@@ -11165,6 +11170,8 @@ snapshots:
|
||||
randombytes: 2.1.0
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
re2js@2.9.1: {}
|
||||
|
||||
react-docgen-typescript@2.4.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
@@ -132,16 +132,16 @@ export async function createMatrixRTCSdk(
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
encryptionSystem,
|
||||
// The bundle owns its page, so the body is the space it has
|
||||
scope.behavior(observeElementSize$(document.body)),
|
||||
of({}),
|
||||
of({}),
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem,
|
||||
capture,
|
||||
hostBridge,
|
||||
// The bundle owns its page, so the body is the space it has
|
||||
windowSize$: scope.behavior(observeElementSize$(document.body)),
|
||||
},
|
||||
of({}),
|
||||
of({}),
|
||||
);
|
||||
// Once the hang-up has gone through, unstick and ask the host to close us
|
||||
vm.leave$
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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 };
|
||||
}
|
||||
@@ -45,7 +45,7 @@ interface Props {
|
||||
/** Offers to add a background of one's own. Omit to leave that tile out. */
|
||||
onAdd?: () => void;
|
||||
addLabel?: string;
|
||||
/** Removes a removable one, unless it is the selected one. Omit to offer none. */
|
||||
/** Removes a removable one, never the one in force. Omit to offer none. */
|
||||
onRemove?: (id: string) => void;
|
||||
removeLabel?: string;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
||||
import { type MediaMenuSnapshot } from "./MediaMenuViewModel";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
|
||||
// consts for tests
|
||||
const reactionIdentifier = "@user:example.com:DEVICE";
|
||||
@@ -35,57 +35,10 @@ const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
function menu(
|
||||
kind: "audio" | "video",
|
||||
withDevices: boolean,
|
||||
): MediaMenuSnapshot {
|
||||
const device = kind === "audio" ? "Microphone" : "Camera";
|
||||
return {
|
||||
enabled: true,
|
||||
busy: false,
|
||||
toggle: fn(),
|
||||
options: withDevices
|
||||
? [
|
||||
{ id: "1", label: { type: "name", name: `${device} 1` } },
|
||||
{ id: "2", label: { type: "name", name: `${device} 2` } },
|
||||
]
|
||||
: [],
|
||||
selected: "1",
|
||||
select: fn(),
|
||||
outputOptions:
|
||||
withDevices && kind === "audio"
|
||||
? [
|
||||
{
|
||||
id: "default",
|
||||
label: { type: "default", name: "Built-in Output" },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
selectedOutput: "default",
|
||||
selectOutput: fn(),
|
||||
backgroundEffect: "none",
|
||||
backgroundEffects:
|
||||
kind === "video"
|
||||
? [
|
||||
{ id: "none", kind: "none" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
]
|
||||
: [],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
selectBackgroundEffect: fn(),
|
||||
addBackgroundImage: undefined,
|
||||
removeBackgroundEffect: undefined,
|
||||
cameraTrack: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper component that is used for:
|
||||
* - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm)
|
||||
* - constructing the layout switch and the menus' view models; the menus'
|
||||
* own states are in the MediaMenuButton stories
|
||||
* - constructing the layout switch view model
|
||||
* - Add additional react context
|
||||
* The paraeters are all params from the FooterSnapshot,
|
||||
* the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter.
|
||||
@@ -96,22 +49,15 @@ function CallFooterStoryWrapper({
|
||||
children,
|
||||
layout,
|
||||
setLayout,
|
||||
withDevices,
|
||||
...vmSnapshot
|
||||
}: Omit<FooterSnapshot, "layoutSwitchVm" | "audioMenuVm" | "cameraMenuVm"> & {
|
||||
}: Omit<FooterSnapshot, "layoutSwitchVm"> & {
|
||||
children?: false | JSX.Element | JSX.Element[] | undefined;
|
||||
layout: LayoutMode | null;
|
||||
setLayout: (value: LayoutMode) => void;
|
||||
/** Whether the menus have devices to pick, or are plain mute buttons. */
|
||||
withDevices: boolean;
|
||||
}): ReactNode {
|
||||
const audioMenuVm = useStaticViewModel(menu("audio", withDevices));
|
||||
const cameraMenuVm = useStaticViewModel(menu("video", withDevices));
|
||||
const vm = useStaticViewModel({
|
||||
...vmSnapshot,
|
||||
layoutSwitchVm: layout && { layout$: constant(layout), setLayout },
|
||||
audioMenuVm,
|
||||
cameraMenuVm,
|
||||
});
|
||||
return (
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
@@ -156,6 +102,8 @@ const meta = {
|
||||
},
|
||||
toggleScreenSharing: fnArgType,
|
||||
openSettings: fnArgType,
|
||||
toggleAudio: fnArgType,
|
||||
toggleVideo: fnArgType,
|
||||
hangup: fnArgType,
|
||||
},
|
||||
} satisfies Meta<typeof CallFooterStoryWrapper>;
|
||||
@@ -168,9 +116,32 @@ export const Default: Story = {
|
||||
showLogo: false,
|
||||
layout: "grid",
|
||||
setLayout: fn(),
|
||||
withDevices: false,
|
||||
audioEnabled: true,
|
||||
audioBusy: false,
|
||||
videoEnabled: true,
|
||||
videoBusy: false,
|
||||
openSettings: fn(),
|
||||
toggleAudio: fn(),
|
||||
toggleVideo: fn(),
|
||||
toggleScreenSharing: fn(),
|
||||
selectBackgroundEffect: fn(),
|
||||
backgroundEffect: "blur",
|
||||
backgroundEffects: [
|
||||
{ id: "none", kind: "none", removable: false },
|
||||
{ id: "blur", kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
addBackgroundImage: fn(),
|
||||
removeBackgroundEffect: fn(),
|
||||
cameraTrack: null,
|
||||
hangup: fn(),
|
||||
buttonSize: "lg",
|
||||
showFooter: true,
|
||||
@@ -183,17 +154,49 @@ export const Default: Story = {
|
||||
reactionData: undefined,
|
||||
debugTileLayout: false,
|
||||
tileStoreGeneration: undefined,
|
||||
audioOptions: [],
|
||||
audioOutputOptions: [],
|
||||
videoOptions: [],
|
||||
selectedAudio: undefined,
|
||||
selectedAudioOutput: undefined,
|
||||
selectedVideo: undefined,
|
||||
selectAudioButtonOption: undefined,
|
||||
selectAudioOutputOption: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
},
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
export const StaysWhileAMenuIsOpen: Story = {
|
||||
export const WithAudioAndVideoOptions: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
withDevices: true,
|
||||
audioEnabled: false,
|
||||
videoEnabled: true,
|
||||
audioOptions: [
|
||||
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
|
||||
],
|
||||
audioOutputOptions: [
|
||||
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
|
||||
{ label: { type: "name", name: "Headset" }, id: "2" },
|
||||
],
|
||||
videoOptions: [
|
||||
{ label: { type: "name", name: "Camera 1" }, id: "1" },
|
||||
{ label: { type: "name", name: "Camera 2" }, id: "2" },
|
||||
],
|
||||
selectedAudio: "2",
|
||||
selectedAudioOutput: "default",
|
||||
selectedVideo: "1",
|
||||
},
|
||||
};
|
||||
|
||||
export const StaysWhileAMenuIsOpen: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...WithAudioAndVideoOptions.args,
|
||||
// In a short window the footer overlays the call and hides itself.
|
||||
asOverlay: true,
|
||||
showFooter: true,
|
||||
@@ -216,6 +219,25 @@ export const StaysWhileAMenuIsOpen: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioBusy: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
audioBusy: true,
|
||||
videoEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const VideoBusy: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
videoBusy: true,
|
||||
},
|
||||
};
|
||||
export const WithLogo: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
@@ -224,7 +246,45 @@ export const WithLogo: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Switching to grid, and the footer's own buttons, act. */
|
||||
export const AudioVideoEnabled: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
|
||||
await userEvent.click(spotlightRadio);
|
||||
await expect(args.setLayout).toHaveBeenCalledWith("spotlight");
|
||||
|
||||
const micButtonMute = canvas.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
});
|
||||
await userEvent.click(micButtonMute);
|
||||
await expect(args.toggleAudio).toHaveBeenCalled();
|
||||
|
||||
const videoMuteButton = canvas.getByRole("switch", {
|
||||
name: "Stop video",
|
||||
});
|
||||
await userEvent.click(videoMuteButton);
|
||||
await expect(args.toggleVideo).toHaveBeenCalled();
|
||||
const screenShare = canvas.getByRole("switch", {
|
||||
name: "Share screen",
|
||||
});
|
||||
await userEvent.click(screenShare);
|
||||
await expect(args.toggleScreenSharing).toHaveBeenCalled();
|
||||
const endCall = canvas.getByRole("button", {
|
||||
name: "End call",
|
||||
});
|
||||
await userEvent.click(endCall);
|
||||
await expect(args.hangup).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
/** used to test switching to grid mode */
|
||||
export const SpotlightMode: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
@@ -233,12 +293,10 @@ export const SpotlightMode: Story = {
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("radio", { name: "Grid" }));
|
||||
|
||||
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
|
||||
await userEvent.click(spotlightRadio);
|
||||
await expect(args.setLayout).toHaveBeenCalledWith("grid");
|
||||
await userEvent.click(canvas.getByRole("switch", { name: "Share screen" }));
|
||||
await expect(args.toggleScreenSharing).toHaveBeenCalled();
|
||||
await userEvent.click(canvas.getByRole("button", { name: "End call" }));
|
||||
await expect(args.hangup).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -272,10 +330,34 @@ export const Pip: Story = {
|
||||
buttonSize: "md",
|
||||
layout: null,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
within(canvasElement).queryByRole("radio", { name: "Spotlight" }),
|
||||
canvas.queryByRole("radio", { name: "Spotlight" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const micButtonMute = canvas.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
});
|
||||
await userEvent.click(micButtonMute);
|
||||
await expect(args.toggleAudio).toHaveBeenCalled();
|
||||
|
||||
const videoMuteButton = canvas.getByRole("switch", {
|
||||
name: "Stop video",
|
||||
});
|
||||
await userEvent.click(videoMuteButton);
|
||||
await expect(args.toggleVideo).toHaveBeenCalled();
|
||||
const screenShare = canvas.getByRole("switch", {
|
||||
name: "Share screen",
|
||||
});
|
||||
await userEvent.click(screenShare);
|
||||
await expect(args.toggleScreenSharing).toHaveBeenCalled();
|
||||
const endCall = canvas.getByRole("button", {
|
||||
name: "End call",
|
||||
});
|
||||
await userEvent.click(endCall);
|
||||
await expect(args.hangup).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
export const NoControlsWithLogo: Story = {
|
||||
@@ -296,6 +378,18 @@ export const DebugData: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const UnavailableMediaDevices: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
toggleAudio: undefined,
|
||||
toggleVideo: undefined,
|
||||
audioOutputSwitcher: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const MobileLayout: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
@@ -326,6 +420,37 @@ export const Lobby: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyMobile: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
showLogo: false,
|
||||
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
globals: {
|
||||
viewport: { value: "mobile2", isRotated: false },
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyRecentButton: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
showLogo: false,
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyRecentButtonMobile: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
|
||||
+165
-20
@@ -7,11 +7,14 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type FC, type JSX, type Ref, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
import {
|
||||
EndCallButton,
|
||||
MicButton,
|
||||
VideoButton,
|
||||
ShareScreenButton,
|
||||
SettingsButton,
|
||||
ReactionToggleButton,
|
||||
@@ -20,13 +23,23 @@ import {
|
||||
type ReactionData,
|
||||
} from "../button";
|
||||
import styles from "./CallFooter.module.css";
|
||||
import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type ViewModel } from "../state/ViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
|
||||
import { LayoutSwitch } from "../room/LayoutSwitch";
|
||||
import { type MediaMenuViewModel } from "./MediaMenuViewModel";
|
||||
import { MediaMenuButton } from "./MediaMenuButton";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import { type UnusableReason } from "../tracks/backgroundImages";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
export interface BackgroundImageRefusal {
|
||||
reason: UnusableReason | "not-kept";
|
||||
}
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
@@ -48,6 +61,16 @@ export interface AudioOutputSwitcher {
|
||||
*/
|
||||
export type FooterSnapshot = FooterActions & FooterState;
|
||||
export interface FooterActions {
|
||||
/** Also controls if the audioMute button is disabled */
|
||||
toggleAudio: (() => void) | undefined;
|
||||
/** Also controls if the videoMute button is disabled */
|
||||
toggleVideo: (() => void) | undefined;
|
||||
/** Undefined where background effects can't be chosen. */
|
||||
selectBackgroundEffect: ((id: string) => void) | undefined;
|
||||
/** Undefined where no background can be added. */
|
||||
addBackgroundImage: ((file: File) => void) | undefined;
|
||||
/** Removes an added background by its option id; never the one in force. */
|
||||
removeBackgroundEffect: ((id: string) => void) | undefined;
|
||||
toggleScreenSharing: (() => void) | undefined;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings: (() => void) | undefined;
|
||||
@@ -57,8 +80,22 @@ export interface FooterActions {
|
||||
|
||||
// we do not use any ? optional properties so that the vm type is including all fields.
|
||||
export interface FooterState {
|
||||
audioMenuVm: MediaMenuViewModel;
|
||||
cameraMenuVm: MediaMenuViewModel;
|
||||
audioEnabled: boolean;
|
||||
audioBusy: boolean;
|
||||
videoEnabled: boolean;
|
||||
videoBusy: boolean;
|
||||
/** The background effect in force, as its option id. */
|
||||
backgroundEffect: string;
|
||||
/** Every effect on offer, in the order they are shown. */
|
||||
backgroundEffects: BackgroundEffectGridItem[];
|
||||
/** What the user is told about effects here, if anything. */
|
||||
backgroundEffectNotice: "unavailable" | "slow" | undefined;
|
||||
/** Whether the first effect chosen is still being prepared. */
|
||||
backgroundEffectSettling: boolean;
|
||||
/** Why the last file offered couldn't be kept, if it couldn't. */
|
||||
backgroundImageRefusal: BackgroundImageRefusal | undefined;
|
||||
/** The camera to preview, with the effect in force; null while it is off. */
|
||||
cameraTrack: LocalVideoMediaTrack | null;
|
||||
showFooter: boolean;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
@@ -85,6 +122,19 @@ export interface FooterState {
|
||||
// debug stuff
|
||||
debugTileLayout: boolean;
|
||||
tileStoreGeneration: number | undefined;
|
||||
|
||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||
audioOptions: MenuOptions[];
|
||||
/** Output (speaker) devices. */
|
||||
audioOutputOptions: MenuOptions[];
|
||||
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
|
||||
videoOptions: MenuOptions[];
|
||||
selectedAudio: string | undefined;
|
||||
selectedAudioOutput: string | undefined;
|
||||
selectedVideo: string | undefined;
|
||||
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
|
||||
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
|
||||
selectVideoButtonOption: ((option: string) => void) | undefined;
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
@@ -100,14 +150,19 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
children,
|
||||
vm,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
const showModals = useBehavior(vm.showModals$);
|
||||
const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
|
||||
const audioMenuVm = useBehavior(vm.audioMenuVm$);
|
||||
const cameraMenuVm = useBehavior(vm.cameraMenuVm$);
|
||||
const openSettings = useBehavior(vm.openSettings$);
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const audioBusy = useBehavior(vm.audioBusy$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const videoBusy = useBehavior(vm.videoBusy$);
|
||||
const toggleAudio = useBehavior(vm.toggleAudio$);
|
||||
const toggleVideo = useBehavior(vm.toggleVideo$);
|
||||
const sharingScreen = useBehavior(vm.sharingScreen$);
|
||||
const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
|
||||
const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
|
||||
@@ -115,6 +170,38 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
const hangup = useBehavior(vm.hangup$);
|
||||
const debugTileLayout = useBehavior(vm.debugTileLayout$);
|
||||
const videoOptions = useBehavior(vm.videoOptions$);
|
||||
const selectedVideo = useBehavior(vm.selectedVideo$);
|
||||
const audioOptions = useBehavior(vm.audioOptions$);
|
||||
const selectedAudio = useBehavior(vm.selectedAudio$);
|
||||
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
|
||||
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
|
||||
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
|
||||
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
|
||||
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
|
||||
const backgroundEffect = useBehavior(vm.backgroundEffect$);
|
||||
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
|
||||
const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$);
|
||||
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
|
||||
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
|
||||
const cameraTrack = useBehavior(vm.cameraTrack$);
|
||||
const refusal = useBehavior(vm.backgroundImageRefusal$);
|
||||
const refusalMessage = useMemo(() => {
|
||||
switch (refusal?.reason) {
|
||||
case undefined:
|
||||
return undefined;
|
||||
case "not-an-image":
|
||||
return { text: t("error.background_not_an_image") };
|
||||
case "animated":
|
||||
return { text: t("error.background_animated") };
|
||||
case "undecodable":
|
||||
return { text: t("error.background_undecodable") };
|
||||
case "not-kept":
|
||||
return { text: t("error.background_not_kept") };
|
||||
}
|
||||
}, [refusal, t]);
|
||||
const backgroundEffects = useBehavior(vm.backgroundEffects$);
|
||||
const buttonSize = useBehavior(vm.buttonSize$);
|
||||
const showLogo = useBehavior(vm.showLogo$);
|
||||
|
||||
@@ -134,20 +221,78 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<MediaMenuButton
|
||||
key="audio"
|
||||
kind="audio"
|
||||
vm={audioMenuVm}
|
||||
size={buttonSize}
|
||||
/>,
|
||||
<MediaMenuButton
|
||||
key="video"
|
||||
kind="video"
|
||||
vm={cameraMenuVm}
|
||||
size={buttonSize}
|
||||
/>,
|
||||
);
|
||||
if ((audioOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
key="audio"
|
||||
iconsAndLabels="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
busy={audioBusy ?? false}
|
||||
onMuteClick={toggleAudio}
|
||||
data-testid="incall_mute"
|
||||
options={audioOptions}
|
||||
selectedOption={selectedAudio}
|
||||
onSelect={selectAudioButtonOption}
|
||||
outputOptions={audioOutputOptions}
|
||||
selectedOutputOption={selectedAudioOutput}
|
||||
onSelectOutput={selectAudioOutputOption}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
busy={audioBusy ?? false}
|
||||
onClick={toggleAudio}
|
||||
disabled={(audioBusy ?? false) || toggleAudio === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if ((videoOptions?.length ?? 0) > 0) {
|
||||
buttons.push(
|
||||
<MediaMuteAndSwitchButton
|
||||
key="video"
|
||||
iconsAndLabels="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
busy={videoBusy ?? false}
|
||||
onMuteClick={toggleVideo}
|
||||
options={videoOptions}
|
||||
selectedOption={selectedVideo}
|
||||
onSelect={selectVideoButtonOption}
|
||||
backgroundEffects={backgroundEffects}
|
||||
selectedBackgroundEffect={backgroundEffect}
|
||||
onSelectBackgroundEffect={selectBackgroundEffect}
|
||||
backgroundEffectSettling={backgroundEffectSettling}
|
||||
onAddBackgroundImage={addBackgroundImage}
|
||||
onRemoveBackgroundEffect={removeBackgroundEffect}
|
||||
selfPreview={<SelfPreview track={cameraTrack} />}
|
||||
backgroundImageRefusal={refusalMessage}
|
||||
backgroundEffectNotice={
|
||||
backgroundEffectNotice === "unavailable"
|
||||
? t("background_effects.unavailable")
|
||||
: backgroundEffectNotice === "slow"
|
||||
? t("background_effects.slow")
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
enabled={videoEnabled ?? false}
|
||||
busy={videoBusy ?? false}
|
||||
onClick={toggleVideo}
|
||||
disabled={(videoBusy ?? false) || toggleVideo === undefined}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (toggleScreenSharing !== undefined) {
|
||||
buttons.push(
|
||||
|
||||
@@ -5,15 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import {
|
||||
flushPromises,
|
||||
testScope,
|
||||
mockMuteStates,
|
||||
mockMediaDevices,
|
||||
mockVideoTrack,
|
||||
} from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import type { Layout } from "../state/layout-types";
|
||||
@@ -24,6 +27,15 @@ import {
|
||||
createLobbyFooterViewModel,
|
||||
} from "./CallFooterViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
import type * as BackgroundImages from "../tracks/backgroundImages";
|
||||
import {
|
||||
type AddedBackground,
|
||||
addedBackgrounds,
|
||||
maxAddedBackgrounds,
|
||||
UnusableImage,
|
||||
} from "../tracks/backgroundImages";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
@@ -32,6 +44,35 @@ vi.mock("../Platform", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// The SDK's own check needs WebGL and canvas APIs jsdom does not have.
|
||||
const sdkSupportMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const modernRouteMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("@livekit/track-processors", () => ({
|
||||
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
|
||||
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
|
||||
}));
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
add: vi.fn<(file: Blob) => Promise<string>>(),
|
||||
remove: vi.fn<(id: string) => Promise<void>>(),
|
||||
}));
|
||||
vi.mock("../tracks/backgroundImages", async (original) => {
|
||||
const { BehaviorSubject } = await import("rxjs");
|
||||
return {
|
||||
...(await original<typeof BackgroundImages>()),
|
||||
addedBackgrounds: {
|
||||
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
|
||||
add: store.add,
|
||||
remove: store.remove,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("livekit-client", () => ({
|
||||
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Returns the minimum set of CallViewModel fields required by
|
||||
* createCallFooterViewModel, with all other properties stubbed to
|
||||
@@ -72,82 +113,424 @@ const pipLayout: Layout = {
|
||||
spotlight: {} as SpotlightTileViewModel,
|
||||
};
|
||||
|
||||
const pipeline = constant<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
const noPipeline = constant<BackgroundEffectsState>({
|
||||
supported: false,
|
||||
processor: undefined,
|
||||
});
|
||||
|
||||
const oneOfEach = {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([["1", { type: "number", number: 1 }]]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
};
|
||||
const devices = mockMediaDevices({
|
||||
audioInput: oneOfEach,
|
||||
videoInput: oneOfEach,
|
||||
/** The provider's verdict, which the SDK's check stands for here. */
|
||||
function pipelineAsTheSdkSays(): Behavior<BackgroundEffectsState> {
|
||||
return constant({ supported: sdkSupportMock(), processor: undefined });
|
||||
}
|
||||
|
||||
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic1", { type: "number", number: 1 }],
|
||||
["mic2", { type: "name", name: "Microphone 2" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
videoInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["cam1", { type: "name", name: "Camera 1" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
describe("createCallFooterViewModel", () => {
|
||||
it.each([
|
||||
["desktop", "grid", true, gridLayout],
|
||||
["ios", "grid", false, gridLayout],
|
||||
["desktop", "pip", false, pipLayout],
|
||||
] as const)(
|
||||
"on %s in a %s layout, offers the device menus: %s",
|
||||
(platform, _, offered, layout) => {
|
||||
describe("selectAudioOutputOption", () => {
|
||||
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
return createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
}
|
||||
|
||||
it("is withheld where the platform cannot route audio to a chosen device", () => {
|
||||
outputSelectionMock.mockReturnValue(false);
|
||||
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is offered where the platform can route audio to a chosen device", () => {
|
||||
outputSelectionMock.mockReturnValue(true);
|
||||
expect(buildFooterVm().selectAudioOutputOption$.value).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioOutputOptions", () => {
|
||||
it("is an empty list, not absent, where the platform enumerates no outputs", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
outputSelectionMock.mockReturnValue(true);
|
||||
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic1", { type: "name", name: "Microphone 1" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
// As Safari: no outputs listed.
|
||||
audioOutput: {
|
||||
available$: constant(new Map<string, DeviceLabel>()),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
}),
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
// Empty, not undefined: undefined would hide the section.
|
||||
expect(vm.audioOutputOptions$.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audioOptions and videoOptions", () => {
|
||||
function checkEmptyFor(platform: string, layout: Layout): void {
|
||||
platformMock.mockReturnValue(platform);
|
||||
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(layout),
|
||||
mockMuteStates(),
|
||||
devices,
|
||||
pipeline,
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
const audio = vm.audioMenuVm$.value;
|
||||
const camera = vm.cameraMenuVm$.value;
|
||||
expect(audio.options$.value.length > 0).toBe(offered);
|
||||
expect(camera.options$.value.length > 0).toBe(offered);
|
||||
expect(camera.selectBackgroundEffect$.value !== undefined).toBe(offered);
|
||||
},
|
||||
);
|
||||
|
||||
it("previews the call's camera", () => {
|
||||
const track = mockVideoTrack({});
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
|
||||
mockMuteStates(),
|
||||
devices,
|
||||
pipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
expect(vm.cameraMenuVm$.value.cameraTrack$.value).toBe(track);
|
||||
expect(vm.audioOptions$.value).toEqual([]);
|
||||
expect(vm.videoOptions$.value).toEqual([]);
|
||||
}
|
||||
it("are both empty when the platform is iOS", () => {
|
||||
checkEmptyFor("ios", gridLayout);
|
||||
});
|
||||
it("are both empty when the layout is pip", () => {
|
||||
checkEmptyFor("desktop", pipLayout);
|
||||
});
|
||||
|
||||
it("are populated when the platform is desktop and the layout is not PiP", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$?.value).toEqual([
|
||||
{
|
||||
id: "mic1",
|
||||
label: {
|
||||
number: 1,
|
||||
type: "number",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mic2",
|
||||
label: {
|
||||
name: "Microphone 2",
|
||||
type: "name",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(vm.videoOptions$?.value).toEqual([
|
||||
{
|
||||
id: "cam1",
|
||||
label: {
|
||||
name: "Camera 1",
|
||||
type: "name",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLobbyFooterViewModel", () => {
|
||||
it("offers the device menus even on a phone, previewing the lobby's camera", () => {
|
||||
platformMock.mockReturnValue("ios");
|
||||
const track = mockVideoTrack({});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
devices,
|
||||
pipeline,
|
||||
constant(track),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
const camera = vm.cameraMenuVm$.value;
|
||||
expect(vm.audioMenuVm$.value.options$.value).not.toEqual([]);
|
||||
expect(camera.options$.value).not.toEqual([]);
|
||||
expect(camera.selectBackgroundEffect$.value).toBeDefined();
|
||||
expect(camera.cameraTrack$.value).toBe(track);
|
||||
|
||||
describe("background effects", () => {
|
||||
afterEach(() => backgroundEffectSetting.setValue("none"));
|
||||
|
||||
// The lobby, because in a call the whole device switcher is already
|
||||
// withheld on a phone, whatever the verdict says.
|
||||
function lobbyOn(
|
||||
platform: string,
|
||||
): ReturnType<typeof createLobbyFooterViewModel> {
|
||||
platformMock.mockReturnValue(platform);
|
||||
return createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipelineAsTheSdkSays(),
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
}
|
||||
|
||||
it("offers them on a phone whose browser can run them", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("ios").selectBackgroundEffect$.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers them on a desktop whose browser can run them", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers nothing where the browser itself cannot run them", () => {
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(lobbyOn("desktop").addBackgroundImage$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("puts no effect in force where the browser cannot run them", () => {
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("none");
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("blur");
|
||||
});
|
||||
|
||||
it("stores the effect chosen", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const vm = lobbyOn("desktop");
|
||||
vm.selectBackgroundEffect$.value?.("image:arc");
|
||||
expect(vm.backgroundEffect$.value).toBe("image:arc");
|
||||
// What isn't an effect on offer is stored as none.
|
||||
vm.selectBackgroundEffect$.value?.("image:gone");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("none");
|
||||
});
|
||||
|
||||
it("says they run slowly where only the slower route exists", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
modernRouteMock.mockReturnValue(false);
|
||||
const slow = lobbyOn("desktop");
|
||||
expect(slow.backgroundEffectNotice$.value).toBe("slow");
|
||||
expect(slow.selectBackgroundEffect$.value).toBeDefined();
|
||||
modernRouteMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers every effect in order, the added ones removable", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const added$ = addedBackgrounds.added$ as BehaviorSubject<
|
||||
AddedBackground[] | undefined
|
||||
>;
|
||||
added$.next([{ id: "mine", url: "blob:mine" }]);
|
||||
expect(lobbyOn("desktop").backgroundEffects$.value).toEqual([
|
||||
{ id: "none", kind: "none", removable: false },
|
||||
{ id: "blur", kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image",
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
{
|
||||
id: "added:mine",
|
||||
kind: "image",
|
||||
imageUrl: "blob:mine",
|
||||
removable: true,
|
||||
},
|
||||
]);
|
||||
added$.next([]);
|
||||
});
|
||||
|
||||
it("says they are unavailable where they cannot be chosen", () => {
|
||||
sdkSupportMock.mockReturnValue(false);
|
||||
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes on the wait for the first effect", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
wait: "settling",
|
||||
});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipeline$,
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(true);
|
||||
pipeline$.next({ ...pipeline$.value, wait: undefined });
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(false);
|
||||
});
|
||||
|
||||
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
});
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipeline$,
|
||||
/* camera$ */ constant(null),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffect$.value).toBe("blur");
|
||||
|
||||
pipeline$.next({ supported: false, processor: undefined });
|
||||
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
expect(vm.backgroundEffect$.value).toBe("none");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("blur");
|
||||
});
|
||||
|
||||
it("adding is unavailable at the limit", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const added$ = addedBackgrounds.added$ as BehaviorSubject<
|
||||
AddedBackground[] | undefined
|
||||
>;
|
||||
const vm = lobbyOn("desktop");
|
||||
expect(vm.addBackgroundImage$.value).toBeDefined();
|
||||
added$.next(
|
||||
Array.from({ length: maxAddedBackgrounds }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
url: `blob:${i}`,
|
||||
})),
|
||||
);
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
added$.next(added$.value!.slice(1));
|
||||
expect(vm.addBackgroundImage$.value).toBeDefined();
|
||||
added$.next([]);
|
||||
});
|
||||
|
||||
it("says why each refused file was refused", async () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
const vm = lobbyOn("desktop");
|
||||
store.add.mockRejectedValue(new UnusableImage("animated"));
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
|
||||
await flushPromises();
|
||||
const first = vm.backgroundImageRefusal$.value;
|
||||
expect(first).toEqual({ reason: "animated" });
|
||||
|
||||
// The same again is a refusal of its own, so it is shown again.
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "animated" });
|
||||
expect(vm.backgroundImageRefusal$.value).not.toBe(first);
|
||||
|
||||
store.add.mockRejectedValue(new Error("quota"));
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "b.png"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "not-kept" });
|
||||
|
||||
store.add.mockResolvedValue("kept");
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "c.png"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never removes the background in force", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
store.remove.mockResolvedValue();
|
||||
const vm = lobbyOn("desktop");
|
||||
backgroundEffectSetting.setValue("added:worn");
|
||||
vm.removeBackgroundEffect$.value!("added:worn");
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
vm.removeBackgroundEffect$.value!("added:spare");
|
||||
expect(store.remove).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("previews the lobby's camera while there is one", () => {
|
||||
const track = mockVideoTrack({});
|
||||
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
camera$,
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
// The lobby releases the camera when it is turned off
|
||||
camera$.next(null);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
});
|
||||
|
||||
it("previews the call's camera only while it is not muted", () => {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
const track = mockVideoTrack({ muted$ });
|
||||
const vm = createCallFooterViewModel(
|
||||
testScope(),
|
||||
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
noPipeline,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
muted$.next(true);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
muted$.next(false);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
});
|
||||
|
||||
it("availability is the same before and during a call", () => {
|
||||
for (const supported of [true, false]) {
|
||||
sdkSupportMock.mockReturnValue(supported);
|
||||
const lobby = lobbyOn("desktop");
|
||||
const inCall = createCallFooterViewModel(
|
||||
testScope(),
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
pipelineAsTheSdkSays(),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
const offeredInLobby =
|
||||
lobby.selectBackgroundEffect$.value !== undefined;
|
||||
expect(inCall.selectBackgroundEffect$.value !== undefined).toBe(
|
||||
offeredInLobby,
|
||||
);
|
||||
expect(offeredInLobby).toBe(supported);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,24 +5,253 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, type Observable } from "rxjs";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
parseEffect,
|
||||
serializeEffect,
|
||||
shippedBackgrounds,
|
||||
} from "../tracks/backgroundEffectCatalog";
|
||||
import {
|
||||
type AddedBackground,
|
||||
addedBackgrounds,
|
||||
maxAddedBackgrounds,
|
||||
UnusableImage,
|
||||
} from "../tracks/backgroundImages";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { debugTileLayout as debugTileLayoutSetting } from "../settings/settings";
|
||||
import {
|
||||
backgroundEffect as backgroundEffectSetting,
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
} from "../settings/settings";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import type { ObservableScope } from "../state/ObservableScope";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { type FooterSnapshot } from "./CallFooter";
|
||||
import {
|
||||
createAudioMenuViewModel,
|
||||
createCameraMenuViewModel,
|
||||
} from "./MediaMenuViewModel";
|
||||
import { type BackgroundImageRefusal, type FooterSnapshot } from "./CallFooter";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
|
||||
/**
|
||||
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
|
||||
* needed by FooterSnapshot.
|
||||
*/
|
||||
function buildMuteBehaviors(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioEnabled$"
|
||||
| "audioBusy$"
|
||||
| "toggleAudio$"
|
||||
| "videoEnabled$"
|
||||
| "videoBusy$"
|
||||
| "toggleVideo$"
|
||||
> {
|
||||
return {
|
||||
audioEnabled$: muteStates.audio.enabled$,
|
||||
audioBusy$: muteStates.audio.syncing$,
|
||||
toggleAudio$: scope.behavior(
|
||||
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
|
||||
),
|
||||
videoEnabled$: muteStates.video.enabled$,
|
||||
videoBusy$: muteStates.video.syncing$,
|
||||
toggleVideo$: scope.behavior(
|
||||
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
|
||||
* needed by FooterSnapshot (options, selection, callbacks, background effect).
|
||||
*/
|
||||
function buildDeviceBehaviors(
|
||||
scope: ObservableScope,
|
||||
mediaDevices: MediaDevices,
|
||||
/** return empty arrays for audioOptions and videoOptions*/
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
/** The camera as the pipeline has it, for the menu's preview. */
|
||||
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioOptions$"
|
||||
| "selectedAudio$"
|
||||
| "selectAudioButtonOption$"
|
||||
| "audioOutputOptions$"
|
||||
| "selectedAudioOutput$"
|
||||
| "selectAudioOutputOption$"
|
||||
| "videoOptions$"
|
||||
| "selectedVideo$"
|
||||
| "selectVideoButtonOption$"
|
||||
| "backgroundEffect$"
|
||||
| "selectBackgroundEffect$"
|
||||
| "backgroundEffects$"
|
||||
| "backgroundEffectNotice$"
|
||||
| "backgroundEffectSettling$"
|
||||
| "addBackgroundImage$"
|
||||
| "backgroundImageRefusal$"
|
||||
| "removeBackgroundEffect$"
|
||||
| "cameraTrack$"
|
||||
> {
|
||||
const options$ = (
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
): Observable<MenuOptions[]> =>
|
||||
disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({ id, label })),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Also false once the pipeline has failed to build this session.
|
||||
const supported$ = backgroundEffectsState$.pipe(
|
||||
map(({ supported }) => supported === true),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const slow = !supportsModernBackgroundProcessors();
|
||||
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
|
||||
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
|
||||
);
|
||||
// A new object for each refusal, so the same one twice is shown twice.
|
||||
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return {
|
||||
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
|
||||
selectedAudio$: scope.behavior(
|
||||
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
|
||||
audioOutputOptions$: scope.behavior(
|
||||
options$(mediaDevices.audioOutput.available$),
|
||||
),
|
||||
selectedAudioOutput$: scope.behavior(
|
||||
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
// Withheld where the platform can't route audio to a chosen device, which
|
||||
// disables the speaker section.
|
||||
selectAudioOutputOption$: constant(
|
||||
supportsAudioOutputSelection()
|
||||
? mediaDevices.audioOutput.select
|
||||
: undefined,
|
||||
),
|
||||
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
|
||||
selectedVideo$: scope.behavior(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
|
||||
backgroundEffect$: scope.behavior(
|
||||
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
|
||||
map(([raw, supported]) =>
|
||||
supported ? serializeEffect(parseEffect(raw)) : "none",
|
||||
),
|
||||
),
|
||||
),
|
||||
selectBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (id: string): void =>
|
||||
backgroundEffectSetting.setValue(
|
||||
serializeEffect(parseEffect(id)),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffects$: scope.behavior(
|
||||
addedBackgrounds.added$.pipe(
|
||||
map((added) => backgroundEffectChoices(added ?? [])),
|
||||
),
|
||||
),
|
||||
backgroundEffectNotice$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
!offered ? "unavailable" : slow ? "slow" : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffectSettling$: scope.behavior(
|
||||
backgroundEffectsState$.pipe(
|
||||
map(({ wait }) => wait !== undefined),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
false,
|
||||
),
|
||||
// Kept and offered, not put on: that waits for the user to choose it.
|
||||
addBackgroundImage$: scope.behavior(
|
||||
combineLatest([offered$, addedBackgrounds.added$]).pipe(
|
||||
map(([offered, added]) =>
|
||||
offered && (added?.length ?? 0) < maxAddedBackgrounds
|
||||
? (file: File): void => {
|
||||
refusal$.next(undefined);
|
||||
addedBackgrounds.add(file).catch((e) => {
|
||||
logger.warn("Could not keep that background", e);
|
||||
refusal$.next({
|
||||
reason: e instanceof UnusableImage ? e.reason : "not-kept",
|
||||
});
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundImageRefusal$: refusal$,
|
||||
// None while muted: the call mutes its track rather than releasing it.
|
||||
cameraTrack$: scope.behavior(
|
||||
camera$.pipe(
|
||||
switchMap((camera) =>
|
||||
camera
|
||||
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
|
||||
: of(null),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
// Never the one in force: taking it away would leave nothing chosen.
|
||||
removeBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (optionId: string): void => {
|
||||
const effect = parseEffect(optionId);
|
||||
if (effect.kind !== "added") return;
|
||||
if (backgroundEffectSetting.getValue() === optionId) return;
|
||||
addedBackgrounds
|
||||
.remove(effect.id)
|
||||
.catch((e) =>
|
||||
logger.warn("Could not remove that background", e),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the ViewModel for the CallFooter.
|
||||
@@ -57,23 +286,13 @@ export function createCallFooterViewModel(
|
||||
isPip$.pipe(map((isPip) => isPip || platform !== "desktop")),
|
||||
);
|
||||
return {
|
||||
audioMenuVm$: constant(
|
||||
createAudioMenuViewModel(
|
||||
scope,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
),
|
||||
),
|
||||
cameraMenuVm$: constant(
|
||||
createCameraMenuViewModel(
|
||||
scope,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
backgroundEffectsState$,
|
||||
callModel.camera$,
|
||||
),
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
backgroundEffectsState$,
|
||||
callModel.camera$,
|
||||
),
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
@@ -153,40 +372,72 @@ export function createLobbyFooterViewModel(
|
||||
hangup: (() => void) | undefined,
|
||||
showLogo: boolean,
|
||||
): ViewModel<FooterSnapshot> {
|
||||
return createStaticViewModel({
|
||||
// we can safly skip any props that we do not need.
|
||||
// The view model will then have less keys.
|
||||
// But as soon as we call `useViewModel` and convert back to a snapshot the missing props will
|
||||
// be correcty matching the snapshot type.
|
||||
showLogo,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
buttonSize: "lg",
|
||||
openSettings,
|
||||
hangup,
|
||||
debugTileLayout: false,
|
||||
showFooter: true,
|
||||
toggleScreenSharing: undefined,
|
||||
layoutSwitchVm: null,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
reactionData: undefined,
|
||||
tileStoreGeneration: undefined,
|
||||
audioMenuVm: createAudioMenuViewModel(
|
||||
return {
|
||||
...createStaticViewModel({
|
||||
// we can safly skip any props that we do not need.
|
||||
// The view model will then have less keys.
|
||||
// But as soon as we call `useViewModel` and convert back to a snapshot the missing props will
|
||||
// be correcty matching the snapshot type.
|
||||
showLogo,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
buttonSize: "lg",
|
||||
openSettings,
|
||||
hangup,
|
||||
debugTileLayout: false,
|
||||
showFooter: true,
|
||||
toggleAudio: undefined,
|
||||
toggleVideo: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
audioEnabled: undefined,
|
||||
audioBusy: false,
|
||||
videoEnabled: undefined,
|
||||
videoBusy: false,
|
||||
layoutSwitchVm: null,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
reactionData: undefined,
|
||||
tileStoreGeneration: undefined,
|
||||
audioOptions: undefined,
|
||||
audioOutputOptions: undefined,
|
||||
videoOptions: undefined,
|
||||
selectedAudio: undefined,
|
||||
selectedAudioOutput: undefined,
|
||||
selectedVideo: undefined,
|
||||
selectAudioButtonOption: undefined,
|
||||
selectAudioOutputOption: undefined,
|
||||
selectVideoButtonOption: undefined,
|
||||
}),
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
),
|
||||
cameraMenuVm: createCameraMenuViewModel(
|
||||
scope,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
backgroundEffectsState$,
|
||||
camera$,
|
||||
),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundEffectChoices(
|
||||
added: AddedBackground[],
|
||||
): BackgroundEffectGridItem[] {
|
||||
return [
|
||||
{ id: serializeEffect({ kind: "none" }), kind: "none", removable: false },
|
||||
{ id: serializeEffect({ kind: "blur" }), kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: serializeEffect({ kind: "shipped", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
...added.map((background) => ({
|
||||
id: serializeEffect({ kind: "added", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.url,
|
||||
removable: true,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { type JSX, type ReactNode } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MediaMenuButton } from "./MediaMenuButton";
|
||||
import { type MediaMenuSnapshot } from "./MediaMenuViewModel";
|
||||
import { useStaticViewModel } from "../state/ViewModel";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
|
||||
const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
/** Builds the view model from the story's args, so each field is a control. */
|
||||
function MediaMenuButtonStoryWrapper({
|
||||
kind,
|
||||
...snapshot
|
||||
}: MediaMenuSnapshot & { kind: "audio" | "video" }): ReactNode {
|
||||
const vm = useStaticViewModel(snapshot);
|
||||
return <MediaMenuButton kind={kind} vm={vm} size="lg" />;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
component: MediaMenuButtonStoryWrapper,
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<Story />
|
||||
</MediaDevicesContext>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof MediaMenuButtonStoryWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const empty = {
|
||||
outputOptions: [],
|
||||
selectedOutput: undefined,
|
||||
selectOutput: undefined,
|
||||
backgroundEffect: undefined,
|
||||
backgroundEffects: [],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
selectBackgroundEffect: undefined,
|
||||
addBackgroundImage: undefined,
|
||||
removeBackgroundEffect: undefined,
|
||||
cameraTrack: null,
|
||||
} satisfies Partial<MediaMenuSnapshot>;
|
||||
|
||||
export const Microphone: Story = {
|
||||
args: {
|
||||
...empty,
|
||||
kind: "audio",
|
||||
enabled: true,
|
||||
busy: false,
|
||||
toggle: fn(),
|
||||
options: [
|
||||
{ id: "mic1", label: { type: "name", name: "Microphone 1" } },
|
||||
{ id: "mic2", label: { type: "name", name: "Microphone 2" } },
|
||||
],
|
||||
selected: "mic1",
|
||||
select: fn(),
|
||||
outputOptions: [
|
||||
{ id: "default", label: { type: "default", name: "Built-in Output" } },
|
||||
],
|
||||
selectedOutput: "default",
|
||||
selectOutput: fn(),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("switch", { name: "Mute microphone" }),
|
||||
);
|
||||
await expect(args.toggle).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const Camera: Story = {
|
||||
args: {
|
||||
...empty,
|
||||
kind: "video",
|
||||
enabled: true,
|
||||
busy: false,
|
||||
toggle: fn(),
|
||||
options: [{ id: "cam1", label: { type: "name", name: "Camera 1" } }],
|
||||
selected: "cam1",
|
||||
select: fn(),
|
||||
backgroundEffect: "none",
|
||||
backgroundEffects: [
|
||||
{ id: "none", kind: "none" },
|
||||
{ id: "blur", kind: "blur" },
|
||||
],
|
||||
selectBackgroundEffect: fn(),
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("switch", { name: "Stop video" }),
|
||||
);
|
||||
await expect(args.toggle).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const CameraBusy: Story = {
|
||||
args: { ...Camera.args, busy: true },
|
||||
};
|
||||
|
||||
/** Opens the effects, for the stories that check what they say. */
|
||||
async function openEffects(canvasElement: HTMLElement): Promise<HTMLElement> {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole("button", { name: "Camera" }),
|
||||
);
|
||||
return within(document.body).findByRole("group", {
|
||||
name: "Background effects",
|
||||
});
|
||||
}
|
||||
|
||||
/** The view model says why; the view says it in words. */
|
||||
export const CameraEffectsUnavailable: Story = {
|
||||
args: {
|
||||
...Camera.args,
|
||||
selectBackgroundEffect: undefined,
|
||||
backgroundEffectNotice: "unavailable",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const section = await openEffects(canvasElement);
|
||||
await expect(
|
||||
within(section).getByRole("group"),
|
||||
).toHaveAccessibleDescription(
|
||||
"Background effects are not supported on this platform.",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CameraEffectsSlow: Story = {
|
||||
args: {
|
||||
...Camera.args,
|
||||
backgroundEffectNotice: "slow",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const section = await openEffects(canvasElement);
|
||||
await expect(
|
||||
within(section).getByRole("group"),
|
||||
).toHaveAccessibleDescription(
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const CameraFileRefused: Story = {
|
||||
args: {
|
||||
...Camera.args,
|
||||
addBackgroundImage: fn(),
|
||||
backgroundImageRefusal: { reason: "not-an-image" },
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await openEffects(canvasElement);
|
||||
await expect(
|
||||
await within(document.body).findByText(
|
||||
"That file is not a supported image",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** No devices to pick and none to mute: a plain, disabled mute button. */
|
||||
export const WithoutDevices: Story = {
|
||||
args: {
|
||||
...Camera.args,
|
||||
enabled: false,
|
||||
toggle: undefined,
|
||||
options: [],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.queryByRole("button", { name: "Camera" }),
|
||||
).not.toBeInTheDocument();
|
||||
await expect(canvas.getByTestId("incall_videomute")).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MicButton, VideoButton } from "../button";
|
||||
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaMenuViewModel } from "./MediaMenuViewModel";
|
||||
import { SelfPreview } from "./SelfPreview";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface Props {
|
||||
vm: MediaMenuViewModel;
|
||||
kind: "audio" | "video";
|
||||
size: "md" | "lg";
|
||||
}
|
||||
|
||||
/**
|
||||
* The microphone's or the camera's mute button: with a menu to pick the
|
||||
* device while there are devices to offer, a plain mute button otherwise.
|
||||
*/
|
||||
export const MediaMenuButton: FC<Props> = ({ vm, kind, size }) => {
|
||||
const { t } = useTranslation();
|
||||
const enabled = useBehavior(vm.enabled$);
|
||||
const busy = useBehavior(vm.busy$);
|
||||
const toggle = useBehavior(vm.toggle$);
|
||||
const options = useBehavior(vm.options$);
|
||||
const selected = useBehavior(vm.selected$);
|
||||
const select = useBehavior(vm.select$);
|
||||
const outputOptions = useBehavior(vm.outputOptions$);
|
||||
const selectedOutput = useBehavior(vm.selectedOutput$);
|
||||
const selectOutput = useBehavior(vm.selectOutput$);
|
||||
const backgroundEffect = useBehavior(vm.backgroundEffect$);
|
||||
const backgroundEffects = useBehavior(vm.backgroundEffects$);
|
||||
const notice = useBehavior(vm.backgroundEffectNotice$);
|
||||
const settling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const refusal = useBehavior(vm.backgroundImageRefusal$);
|
||||
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
|
||||
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
|
||||
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
|
||||
const cameraTrack = useBehavior(vm.cameraTrack$);
|
||||
|
||||
const testId = kind === "audio" ? "incall_mute" : "incall_videomute";
|
||||
|
||||
const noticeText = useMemo(() => {
|
||||
switch (notice) {
|
||||
case undefined:
|
||||
return undefined;
|
||||
case "unavailable":
|
||||
return t("background_effects.unavailable");
|
||||
case "slow":
|
||||
return t("background_effects.slow");
|
||||
}
|
||||
}, [notice, t]);
|
||||
// A new object for each refusal, so the same one twice is shown twice.
|
||||
const refusalMessage = useMemo(() => {
|
||||
switch (refusal?.reason) {
|
||||
case undefined:
|
||||
return undefined;
|
||||
case "not-an-image":
|
||||
return { text: t("error.background_not_an_image") };
|
||||
case "animated":
|
||||
return { text: t("error.background_animated") };
|
||||
case "undecodable":
|
||||
return { text: t("error.background_undecodable") };
|
||||
case "not-kept":
|
||||
return { text: t("error.background_not_kept") };
|
||||
}
|
||||
}, [refusal, t]);
|
||||
|
||||
if (options.length === 0) {
|
||||
const PlainButton = kind === "audio" ? MicButton : VideoButton;
|
||||
return (
|
||||
<PlainButton
|
||||
size={size}
|
||||
enabled={enabled}
|
||||
busy={busy}
|
||||
onClick={toggle}
|
||||
disabled={busy || toggle === undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return kind === "audio" ? (
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="audio"
|
||||
enabled={enabled}
|
||||
busy={busy}
|
||||
onMuteClick={toggle}
|
||||
data-testid={testId}
|
||||
options={options}
|
||||
selectedOption={selected}
|
||||
onSelect={select}
|
||||
outputOptions={outputOptions}
|
||||
selectedOutputOption={selectedOutput}
|
||||
onSelectOutput={selectOutput}
|
||||
/>
|
||||
) : (
|
||||
<MediaMuteAndSwitchButton
|
||||
iconsAndLabels="video"
|
||||
enabled={enabled}
|
||||
busy={busy}
|
||||
onMuteClick={toggle}
|
||||
options={options}
|
||||
selectedOption={selected}
|
||||
onSelect={select}
|
||||
backgroundEffects={backgroundEffects}
|
||||
selectedBackgroundEffect={backgroundEffect}
|
||||
onSelectBackgroundEffect={selectBackgroundEffect}
|
||||
backgroundEffectSettling={settling}
|
||||
onAddBackgroundImage={addBackgroundImage}
|
||||
onRemoveBackgroundEffect={removeBackgroundEffect}
|
||||
selfPreview={<SelfPreview track={cameraTrack} />}
|
||||
backgroundImageRefusal={refusalMessage}
|
||||
backgroundEffectNotice={noticeText}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,284 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject, type Observable } from "rxjs";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import {
|
||||
flushPromises,
|
||||
testScope,
|
||||
mockMuteStates,
|
||||
mockMediaDevices,
|
||||
mockVideoTrack,
|
||||
} from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import type { DeviceLabel } from "../state/MediaDevices";
|
||||
import {
|
||||
createAudioMenuViewModel,
|
||||
createCameraMenuViewModel,
|
||||
type MediaMenuViewModel,
|
||||
} from "./MediaMenuViewModel";
|
||||
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
|
||||
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
|
||||
import type * as BackgroundImages from "../tracks/backgroundImages";
|
||||
import {
|
||||
type AddedBackground,
|
||||
addedBackgrounds,
|
||||
maxAddedBackgrounds,
|
||||
UnusableImage,
|
||||
} from "../tracks/backgroundImages";
|
||||
|
||||
const modernRouteMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("@livekit/track-processors", () => ({
|
||||
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
|
||||
}));
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("livekit-client", () => ({
|
||||
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
|
||||
}));
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
add: vi.fn<(file: Blob) => Promise<string>>(),
|
||||
remove: vi.fn<(id: string) => Promise<void>>(),
|
||||
}));
|
||||
vi.mock("../tracks/backgroundImages", async (original) => {
|
||||
const { BehaviorSubject } = await import("rxjs");
|
||||
return {
|
||||
...(await original<typeof BackgroundImages>()),
|
||||
addedBackgrounds: {
|
||||
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
|
||||
add: store.add,
|
||||
remove: store.remove,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const added$ = addedBackgrounds.added$ as BehaviorSubject<
|
||||
AddedBackground[] | undefined
|
||||
>;
|
||||
|
||||
const devices = mockMediaDevices({
|
||||
audioInput: {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic1", { type: "number", number: 1 }],
|
||||
["mic2", { type: "name", name: "Microphone 2" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
// As Safari: no outputs listed.
|
||||
audioOutput: {
|
||||
available$: constant(new Map<string, DeviceLabel>()),
|
||||
selected$: constant(undefined),
|
||||
select: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
function audioMenu(disableSwitcher = false): MediaMenuViewModel {
|
||||
return createAudioMenuViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
devices,
|
||||
constant(disableSwitcher),
|
||||
);
|
||||
}
|
||||
|
||||
function cameraMenu({
|
||||
supported = true,
|
||||
disableSwitcher = false,
|
||||
pipeline$ = constant({ supported, processor: undefined }),
|
||||
camera$ = constant(null),
|
||||
}: {
|
||||
supported?: boolean;
|
||||
disableSwitcher?: boolean;
|
||||
pipeline$?: Observable<BackgroundEffectsState>;
|
||||
camera$?: Observable<LocalVideoMediaTrack | null>;
|
||||
} = {}): MediaMenuViewModel {
|
||||
return createCameraMenuViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
devices,
|
||||
constant(disableSwitcher),
|
||||
pipeline$,
|
||||
camera$,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createAudioMenuViewModel", () => {
|
||||
it("lists the devices, unless the switcher is withheld", () => {
|
||||
expect(audioMenu().options$.value).toEqual([
|
||||
{ id: "mic1", label: { type: "number", number: 1 } },
|
||||
{ id: "mic2", label: { type: "name", name: "Microphone 2" } },
|
||||
]);
|
||||
expect(audioMenu(true).options$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists no outputs as an empty list, which keeps the section", () => {
|
||||
expect(audioMenu().outputOptions$.value).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"offers to pick an output only where the platform can route to it (%s)",
|
||||
(canRoute) => {
|
||||
outputSelectionMock.mockReturnValue(canRoute);
|
||||
expect(audioMenu().selectOutput$.value !== undefined).toBe(canRoute);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("createCameraMenuViewModel", () => {
|
||||
afterEach(() => {
|
||||
backgroundEffectSetting.setValue("none");
|
||||
added$.next([]);
|
||||
});
|
||||
|
||||
it("offers effects where they run, saying where they run slowly", () => {
|
||||
modernRouteMock.mockReturnValue(true);
|
||||
const vm = cameraMenu();
|
||||
expect(vm.selectBackgroundEffect$.value).toBeDefined();
|
||||
expect(vm.addBackgroundImage$.value).toBeDefined();
|
||||
expect(vm.backgroundEffectNotice$.value).toBeUndefined();
|
||||
|
||||
modernRouteMock.mockReturnValue(false);
|
||||
const slow = cameraMenu();
|
||||
expect(slow.selectBackgroundEffect$.value).toBeDefined();
|
||||
expect(slow.backgroundEffectNotice$.value).toBe("slow");
|
||||
modernRouteMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("offers nothing while the switcher is withheld", () => {
|
||||
const vm = cameraMenu({ disableSwitcher: true });
|
||||
expect(vm.options$.value).toEqual([]);
|
||||
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.backgroundEffectNotice$.value).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
|
||||
backgroundEffectSetting.setValue("blur");
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
});
|
||||
const vm = cameraMenu({ pipeline$ });
|
||||
expect(vm.backgroundEffect$.value).toBe("blur");
|
||||
|
||||
pipeline$.next({ supported: false, processor: undefined });
|
||||
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
expect(vm.removeBackgroundEffect$.value).toBeUndefined();
|
||||
expect(vm.backgroundEffectNotice$.value).toBe("unavailable");
|
||||
expect(vm.backgroundEffect$.value).toBe("none");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("blur");
|
||||
});
|
||||
|
||||
it("saves the effect chosen, and none for one not on offer", () => {
|
||||
const vm = cameraMenu();
|
||||
vm.selectBackgroundEffect$.value!("image:arc");
|
||||
expect(vm.backgroundEffect$.value).toBe("image:arc");
|
||||
vm.selectBackgroundEffect$.value!("image:gone");
|
||||
expect(backgroundEffectSetting.getValue()).toBe("none");
|
||||
});
|
||||
|
||||
it("offers every effect in order, the added ones removable", () => {
|
||||
added$.next([{ id: "mine", url: "blob:mine" }]);
|
||||
expect(cameraMenu().backgroundEffects$.value).toEqual([
|
||||
{ id: "none", kind: "none", removable: false },
|
||||
{ id: "blur", kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: `image:${background.id}`,
|
||||
kind: "image",
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
{
|
||||
id: "added:mine",
|
||||
kind: "image",
|
||||
imageUrl: "blob:mine",
|
||||
removable: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes on the wait for the first effect", () => {
|
||||
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
|
||||
supported: true,
|
||||
processor: undefined,
|
||||
wait: "settling",
|
||||
});
|
||||
const vm = cameraMenu({ pipeline$ });
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(true);
|
||||
pipeline$.next({ ...pipeline$.value, wait: undefined });
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(false);
|
||||
});
|
||||
|
||||
it("offers adding only below the limit", () => {
|
||||
const vm = cameraMenu();
|
||||
added$.next(
|
||||
Array.from({ length: maxAddedBackgrounds }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
url: `blob:${i}`,
|
||||
})),
|
||||
);
|
||||
expect(vm.addBackgroundImage$.value).toBeUndefined();
|
||||
added$.next(added$.value!.slice(1));
|
||||
expect(vm.addBackgroundImage$.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("says why each refused file was refused", async () => {
|
||||
const vm = cameraMenu();
|
||||
store.add.mockRejectedValue(new UnusableImage("animated"));
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
|
||||
await flushPromises();
|
||||
const first = vm.backgroundImageRefusal$.value;
|
||||
expect(first).toEqual({ reason: "animated" });
|
||||
|
||||
// The same again is a refusal of its own, so it is shown again.
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "animated" });
|
||||
expect(vm.backgroundImageRefusal$.value).not.toBe(first);
|
||||
|
||||
store.add.mockRejectedValue(new Error("quota"));
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "b.png"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "not-kept" });
|
||||
|
||||
store.add.mockResolvedValue("kept");
|
||||
vm.addBackgroundImage$.value!(new File(["x"], "c.png"));
|
||||
await flushPromises();
|
||||
expect(vm.backgroundImageRefusal$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never removes the selected background", () => {
|
||||
store.remove.mockResolvedValue();
|
||||
const vm = cameraMenu();
|
||||
backgroundEffectSetting.setValue("added:worn");
|
||||
vm.removeBackgroundEffect$.value!("added:worn");
|
||||
expect(store.remove).not.toHaveBeenCalled();
|
||||
vm.removeBackgroundEffect$.value!("added:spare");
|
||||
expect(store.remove).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("previews the camera only while there is one and it is not muted", () => {
|
||||
const muted$ = new BehaviorSubject(false);
|
||||
const track = mockVideoTrack({ muted$ });
|
||||
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
|
||||
const vm = cameraMenu({ camera$ });
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
muted$.next(true);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
muted$.next(false);
|
||||
expect(vm.cameraTrack$.value).toBe(track);
|
||||
camera$.next(null);
|
||||
expect(vm.cameraTrack$.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,316 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
|
||||
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
parseEffect,
|
||||
serializeEffect,
|
||||
shippedBackgrounds,
|
||||
} from "../tracks/backgroundEffectCatalog";
|
||||
import {
|
||||
type AddedBackground,
|
||||
addedBackgrounds,
|
||||
maxAddedBackgrounds,
|
||||
type UnusableReason,
|
||||
UnusableImage,
|
||||
} from "../tracks/backgroundImages";
|
||||
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
|
||||
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import type { ObservableScope } from "../state/ObservableScope";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
||||
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
|
||||
|
||||
export interface BackgroundImageRefusal {
|
||||
reason: UnusableReason | "not-kept";
|
||||
}
|
||||
|
||||
/**
|
||||
* A mute button with a menu to pick the device, shared by the microphone and
|
||||
* the camera. Fields that belong to the other device are left empty.
|
||||
*/
|
||||
export interface MediaMenuSnapshot {
|
||||
enabled: boolean;
|
||||
busy: boolean;
|
||||
/** Also controls if the button is disabled */
|
||||
toggle: (() => void) | undefined;
|
||||
/** No options leave out the menu, which is then a plain mute button. */
|
||||
options: MenuOptions[];
|
||||
selected: string | undefined;
|
||||
select: ((id: string) => void) | undefined;
|
||||
|
||||
/** Output (speaker) devices. Microphone only. */
|
||||
outputOptions: MenuOptions[];
|
||||
selectedOutput: string | undefined;
|
||||
/** Undefined where the platform can't route audio to a chosen device. */
|
||||
selectOutput: ((id: string) => void) | undefined;
|
||||
|
||||
/** The selected background effect, as its option id. Camera only. */
|
||||
backgroundEffect: string | undefined;
|
||||
/** Every effect on offer, in the order they are shown. */
|
||||
backgroundEffects: BackgroundEffectGridItem[];
|
||||
/** What the user is told about effects here, if anything. */
|
||||
backgroundEffectNotice: "unavailable" | "slow" | undefined;
|
||||
/** Whether the first effect chosen is still being prepared. */
|
||||
backgroundEffectSettling: boolean;
|
||||
/** Why the last file offered couldn't be kept, if it couldn't. */
|
||||
backgroundImageRefusal: BackgroundImageRefusal | undefined;
|
||||
/** Undefined where background effects can't be chosen. */
|
||||
selectBackgroundEffect: ((id: string) => void) | undefined;
|
||||
/** Undefined where no background can be added. */
|
||||
addBackgroundImage: ((file: File) => void) | undefined;
|
||||
/** Removes an added background by its option id, unless it is the selected one. */
|
||||
removeBackgroundEffect: ((id: string) => void) | undefined;
|
||||
/** The camera to preview, with the selected effect; null while it is off. */
|
||||
cameraTrack: LocalVideoMediaTrack | null;
|
||||
}
|
||||
|
||||
export type MediaMenuViewModel = ViewModel<MediaMenuSnapshot>;
|
||||
|
||||
const noBackgroundEffects = {
|
||||
backgroundEffect: undefined,
|
||||
backgroundEffects: [],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
backgroundImageRefusal: undefined,
|
||||
selectBackgroundEffect: undefined,
|
||||
addBackgroundImage: undefined,
|
||||
removeBackgroundEffect: undefined,
|
||||
cameraTrack: null,
|
||||
} satisfies Partial<MediaMenuSnapshot>;
|
||||
|
||||
function options$(
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
): Observable<MenuOptions[]> {
|
||||
return disableSwitcher$.pipe(
|
||||
switchMap((disable) =>
|
||||
disable
|
||||
? constant([] as MenuOptions[])
|
||||
: available$.pipe(
|
||||
map((available) =>
|
||||
[...available.entries()].map(([id, label]) => ({ id, label })),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The microphone's mute button and menu, with the speaker choice.
|
||||
*
|
||||
* @param disableSwitcher$ - True to leave out the menu.
|
||||
*/
|
||||
export function createAudioMenuViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
): MediaMenuViewModel {
|
||||
const { audio } = muteStates;
|
||||
return {
|
||||
...createStaticViewModel(noBackgroundEffects),
|
||||
enabled$: audio.enabled$,
|
||||
busy$: audio.syncing$,
|
||||
toggle$: scope.behavior(audio.toggle$.pipe(map((t) => t ?? undefined))),
|
||||
options$: scope.behavior(
|
||||
options$(disableSwitcher$, mediaDevices.audioInput.available$),
|
||||
),
|
||||
selected$: scope.behavior(
|
||||
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
select$: constant(mediaDevices.audioInput.select),
|
||||
outputOptions$: scope.behavior(
|
||||
options$(disableSwitcher$, mediaDevices.audioOutput.available$),
|
||||
),
|
||||
selectedOutput$: scope.behavior(
|
||||
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
// Withheld where the platform can't route audio to a chosen device, which
|
||||
// disables the speaker section.
|
||||
selectOutput$: constant(
|
||||
supportsAudioOutputSelection()
|
||||
? mediaDevices.audioOutput.select
|
||||
: undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera's mute button and menu, with the background effects and a
|
||||
* preview of the camera.
|
||||
*
|
||||
* @param disableSwitcher$ - True to leave out the menu.
|
||||
* @param backgroundEffectsState$ - The background pipeline's state: whether
|
||||
* effects run here, and the wait for the first.
|
||||
* @param camera$ - The camera as the pipeline has it, for the preview.
|
||||
*/
|
||||
export function createCameraMenuViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
backgroundEffectsState$: Observable<BackgroundEffectsState>,
|
||||
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
|
||||
): MediaMenuViewModel {
|
||||
const { video } = muteStates;
|
||||
// Also false once the pipeline has failed to build this session.
|
||||
const supported$ = backgroundEffectsState$.pipe(
|
||||
map(({ supported }) => supported === true),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const slow = !supportsModernBackgroundProcessors();
|
||||
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
|
||||
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
|
||||
);
|
||||
// A new object for each refusal, so the same one twice is shown twice.
|
||||
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
|
||||
undefined,
|
||||
);
|
||||
return {
|
||||
...createStaticViewModel({
|
||||
outputOptions: [],
|
||||
selectedOutput: undefined,
|
||||
selectOutput: undefined,
|
||||
}),
|
||||
enabled$: video.enabled$,
|
||||
busy$: video.syncing$,
|
||||
toggle$: scope.behavior(video.toggle$.pipe(map((t) => t ?? undefined))),
|
||||
options$: scope.behavior(
|
||||
options$(disableSwitcher$, mediaDevices.videoInput.available$),
|
||||
),
|
||||
selected$: scope.behavior(
|
||||
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
|
||||
),
|
||||
select$: constant(mediaDevices.videoInput.select),
|
||||
backgroundEffect$: scope.behavior(
|
||||
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
|
||||
map(([raw, supported]) =>
|
||||
supported ? serializeEffect(parseEffect(raw)) : "none",
|
||||
),
|
||||
),
|
||||
),
|
||||
selectBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (id: string): void =>
|
||||
backgroundEffectSetting.setValue(
|
||||
serializeEffect(parseEffect(id)),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffects$: scope.behavior(
|
||||
addedBackgrounds.added$.pipe(
|
||||
map((added) => backgroundEffectChoices(added ?? [])),
|
||||
),
|
||||
),
|
||||
backgroundEffectNotice$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
!offered ? "unavailable" : slow ? "slow" : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffectSettling$: scope.behavior(
|
||||
backgroundEffectsState$.pipe(
|
||||
map(({ wait }) => wait !== undefined),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
false,
|
||||
),
|
||||
// Kept and offered, not put on: that waits for the user to choose it.
|
||||
addBackgroundImage$: scope.behavior(
|
||||
combineLatest([offered$, addedBackgrounds.added$]).pipe(
|
||||
map(([offered, added]) =>
|
||||
offered && (added?.length ?? 0) < maxAddedBackgrounds
|
||||
? (file: File): void => {
|
||||
refusal$.next(undefined);
|
||||
addedBackgrounds.add(file).catch((e) => {
|
||||
logger.warn("Could not keep that background", e);
|
||||
refusal$.next({
|
||||
reason: e instanceof UnusableImage ? e.reason : "not-kept",
|
||||
});
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundImageRefusal$: refusal$,
|
||||
// None while muted: the call mutes its track rather than releasing it.
|
||||
cameraTrack$: scope.behavior(
|
||||
camera$.pipe(
|
||||
switchMap((camera) =>
|
||||
camera
|
||||
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
|
||||
: of(null),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
removeBackgroundEffect$: scope.behavior(
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
offered
|
||||
? (optionId: string): void => {
|
||||
const effect = parseEffect(optionId);
|
||||
if (effect.kind !== "added") return;
|
||||
// The selected effect can't be removed: that would leave no
|
||||
// effect selected.
|
||||
if (backgroundEffectSetting.getValue() === optionId) return;
|
||||
addedBackgrounds
|
||||
.remove(effect.id)
|
||||
.catch((e) =>
|
||||
logger.warn("Could not remove that background", e),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundEffectChoices(
|
||||
added: AddedBackground[],
|
||||
): BackgroundEffectGridItem[] {
|
||||
return [
|
||||
{ id: serializeEffect({ kind: "none" }), kind: "none", removable: false },
|
||||
{ id: serializeEffect({ kind: "blur" }), kind: "blur", removable: false },
|
||||
...shippedBackgrounds.map((background) => ({
|
||||
id: serializeEffect({ kind: "shipped", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.imagePath,
|
||||
removable: false,
|
||||
})),
|
||||
...added.map((background) => ({
|
||||
id: serializeEffect({ kind: "added", id: background.id }),
|
||||
kind: "image" as const,
|
||||
imageUrl: background.url,
|
||||
removable: true,
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -440,7 +440,7 @@ function swatch(colour: string): string {
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** One's own backgrounds are removable, unless one is the selected one. */
|
||||
/** One's own backgrounds are removable, never the one in force. */
|
||||
export const AddedBackgroundsCanBeRemoved: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
|
||||
@@ -85,7 +85,7 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
backgroundEffectSettling?: boolean;
|
||||
/** Called with the file chosen from the add tile. Omit to leave it out. */
|
||||
onAddBackgroundImage?: (file: File) => void;
|
||||
/** Removes a removable effect, unless it is the selected one. */
|
||||
/** Removes a removable effect, never the one in force. */
|
||||
onRemoveBackgroundEffect?: (id: string) => void;
|
||||
/** Why the last file offered couldn't be used; a new object each time. */
|
||||
backgroundImageRefusal?: { text: string };
|
||||
|
||||
@@ -28,10 +28,8 @@ describe("SelfPreview", () => {
|
||||
expect(track.detach).toHaveBeenCalledExactlyOnceWith(a);
|
||||
});
|
||||
|
||||
it("mirrors the camera unless it faces away", () => {
|
||||
const facingMode$ = new BehaviorSubject<"user" | "environment" | undefined>(
|
||||
"user",
|
||||
);
|
||||
it("mirrors the camera while it faces the user, and only then", () => {
|
||||
const facingMode$ = new BehaviorSubject<"user" | "environment">("user");
|
||||
const { container } = render(
|
||||
<SelfPreview track={mockVideoTrack({ facingMode$ })} />,
|
||||
);
|
||||
@@ -39,9 +37,6 @@ describe("SelfPreview", () => {
|
||||
expect(video().classList.contains(styles.mirrored)).toBe(true);
|
||||
act(() => facingMode$.next("environment"));
|
||||
expect(video().classList.contains(styles.mirrored)).toBe(false);
|
||||
// As the built-in camera of a Mac, which doesn't say
|
||||
act(() => facingMode$.next(undefined));
|
||||
expect(video().classList.contains(styles.mirrored)).toBe(true);
|
||||
});
|
||||
|
||||
it("says the camera is off while there is none", () => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { VideoCallOffIcon } from "@vector-im/compound-design-tokens/assets/web/i
|
||||
|
||||
import styles from "./SelfPreview.module.css";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { mirrors } from "../utils/media";
|
||||
|
||||
const noFacingMode = constant(undefined);
|
||||
|
||||
@@ -43,7 +42,7 @@ export const SelfPreview: FC<Props> = ({ track }) => {
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
className={mirrors(facingMode) ? styles.mirrored : undefined}
|
||||
className={facingMode === "user" ? styles.mirrored : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -108,6 +108,22 @@ describe("Config.initWith", () => {
|
||||
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
|
||||
});
|
||||
|
||||
it("falls back to the defaults when config.json is not JSON", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("<!doctype html><title>Element</title>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
);
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
Reflect.set(Config, "internalInstance", undefined);
|
||||
|
||||
await expect(Config.init()).resolves.toBeUndefined();
|
||||
|
||||
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces a config initialized earlier", () => {
|
||||
Config.initWith({ ssla: "https://first.invalid/ssla" });
|
||||
Config.initWith({ ssla: "https://second.invalid/ssla" });
|
||||
|
||||
@@ -156,5 +156,10 @@ async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
|
||||
return {};
|
||||
}
|
||||
|
||||
return response.json();
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
logger.warn(`Config at ${fetchTarget} is not JSON, using defaults`, e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -190,12 +190,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.matrixRoom,
|
||||
mediaDevices,
|
||||
props.muteStates,
|
||||
props.e2eeSystem,
|
||||
scope.behavior(observeElementSize$(rootElement)),
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
capture,
|
||||
backgroundEffectsState$,
|
||||
hostBridge,
|
||||
@@ -205,7 +202,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
scope,
|
||||
props.rtcSession,
|
||||
),
|
||||
windowSize$: scope.behavior(observeElementSize$(rootElement)),
|
||||
},
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
);
|
||||
setVm(vm);
|
||||
|
||||
@@ -822,9 +822,7 @@ export function rtcParticipationOptions(
|
||||
const session = config.matrix_rtc_session;
|
||||
return {
|
||||
sendNotificationType: urlParams.sendNotificationType,
|
||||
applicationData: urlParams.callIntent
|
||||
? { "m.call.intent": urlParams.callIntent }
|
||||
: undefined,
|
||||
callIntent: urlParams.callIntent,
|
||||
timings: {
|
||||
syncDisconnectGracePeriodMs: config.sync_disconnect_grace_period_ms,
|
||||
networkErrorRetryMs: session.network_error_retry_ms,
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -7,12 +7,9 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, describe, it, beforeAll } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { constant } from "@element-hq/matrixrtc-sdk";
|
||||
|
||||
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { mockVideoTrack } from "../utils/test";
|
||||
import styles from "./VideoPreview.module.css";
|
||||
|
||||
describe("VideoPreview", () => {
|
||||
const matrixInfo: MatrixInfo = {
|
||||
@@ -63,18 +60,4 @@ describe("VideoPreview", () => {
|
||||
);
|
||||
expect(queryByRole("status")).toHaveTextContent("Video loading...");
|
||||
});
|
||||
|
||||
it("mirrors a camera that doesn't say which way it faces", () => {
|
||||
const { container } = render(
|
||||
<VideoPreview
|
||||
matrixInfo={matrixInfo}
|
||||
videoEnabled
|
||||
videoTrack={mockVideoTrack({ facingMode$: constant(undefined) })}
|
||||
children={<></>}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector("video")!.classList.contains(styles.mirror),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ import styles from "./VideoPreview.module.css";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import videoPlaceholder from "../graphics/video-placeholder.gif";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { mirrors } from "../utils/media";
|
||||
|
||||
const noFacingMode = constant(undefined);
|
||||
|
||||
@@ -65,7 +64,7 @@ export const VideoPreview: FC<Props> = ({
|
||||
return (
|
||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||
<video
|
||||
className={mirrors(facingMode) ? styles.mirror : undefined}
|
||||
className={facingMode === "user" ? styles.mirror : undefined}
|
||||
ref={videoEl}
|
||||
muted
|
||||
playsInline
|
||||
|
||||
@@ -47,7 +47,6 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
class="_preview_dd2178"
|
||||
>
|
||||
<video
|
||||
class="_mirror_dd2178"
|
||||
disablepictureinpicture=""
|
||||
playsinline=""
|
||||
poster="/src/graphics/video-placeholder.gif"
|
||||
@@ -280,7 +279,6 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
class="_preview_dd2178"
|
||||
>
|
||||
<video
|
||||
class="_mirror_dd2178"
|
||||
disablepictureinpicture=""
|
||||
playsinline=""
|
||||
poster="/src/graphics/video-placeholder.gif"
|
||||
@@ -560,7 +558,6 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
class="_preview_dd2178"
|
||||
>
|
||||
<video
|
||||
class="_mirror_dd2178"
|
||||
disablepictureinpicture=""
|
||||
playsinline=""
|
||||
poster="/src/graphics/video-placeholder.gif"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { type MatrixClient } from "matrix-js-sdk/lib/client";
|
||||
|
||||
import { useSubmitRageshake, getRageshakeSubmitUrl } from "./submit-rageshake";
|
||||
import { ClientContextProvider } from "../ClientContext";
|
||||
import { hostClientSession } from "../state/ClientSession";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { mockConfig } from "../utils/test";
|
||||
import { DEFAULT_CONFIG } from "../config/ConfigOptions";
|
||||
@@ -74,22 +75,7 @@ function renderWithMockClient(
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
render(
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
state: "valid",
|
||||
disconnected: false,
|
||||
supportedFeatures: {
|
||||
reactions: true,
|
||||
},
|
||||
setClient: vi.fn(),
|
||||
authenticated: {
|
||||
client,
|
||||
isPasswordlessUser: true,
|
||||
changePassword: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ClientContextProvider value={hostClientSession(client)}>
|
||||
<TestComponent
|
||||
sendLogs={sendLogs}
|
||||
getRageshakeSubmitUrl={getRageshakeSubmitUrl}
|
||||
|
||||
+7
-26
@@ -34,23 +34,6 @@ describe("prefetchSounds", () => {
|
||||
expect(bytesOf(sounds.beep)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("decodes base64 data: URLs without fetching them", async () => {
|
||||
// "OggS" — the bytes 0x4f 0x67 0x67 0x53
|
||||
const sounds = await prefetchSounds({
|
||||
beep: { ogg: "data:audio/ogg;base64,T2dnUw==" },
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(bytesOf(sounds.beep)).toEqual([0x4f, 0x67, 0x67, 0x53]);
|
||||
});
|
||||
|
||||
it("leaves out a sound with a percent-encoded data: URL", async () => {
|
||||
const sounds = await prefetchSounds({
|
||||
beep: { ogg: "data:audio/ogg,Ogg%53" },
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(sounds).toEqual({});
|
||||
});
|
||||
|
||||
it("leaves out a sound whose response is not okay", async () => {
|
||||
fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
|
||||
const sounds = await prefetchSounds({
|
||||
@@ -60,18 +43,16 @@ describe("prefetchSounds", () => {
|
||||
});
|
||||
|
||||
it("leaves out a sound that fails to load, keeping the others", async () => {
|
||||
fetchSpy.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
// Fetched in the order given
|
||||
fetchSpy
|
||||
.mockRejectedValueOnce(new TypeError("Failed to fetch"))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
|
||||
);
|
||||
const sounds = await prefetchSounds({
|
||||
beep: { ogg: "https://example.org/beep.ogg" },
|
||||
boop: { ogg: "data:audio/ogg;base64,T2dnUw==" },
|
||||
boop: { ogg: "https://example.org/boop.ogg" },
|
||||
});
|
||||
expect(Object.keys(sounds)).toEqual(["boop"]);
|
||||
});
|
||||
|
||||
it("leaves out a sound with a malformed data: URL", async () => {
|
||||
const sounds = await prefetchSounds({
|
||||
beep: { ogg: "data:audio/ogg;base64" },
|
||||
});
|
||||
expect(sounds).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-27
@@ -32,38 +32,12 @@ function getPreferredAudioFormat(): "ogg" | "mp3" {
|
||||
|
||||
const preferredFormat = getPreferredAudioFormat();
|
||||
|
||||
/**
|
||||
* Decode a `data:` URL into the bytes it carries.
|
||||
*
|
||||
* The component build inlines the sound files as `data:` URLs. Those could be
|
||||
* `fetch`ed like any other URL, but that fetch is subject to the host page's
|
||||
* Content Security Policy, and `connect-src` lists rarely allow `data:` (a `*`
|
||||
* source does not cover it). Reading the bytes out of the URL directly needs no
|
||||
* network access, so no policy can stop it.
|
||||
* @param url The `data:` URL to decode.
|
||||
* @returns The decoded bytes.
|
||||
*/
|
||||
function decodeDataUrl(url: string): ArrayBuffer {
|
||||
const separator = url.indexOf(",");
|
||||
if (!url.startsWith("data:") || separator === -1)
|
||||
throw new Error("Not a valid data: URL");
|
||||
const header = url.slice("data:".length, separator);
|
||||
// Vite only percent-encodes SVGs; every other inlined asset is base64.
|
||||
if (!header.split(";").includes("base64"))
|
||||
throw new Error("Only base64 data: URLs are supported");
|
||||
// `decoded` is a binary string: one char per byte
|
||||
const decoded = atob(url.slice(separator + 1));
|
||||
const bytes = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the bytes of a single sound file.
|
||||
* @param url Where the sound is: an ordinary URL to fetch, or a `data:` URL.
|
||||
* @param url Where the sound is.
|
||||
* @returns The bytes, or null if the sound could not be loaded.
|
||||
*/
|
||||
async function loadSound(url: string): Promise<ArrayBuffer | null> {
|
||||
if (url.startsWith("data:")) return decodeDataUrl(url);
|
||||
const response = await fetch(url);
|
||||
if (isFailure(response)) return null;
|
||||
return await response.arrayBuffer();
|
||||
|
||||
@@ -7,7 +7,11 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { it, vi, expect, beforeEach, afterEach, describe } from "vitest";
|
||||
import { firstValueFrom, of, Subject, take, toArray } from "rxjs";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts";
|
||||
import type { Controls, OutputDevice } from "../controls";
|
||||
@@ -117,7 +121,7 @@ describe("Default selection", () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BASE_DEVICE_LIST),
|
||||
testScope,
|
||||
"video",
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -142,7 +146,7 @@ describe("Default selection", () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BASE_DEVICE_LIST),
|
||||
testScope,
|
||||
"audio",
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -163,7 +167,7 @@ describe("Default selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
["audio", "video"].forEach((callIntent) => {
|
||||
[RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => {
|
||||
it(`Default to BT headset for ${callIntent} calls if present`, async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(BT_HEADSET_BASE_DEVICE_LIST),
|
||||
@@ -190,7 +194,7 @@ describe("Default selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
["audio", "video"].forEach((callIntent) => {
|
||||
[RTC_CALL_INTENT_AUDIO, RTC_CALL_INTENT_VIDEO].forEach((callIntent) => {
|
||||
it(`Default to wired headset for ${callIntent} calls if present`, async () => {
|
||||
const controlledAudioOutput = new AndroidControlledAudioOutput(
|
||||
of(WIRED_HEADSET_BASE_DEVICE_LIST),
|
||||
@@ -300,7 +304,7 @@ describe("Test select a device", () => {
|
||||
b: BT_HEADSET_BASE_DEVICE_LIST,
|
||||
}),
|
||||
testScope,
|
||||
"audio",
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -340,7 +344,7 @@ describe("Test select a device", () => {
|
||||
c: BASE_DEVICE_LIST,
|
||||
}),
|
||||
testScope,
|
||||
"audio",
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
mockControls,
|
||||
);
|
||||
|
||||
@@ -393,7 +397,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("When a BT headset is added, control should switch to use it", () => {
|
||||
createAudioControlledOutput("video");
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
|
||||
|
||||
// Emit the base device list, the speaker should be selected
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
@@ -419,7 +423,9 @@ describe("Available device changes", () => {
|
||||
|
||||
// Android does not set `isExternalHeadset` to true for wired headphones, so we can't test this case.'
|
||||
it.skip("When a wired headset is added, control should switch to use it", async () => {
|
||||
const controlledAudioOutput = createAudioControlledOutput("video");
|
||||
const controlledAudioOutput = createAudioControlledOutput(
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
);
|
||||
|
||||
// Emit the base device list, the speaker should be selected
|
||||
availableSource$.next(BASE_DEVICE_LIST);
|
||||
@@ -446,7 +452,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("When the active bt headset is removed on audio call, control should switch to earpiece", () => {
|
||||
createAudioControlledOutput("audio");
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_AUDIO);
|
||||
|
||||
// Emit the BT headset device list, the BT headset should be selected
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
@@ -472,7 +478,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("When the active bt headset is removed on video call, control should switch to speaker", () => {
|
||||
createAudioControlledOutput("video");
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
|
||||
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
|
||||
@@ -497,7 +503,7 @@ describe("Available device changes", () => {
|
||||
});
|
||||
|
||||
it("Do not repeatidly set the same device", () => {
|
||||
createAudioControlledOutput("video");
|
||||
createAudioControlledOutput(RTC_CALL_INTENT_VIDEO);
|
||||
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST);
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
type SelectedAudioOutputDevice,
|
||||
} from "./MediaDevices.ts";
|
||||
import type { ObservableScope } from "./ObservableScope.ts";
|
||||
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type Controls, type OutputDevice } from "../controls.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
|
||||
@@ -102,7 +105,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
|
||||
* @constructor
|
||||
* @param controlledDevices$ - The list of available output devices coming from the hosting application, ordered by preference order (most preferred first).
|
||||
* @param scope - The ObservableScope to create the Behaviors in.
|
||||
* @param initialIntent - The initial call intent (e.g. "audio" or "video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls).
|
||||
* @param initialIntent - The initial call intent (e.g. "m.audio" or "m.video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls).
|
||||
* @param controls - The controls provided by the hosting application to control the audio routing and notify of user actions.
|
||||
*/
|
||||
public constructor(
|
||||
@@ -316,7 +319,7 @@ export class AndroidControlledAudioOutput implements MediaDevice<
|
||||
this.logger.debug(
|
||||
`Android routing logic intent: ${this.initialIntent} finding best default...`,
|
||||
);
|
||||
if (this.initialIntent === "audio") {
|
||||
if (this.initialIntent === RTC_CALL_INTENT_AUDIO) {
|
||||
const systemProposed = available[0];
|
||||
// If no headset is connected, android will route to the speaker by default,
|
||||
// but for a voice call we want to route to the earpiece instead,
|
||||
|
||||
@@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ClientManager, type ClientSession } from "./ClientSession";
|
||||
import { type AudioOutputOptions, MediaDevices } from "./MediaDevices";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
import { createAppBackgroundEffects } from "../tracks/BackgroundEffectsContext";
|
||||
@@ -22,8 +23,14 @@ export class AppViewModel {
|
||||
// TODO: Move more application logic here. The CallViewModel, at the very
|
||||
// least, ought to be accessible from this object.
|
||||
|
||||
/**
|
||||
* @param clientSession Where the client comes from. SPA js-sdk, EW host, widget
|
||||
*/
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
private readonly audioOutputOptions: AudioOutputOptions,
|
||||
public readonly clientSession: ClientSession,
|
||||
/** Null where the session is a host's and not ours to manage. */
|
||||
public readonly clientManager: ClientManager | null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,7 @@ export interface BackgroundEffectsOptions {
|
||||
supported: boolean;
|
||||
/** The effect chosen, as the setting stores it. */
|
||||
effect$: Behavior<string>;
|
||||
/** Saves the chosen effect. Used to reset the choice to "none" when the
|
||||
* chosen effect can't run here. */
|
||||
/** Stores a choice, to forget one that can't be honoured. */
|
||||
setEffect: (id: EffectId) => void;
|
||||
/** The backgrounds the device keeps, undefined until it has said. */
|
||||
added$: Behavior<AddedBackground[] | undefined>;
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
MatrixEvent,
|
||||
type Room,
|
||||
} from "matrix-js-sdk";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
RTC_CALL_INTENT_VIDEO,
|
||||
type RTCCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
|
||||
|
||||
import { withTestScheduler } from "../../utils/test";
|
||||
@@ -33,10 +37,12 @@ function mockRingEvent(
|
||||
eventId: string,
|
||||
lifetimeMs: number | undefined,
|
||||
sender = local.userId,
|
||||
legacyIntent?: string,
|
||||
): CallNotificationWrapper {
|
||||
return {
|
||||
event_id: eventId,
|
||||
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
|
||||
...(legacyIntent === undefined ? {} : { "m.call.intent": legacyIntent }),
|
||||
notification_type: "ring",
|
||||
sender,
|
||||
} as unknown as CallNotificationWrapper;
|
||||
@@ -128,7 +134,25 @@ test("ring attempt times out after nobody joins", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("ring attempt maps the legacy intent on the notification to its stable value", () => {
|
||||
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
...defaultProps,
|
||||
memberUserIds$: constant<string[]>([]),
|
||||
sentCallNotification$: hot("-a", {
|
||||
a: mockRingEvent("$notif1", 30, local.userId, "video"),
|
||||
}),
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: RTC_CALL_INTENT_VIDEO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
@@ -148,7 +172,7 @@ test("ring attempt is accepted once recipient joins", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "accept" },
|
||||
});
|
||||
});
|
||||
@@ -166,7 +190,7 @@ test("ring attempt is immediately accepted if recipient is already joined", () =
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "accept" },
|
||||
});
|
||||
});
|
||||
@@ -201,7 +225,7 @@ test("ring attempt can be declined", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "decline" },
|
||||
});
|
||||
});
|
||||
@@ -236,7 +260,7 @@ test("ring attempt times out if recipient declines too late", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
@@ -271,7 +295,7 @@ test("decline event relating to wrong event is ignored (times out)", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
@@ -306,7 +330,7 @@ test("decline event from wrong sender is ignored (times out)", () => {
|
||||
});
|
||||
|
||||
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||
a: { intent: "audio", recipient: alice.userId },
|
||||
a: { intent: RTC_CALL_INTENT_AUDIO, recipient: alice.userId },
|
||||
A: { outcome: "timeout" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
type IRTCNotificationContent,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
type RTCCallIntent,
|
||||
toStableCallIntent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
filter,
|
||||
@@ -155,7 +157,10 @@ export function createCallNotificationLifecycle$({
|
||||
);
|
||||
|
||||
return of({
|
||||
intent: notificationEvent["m.call.intent"] ?? "audio",
|
||||
// The notification still carries the legacy intent values
|
||||
intent:
|
||||
toStableCallIntent(notificationEvent["m.call.intent"]) ??
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
recipient,
|
||||
outcome$: race(timeout$, accept$, decline$).pipe(
|
||||
take(1),
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
MatrixRTCSessionEvent,
|
||||
RTC_CALL_INTENT_AUDIO,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
mockRtcMembership,
|
||||
testScope,
|
||||
} from "../../utils/test.ts";
|
||||
import { E2eeType } from "../../e2ee/e2eeType.ts";
|
||||
import {
|
||||
alice,
|
||||
aliceId,
|
||||
@@ -1464,6 +1466,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1481,6 +1484,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1498,6 +1502,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1527,6 +1532,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1554,7 +1560,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
b: expect.objectContaining({
|
||||
type: "ringing",
|
||||
userId: alice.userId,
|
||||
intent: "audio",
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
}),
|
||||
});
|
||||
// Layout should show placeholder media for the participant we're
|
||||
@@ -1600,7 +1606,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
b: expect.objectContaining({
|
||||
type: "ringing",
|
||||
userId: alice.userId,
|
||||
intent: "audio",
|
||||
intent: RTC_CALL_INTENT_AUDIO,
|
||||
}),
|
||||
});
|
||||
// Layout should show placeholder media for the participant we're
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user