mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-19 23:52:26 +00:00
Compare commits
232
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb7fe07b7 | ||
|
|
873dfdbfbd | ||
|
|
e68d1d1b5f | ||
|
|
b7efa02adc | ||
|
|
c807b8ef7d | ||
|
|
6b5f396e1e | ||
|
|
182be8676d | ||
|
|
f6f47ede62 | ||
|
|
eb117249d1 | ||
|
|
f28d9e9b06 | ||
|
|
bc6c232ef0 | ||
|
|
8e78ac76bd | ||
|
|
d8d70812b8 | ||
|
|
6e44332fd1 | ||
|
|
bc6015eff2 | ||
|
|
0dca5835f6 | ||
|
|
0a8c24bca8 | ||
|
|
7485b3d71f | ||
|
|
efdee7dcdb | ||
|
|
d7d3dcde58 | ||
|
|
9330b907fc | ||
|
|
256ff7a0b6 | ||
|
|
6ddcc15206 | ||
|
|
a56e934841 | ||
|
|
efde51b8c1 | ||
|
|
2a7c6c3c06 | ||
|
|
5bab17b668 | ||
|
|
d7d28a1b55 | ||
|
|
790bd1e2bf | ||
|
|
17231e545c | ||
|
|
17d9d19c91 | ||
|
|
3d37abf490 | ||
|
|
92f504d886 | ||
|
|
b0d098e065 | ||
|
|
a526a8ae19 | ||
|
|
1146d3820a | ||
|
|
a4dda428f5 | ||
|
|
5982586f24 | ||
|
|
e6b4aae714 | ||
|
|
ede29bdf1b | ||
|
|
b5b2bd2193 | ||
|
|
21ca382306 | ||
|
|
25e379546e | ||
|
|
0443ef55d1 | ||
|
|
465c413691 | ||
|
|
06b9105cee | ||
|
|
2b5979162a | ||
|
|
5399f14012 | ||
|
|
f2ffb06f6c | ||
|
|
6cf0e1df51 | ||
|
|
bfa20ef747 | ||
|
|
5955d22c1b | ||
|
|
ce4426858b | ||
|
|
4ec75ecda4 | ||
|
|
b51a33c4e1 | ||
|
|
c3837464a3 | ||
|
|
e7372d4c03 | ||
|
|
69eddd7846 | ||
|
|
de3e4c6a78 | ||
|
|
3abc5a0435 | ||
|
|
8e20d48b1d | ||
|
|
ff57c217aa | ||
|
|
b60e7452b7 | ||
|
|
1c03ad335c | ||
|
|
87873b726f | ||
|
|
3448a3a2ef | ||
|
|
c625fb8845 | ||
|
|
cab9698fc7 | ||
|
|
7410bd8a82 | ||
|
|
f17670b731 | ||
|
|
33a0653ea5 | ||
|
|
b1c29e9a41 | ||
|
|
ad92892063 | ||
|
|
1a827e0930 | ||
|
|
a1b37bb3c9 | ||
|
|
061d25a54c | ||
|
|
b554fa63d1 | ||
|
|
b5bcb9aeed | ||
|
|
3d3e6dcdcc | ||
|
|
9999a755dd | ||
|
|
43ce68e80a | ||
|
|
6d5f698357 | ||
|
|
64a5b2ee5c | ||
|
|
9857ed53e3 | ||
|
|
0aa9d94826 | ||
|
|
f2622f0032 | ||
|
|
c0d5f2d470 | ||
|
|
4a5141406e | ||
|
|
b036d1c0c0 | ||
|
|
0cfe6cb03d | ||
|
|
8be8cf08bf | ||
|
|
9a772131b6 | ||
|
|
1cdd5d8db0 | ||
|
|
fc9e13c922 | ||
|
|
580897730a | ||
|
|
6f7dac31d6 | ||
|
|
78e4881c37 | ||
|
|
1bb342e9e1 | ||
|
|
7ca68fb5bd | ||
|
|
72e7e0f406 | ||
|
|
94a1d87813 | ||
|
|
fee54a09c3 | ||
|
|
72a327f12f | ||
|
|
7f8d8027dc | ||
|
|
cbc12b0365 | ||
|
|
414ae266ab | ||
|
|
ecca5e1e13 | ||
|
|
1d12e10628 | ||
|
|
89cabf9fa2 | ||
|
|
7ca8059b0f | ||
|
|
29ea90f800 | ||
|
|
426ea30fe1 | ||
|
|
7d6a0da6a9 | ||
|
|
ea31f073a7 | ||
|
|
b0d08cb0b5 | ||
|
|
88e5a5badb | ||
|
|
296c735500 | ||
|
|
341c508a42 | ||
|
|
78e585c5f4 | ||
|
|
81da4c9093 | ||
|
|
064a88b9c7 | ||
|
|
14fd8c179b | ||
|
|
4a146ebada | ||
|
|
a6a02778a1 | ||
|
|
662259154a | ||
|
|
978ffee183 | ||
|
|
3ace4ab5a3 | ||
|
|
2d64cf4b30 | ||
|
|
87aee1c02c | ||
|
|
f7cf674152 | ||
|
|
9d5ca297e3 | ||
|
|
c90d1d8dda | ||
|
|
dd59519a85 | ||
|
|
33fc8997f8 | ||
|
|
8aead74ab9 | ||
|
|
1e128c4da0 | ||
|
|
eb65a28a75 | ||
|
|
9d400323d0 | ||
|
|
6d7d3093b3 | ||
|
|
2b07e9e822 | ||
|
|
7ae1fc6cd8 | ||
|
|
e1df71817a | ||
|
|
608695fc95 | ||
|
|
a08a577e18 | ||
|
|
55b2d2b130 | ||
|
|
5b84b2c04d | ||
|
|
3af0744324 | ||
|
|
07567ed31d | ||
|
|
d6e097476e | ||
|
|
540eb306f4 | ||
|
|
f62428ba0d | ||
|
|
d579169d54 | ||
|
|
8daaa649eb | ||
|
|
2d5a4eefec | ||
|
|
d550430c8a | ||
|
|
78724acfcf | ||
|
|
20c7112812 | ||
|
|
a7c996f3ea | ||
|
|
4fbcbb4849 | ||
|
|
74102ac6c2 | ||
|
|
11a6fa9b45 | ||
|
|
507a6f29a5 | ||
|
|
e725bc5de3 | ||
|
|
c15d4c50a1 | ||
|
|
e1bc4a096b | ||
|
|
b524de9298 | ||
|
|
bfb5fb922f | ||
|
|
3f4d55343a | ||
|
|
73d8d2c2c3 | ||
|
|
fcfe73c50d | ||
|
|
ffb6b0a5f6 | ||
|
|
5564015a8b | ||
|
|
62eeb10d38 | ||
|
|
2de7fdde10 | ||
|
|
5a96d1769e | ||
|
|
a443a55bec | ||
|
|
9b2d9fdd1f | ||
|
|
05ad9b2475 | ||
|
|
7962d02be5 | ||
|
|
5c35288162 | ||
|
|
aa6732da19 | ||
|
|
65ac11e952 | ||
|
|
0b21cff020 | ||
|
|
062e0a00fb | ||
|
|
53ae78c609 | ||
|
|
b046c4b569 | ||
|
|
46cb89b232 | ||
|
|
e20abb19fb | ||
|
|
bf76f47357 | ||
|
|
9c2d3d067d | ||
|
|
ae7ede3201 | ||
|
|
e5ff431b7a | ||
|
|
66a676f2f5 | ||
|
|
d33dedd038 | ||
|
|
1f2837f86a | ||
|
|
927cf68110 | ||
|
|
cd72ad0008 | ||
|
|
2d2e85e340 | ||
|
|
4da1b9a02a | ||
|
|
73758f7f77 | ||
|
|
c433c36687 | ||
|
|
3e9d7b7cf7 | ||
|
|
2a56d2afb8 | ||
|
|
bff41ad66a | ||
|
|
1d088911e1 | ||
|
|
f23eb96555 | ||
|
|
0d4e7defb6 | ||
|
|
023aec22ca | ||
|
|
bf4cee9898 | ||
|
|
147ff131f4 | ||
|
|
3e5edd03a6 | ||
|
|
0afefa4889 | ||
|
|
49a762832b | ||
|
|
3cf5cab1d2 | ||
|
|
5968554e4c | ||
|
|
2684fa1d62 | ||
|
|
de75a69cad | ||
|
|
1811b36205 | ||
|
|
9ef3499418 | ||
|
|
08d9982981 | ||
|
|
9a2fc883b6 | ||
|
|
79b9335192 | ||
|
|
dedf4e158e | ||
|
|
8642646fa7 | ||
|
|
98f63c7172 | ||
|
|
297470ac2e | ||
|
|
b043230680 | ||
|
|
bc38d15b6d | ||
|
|
342b61f633 | ||
|
|
390dc22c96 | ||
|
|
2a3271b8b3 | ||
|
|
f45aa85aef |
@@ -9,7 +9,7 @@ on:
|
||||
- opened
|
||||
- labeled
|
||||
push:
|
||||
branches: [livekit]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
versioning:
|
||||
|
||||
@@ -4,12 +4,12 @@ on:
|
||||
|
||||
push:
|
||||
branches:
|
||||
- livekit
|
||||
- main
|
||||
paths:
|
||||
- .github/labels.yml
|
||||
- .github/workflows/sync-labels.yml
|
||||
|
||||
permissions: {} # We use ELEMENT_BOT_TOKEN instead
|
||||
permissions: {} # We use LABEL_SYNC_GITHUB_TOKEN instead
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
@@ -20,4 +20,4 @@ jobs:
|
||||
DELETE: true
|
||||
WET: true
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.LABEL_SYNC_GITHUB_TOKEN }}
|
||||
|
||||
@@ -2,15 +2,11 @@ name: Test
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [livekit]
|
||||
branches: [main]
|
||||
jobs:
|
||||
vitest:
|
||||
name: Run unit tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# Make sure to grab the latest version of the Playwright image
|
||||
# https://playwright.dev/docs/docker#pull-the-image
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
@@ -26,6 +22,18 @@ jobs:
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
- name: Get Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
|
||||
- name: Cache Playwright binaries
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||
- name: Install Playwright binaries
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm playwright install --with-deps
|
||||
- name: Vitest
|
||||
run: "pnpm run test:coverage"
|
||||
- name: Upload to codecov
|
||||
@@ -52,12 +60,22 @@ jobs:
|
||||
- name: Install dependencies
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: pnpm install --frozen-lockfile --ignore-pnpmfile
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
- name: Get Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
|
||||
- name: Cache Playwright binaries
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||
- name: Install Playwright binaries
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm playwright install --with-deps
|
||||
- name: Run backend components
|
||||
run: |
|
||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
|
||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
|
||||
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:
|
||||
|
||||
@@ -40,6 +40,11 @@ jobs:
|
||||
- name: Fix the owner of the downloaded files
|
||||
run: "sudo chown runner:docker -R locales"
|
||||
|
||||
# Localazy doesn't write file contents in the same order that i18next-cli's
|
||||
# extractor uses. We re-run the extractor after downloading to fix the order.
|
||||
- name: i18n
|
||||
run: pnpm i18n
|
||||
|
||||
- name: Formatting
|
||||
run: pnpm format
|
||||
|
||||
@@ -53,7 +58,7 @@ jobs:
|
||||
title: Localazy Download
|
||||
commit-message: Translations updates
|
||||
labels: |
|
||||
T-Task
|
||||
PR-Task
|
||||
|
||||
- name: Enable automerge
|
||||
run: gh pr merge --merge --auto "$PR_NUMBER"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Upload translation files to Localazy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- livekit
|
||||
- main
|
||||
paths-ignore:
|
||||
- ".github/**"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: GitHub Actions Security Analysis with zizmor 🌈
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["livekit", "full-mesh"]
|
||||
branches: ["main", "full-mesh"]
|
||||
pull_request: {}
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import i18n from "i18next";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import EN from "../locales/en/app.json";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
import "../src/index.css";
|
||||
|
||||
// Bare-minimum i18n config
|
||||
// Bare-minimum i18n config.
|
||||
// Unlike the app, stories register the instance as react-i18next's default
|
||||
// rather than wrapping every story in an <I18nextProvider>.
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
|
||||
@@ -115,30 +115,33 @@ For more details on the packages, see the
|
||||
For operating and deploying Element Call on your own server, refer to the
|
||||
[**Self-Hosting Guide**](./docs/self_hosting.md).
|
||||
|
||||
## 🧭 MatrixRTC Backend Discovery and Selection
|
||||
## MatrixRTC Transports
|
||||
|
||||
For proper Element Call operation each site deployment needs a MatrixRTC backend
|
||||
setup as outlined in the [Self-Hosting Guide](./docs/self_hosting.md). A typical
|
||||
federated site deployment for three different sites A, B and C is depicted below.
|
||||
For proper operation of Element Call, each deployment needs to set up a
|
||||
MatrixRTC transport in the form of a LiveKit server as outlined in the
|
||||
[Self-Hosting Guide](./docs/self_hosting.md). A typical federated site
|
||||
deployment for three different sites A, B and C is depicted below.
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/Federated_Setup.drawio.png" alt="Element Call federated setup">
|
||||
</p>
|
||||
|
||||
### Backend Discovery
|
||||
### Transport Discovery
|
||||
|
||||
The MatrixRTC backend (according to
|
||||
[MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)) is
|
||||
announced by the Matrix site's `.well-known/matrix/client` file and discovered
|
||||
via the `org.matrix.msc4143.rtc_foci` key, e.g.:
|
||||
Element Call discovers the available MatrixRTC transports (as defined by
|
||||
[MSC4519](https://github.com/matrix-org/matrix-spec-proposals/pull/4519)) by
|
||||
hitting the `GET /_matrix/client/unstable/org.matrix.msc4143/rtc/transports`
|
||||
endpoint of the Client-Server API. An example response:
|
||||
|
||||
```json
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"rtc_transports": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"
|
||||
},
|
||||
]
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
where the format for MatrixRTC using LiveKit backend is defined in
|
||||
@@ -149,7 +152,7 @@ via `livekit_service_url`.
|
||||
|
||||
### Backend Selection
|
||||
|
||||
- Each call participant proposes their discovered MatrixRTC backend from
|
||||
- Each call participant proposes their discovered MatrixRTC transport from
|
||||
`org.matrix.msc4143.rtc_foci` in their `org.matrix.msc3401.call.member` state event.
|
||||
- For the **LiveKit** MatrixRTC backend
|
||||
([MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)),
|
||||
@@ -212,7 +215,7 @@ See also:
|
||||
|
||||
### Backend
|
||||
|
||||
A docker compose file `dev-backend-docker-compose.yml` is provided to start the
|
||||
A docker compose file `docker-compose-dev.yml` is provided to start the
|
||||
whole stack of components which is required for a local development environment
|
||||
including federation:
|
||||
|
||||
@@ -239,15 +242,14 @@ the backend components:
|
||||
```sh
|
||||
pnpm backend
|
||||
# or for podman-compose:
|
||||
# podman-compose -f dev-backend-docker-compose.yml up
|
||||
# podman-compose -f docker-compose-dev.yml up
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To ensure your local development frontend functions properly, you’ll need to
|
||||
> add certificate exceptions in your browser for `https://localhost:3000`,
|
||||
> `https://matrix-rtc.m.localhost/livekit/jwt/healthz` and
|
||||
> `https://synapse.m.localhost/.well-known/matrix/client`. This can be either
|
||||
> done by adding the minimum localhost CA
|
||||
> add certificate exceptions in your browser for `https://localhost:3000` and
|
||||
> `https://matrix-rtc.m.localhost/livekit/jwt/healthz`. This can be done either
|
||||
> by adding the minimum localhost CA
|
||||
> ([./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt)) to your web
|
||||
> browser's trusted certificates or by simply copying and pasting each URL into
|
||||
> your browser’s address bar and follow the prompts to add the exception.
|
||||
|
||||
@@ -40,6 +40,8 @@ experimental_features:
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
@@ -54,7 +56,6 @@ enable_registration_without_verification: true
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
# Ratelimiting settings for client actions (registration, login, messaging).
|
||||
#
|
||||
@@ -67,3 +68,8 @@ rc_message:
|
||||
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
|
||||
per_second: 0.5
|
||||
burst_count: 30
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: https://matrix-rtc.othersite.m.localhost/livekit/jwt
|
||||
|
||||
@@ -40,6 +40,8 @@ experimental_features:
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
@@ -54,7 +56,6 @@ enable_registration_without_verification: true
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
# Ratelimiting settings for client actions (registration, login, messaging).
|
||||
#
|
||||
@@ -67,3 +68,8 @@ rc_message:
|
||||
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
|
||||
per_second: 0.5
|
||||
burst_count: 30
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: https://matrix-rtc.m.localhost/livekit/jwt
|
||||
|
||||
+2
-26
@@ -1,4 +1,4 @@
|
||||
# Synapse reverse proxy including .well-known/matrix/client
|
||||
# Synapse reverse proxy
|
||||
# domain synapse.m.localhost
|
||||
server {
|
||||
listen 80;
|
||||
@@ -11,18 +11,6 @@ server {
|
||||
ssl_certificate /root/ssl/cert.pem;
|
||||
ssl_certificate_key /root/ssl/key.pem;
|
||||
|
||||
# well-known config adding rtc_foci backend
|
||||
# Note well-known is currently not effective due to:
|
||||
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
|
||||
# says it must be at https://$server_name/... (implied port 443) Hence, we
|
||||
# currently rely for local development environment on deprecated config.json
|
||||
# setting for livekit_service_url
|
||||
location /.well-known/matrix/client {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
return 200 '{"m.homeserver": {"base_url": "https://synapse.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.m.localhost/livekit/jwt"}]}';
|
||||
default_type application/json;
|
||||
}
|
||||
|
||||
# Reverse proxy for Matrix Synapse Homeserver
|
||||
# This is also required for development environment.
|
||||
# Reason: the lk-jwt-service uses the federation API for the openid token
|
||||
@@ -44,7 +32,7 @@ server {
|
||||
|
||||
}
|
||||
|
||||
# Synapse reverse proxy including .well-known/matrix/client
|
||||
# Synapse reverse proxy
|
||||
# domain synapse.othersite.m.localhost
|
||||
server {
|
||||
listen 80;
|
||||
@@ -57,18 +45,6 @@ server {
|
||||
ssl_certificate /root/ssl/cert.pem;
|
||||
ssl_certificate_key /root/ssl/key.pem;
|
||||
|
||||
# well-known config adding rtc_foci backend
|
||||
# Note well-known is currently not effective due to:
|
||||
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
|
||||
# says it must be at https://$server_name/... (implied port 443) Hence, we
|
||||
# currently rely for local development environment on deprecated config.json
|
||||
# setting for livekit_service_url
|
||||
location /.well-known/matrix/client {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
return 200 '{"m.homeserver": {"base_url": "https://synapse.othersite.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.othersite.m.localhost/livekit/jwt"}]}';
|
||||
default_type application/json;
|
||||
}
|
||||
|
||||
# Reverse proxy for Matrix Synapse Homeserver
|
||||
# This is also required for development environment.
|
||||
# Reason: the lk-jwt-service uses the federation API for the openid token
|
||||
|
||||
@@ -40,6 +40,8 @@ experimental_features:
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
@@ -54,7 +56,6 @@ enable_registration_without_verification: true
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
# Ratelimiting settings for client actions (registration, login, messaging).
|
||||
#
|
||||
@@ -84,3 +85,8 @@ rc_login:
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: https://matrix-rtc.othersite.m.localhost/livekit/jwt
|
||||
|
||||
@@ -40,6 +40,8 @@ experimental_features:
|
||||
msc4222_enabled: true
|
||||
# sticky events for MatrixRTC user state
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
@@ -54,7 +56,6 @@ enable_registration_without_verification: true
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
# Ratelimiting settings for client actions (registration, login, messaging).
|
||||
#
|
||||
@@ -84,3 +85,8 @@ rc_login:
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: https://matrix-rtc.m.localhost/livekit/jwt
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
"server_name": "synapse.m.localhost"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"feature_use_device_session_member_events": true
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
"livekit": {
|
||||
"livekit_service_url": "https://livekit-jwt.mydomain.com"
|
||||
},
|
||||
"features": {
|
||||
"feature_use_device_session_member_events": true
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_mode": "compatibility",
|
||||
"matrix_rtc_session": {
|
||||
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.10.1
|
||||
image: livekit/livekit-server:v1.13.4
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
@@ -70,7 +70,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
livekit-1:
|
||||
image: livekit/livekit-server:v1.10.1
|
||||
image: livekit/livekit-server:v1.13.4
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu-1
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
@@ -156,7 +156,7 @@ services:
|
||||
nginx:
|
||||
# see backend/dev_tls_setup for how to generate the tls certs
|
||||
hostname: synapse.m.localhost
|
||||
image: nginx:latest
|
||||
image: nginx:latest@sha256:4ae259ae64fbedb67918c07d167fdcb0e05855a1615480ca445bea485e7d65ff
|
||||
pull_policy: always
|
||||
volumes:
|
||||
- ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z
|
||||
@@ -0,0 +1,19 @@
|
||||
# This file contains overrides to docker-compose-dev.yml and should
|
||||
# only be used in combination with that file.
|
||||
services:
|
||||
synapse:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
synapse-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
volumes:
|
||||
- ./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:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f
|
||||
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:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f
|
||||
@@ -5,5 +5,6 @@ This folder contains documentation for setup, usage, and development of Element
|
||||
- [Embedded vs standalone mode](./embedded_standalone.md)
|
||||
- [Url format and parameters](./url_params.md)
|
||||
- [Global JS controls](./controls.md)
|
||||
- [MatrixRTC modes](./matrix_rtc_modes.md)
|
||||
- [Self-Hosting](./self_hosting.md)
|
||||
- [Developing with linked packages](./linking.md)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# MatrixRTC modes
|
||||
|
||||
Element Call is in the middle of a transition of how a call session is
|
||||
represented: from room _state_ events (`org.matrix.msc3401.call.member`) to
|
||||
_sticky_ events
|
||||
([MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354)),
|
||||
which are a much better fit for the short lived, per-device nature of call
|
||||
memberships.
|
||||
|
||||
Not every homeserver supports sticky events yet. The two MatrixRTC modes
|
||||
controls whether Element Call uses them.
|
||||
|
||||
## The modes
|
||||
|
||||
| Mode | Membership events | SFU selection | JWT endpoint |
|
||||
| --------------- | ----------------- | ------------- | ---------------------------- |
|
||||
| `compatibility` | state events | multi SFU | legacy |
|
||||
| `matrix_2_0` | sticky events | multi SFU | Matrix 2.0 (hashed identity) |
|
||||
|
||||
**`compatibility`** — multi SFU, but still state events. Use it when the
|
||||
homeserver does not support sticky events. This is the default.
|
||||
|
||||
**`matrix_2_0`** — the target state. Requires a homeserver that advertises
|
||||
MSC4354 and all clients on v0.17.0 or later. The local membership requests its
|
||||
token from the Matrix 2.0 JWT endpoint of the
|
||||
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
||||
and identifies the room by a hashed identity instead of a `livekit_alias`.
|
||||
(Remote memberships always try the new endpoint first and fall back to the
|
||||
legacy one, so remote participants can be on either.)
|
||||
|
||||
## Selecting a mode
|
||||
|
||||
Users can choose a mode under **Settings → Developer → MatrixRTC mode**. The
|
||||
Matrix 2.0 option is disabled if the homeserver does not support sticky events.
|
||||
|
||||
A deployment can pin the mode for all its clients in `config.json`, which
|
||||
disables the Developer Settings choice:
|
||||
|
||||
```json
|
||||
{
|
||||
"matrix_rtc_mode": "compatibility"
|
||||
}
|
||||
```
|
||||
|
||||
Valid values are `compatibility` and `matrix_2_0`; an invalid value is
|
||||
ignored (with a warning) and the user's choice applies. Pinning `matrix_2_0` on a
|
||||
homeserver without sticky event support makes joining fail with a "sticky events
|
||||
required" error.
|
||||
+28
-49
@@ -16,7 +16,7 @@
|
||||
The following [MSCs](https://github.com/matrix-org/matrix-spec-proposals) are
|
||||
required for Element Call to work properly:
|
||||
|
||||
- **[MSC3266](https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md):
|
||||
- **[MSC3266](https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md)
|
||||
Room Summary API**: In Standalone mode Element Call is able to join rooms
|
||||
over federation using knocking. In this context MSC3266 is required as it
|
||||
allows to request a room summary of rooms you are not joined. The summary
|
||||
@@ -29,19 +29,26 @@ required for Element Call to work properly:
|
||||
signalling. If disabled it is very likely that you end up with stuck calls in
|
||||
Matrix rooms.
|
||||
|
||||
- **[MSC4519](https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/voip-transports-registry/proposals/4519-rtc-transports-registry.md)
|
||||
MatrixRTC Transports Registry**: Defines an endpoint that clients can use to
|
||||
query the available MatrixRTC transports (i.e. find your LiveKit SFU).
|
||||
|
||||
- **[MSC4222](https://github.com/matrix-org/matrix-spec-proposals/blob/erikj/sync_v2_state_after/proposals/4222-sync-v2-state-after.md)
|
||||
Adding `state_after` to sync v2**: Allow clients to opt-in to a change of the
|
||||
sync v2 API that allows them to correctly track the state of the room. This is
|
||||
required by Element Call to track room state reliably.
|
||||
|
||||
If you're using [Synapse](https://github.com/element-hq/synapse/) as your
|
||||
homeserver, you'll need to additionally add the following config items to
|
||||
`homeserver.yaml` to comply with Element Call:
|
||||
homeserver, you can configure these features by adding the following entries to
|
||||
`homeserver.yaml`:
|
||||
|
||||
```yaml
|
||||
experimental_features:
|
||||
# MSC3266: Room summary API. Used for knocking over federation
|
||||
msc3266_enabled: true
|
||||
# MSC4143: MatrixRTC. For historical reasons this flag enables the transports
|
||||
# endpoint defined in MSC4519.
|
||||
msc4143_enabled: true
|
||||
# MSC4222 needed for syncv2 state_after. This allow clients to
|
||||
# correctly track the state of the room.
|
||||
msc4222_enabled: true
|
||||
@@ -61,6 +68,15 @@ rc_delayed_event_mgmt:
|
||||
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2Hz
|
||||
per_second: 1
|
||||
burst_count: 20
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
# The transport you specify will be made available to clients over the
|
||||
# /_matrix/client/unstable/org.matrix.msc4143/rtc/transports endpoint as
|
||||
# defined in MSC4519.
|
||||
- type: livekit
|
||||
# Replace this with the actual URL of your MatrixRTC Authorization Service
|
||||
livekit_service_url: https://matrix-rtc.example.com/livekit/jwt
|
||||
```
|
||||
|
||||
As a prerequisite for the
|
||||
@@ -68,10 +84,10 @@ As a prerequisite for the
|
||||
make sure that your Synapse server has either a `federation` or `openid`
|
||||
[listener configured](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#listeners).
|
||||
|
||||
### MatrixRTC Backend
|
||||
### LiveKit backend
|
||||
|
||||
In order to **guarantee smooth operation** of Element Call, a MatrixRTC backend is
|
||||
required for each site deployment.
|
||||
In order to **guarantee smooth operation** of Element Call, a dedicated LiveKit
|
||||
backend is required for each site deployment.
|
||||
|
||||

|
||||
|
||||
@@ -86,10 +102,11 @@ to implement
|
||||
In the context of MatrixRTC, we suggest using a single hostname for backend
|
||||
communication by implementing endpoint routing within a reverse proxy setup. For
|
||||
the example above, this results in:
|
||||
| Service | Endpoint | Example |
|
||||
| -------- | ------- | ------- |
|
||||
|
||||
| Service | Endpoint | Example |
|
||||
| --------------------------------------------------------------------------------- | -------------- | ------------------------------------ |
|
||||
| [Livekit SFU](https://github.com/livekit/livekit) WebSocket signalling connection | `/livekit/sfu` | `matrix-rtc.example.com/livekit/sfu` |
|
||||
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
|
||||
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
|
||||
|
||||
Using Nginx, you can achieve this by:
|
||||
|
||||
@@ -164,7 +181,7 @@ Using Haproxy, you can achieve this by:
|
||||
use_backend mxrtc_auth_backend if is_mxrtc_auth matrixrtc_domain
|
||||
|
||||
# Backend
|
||||
## MatrixRTC backend
|
||||
## LiveKit backend
|
||||
backend sfu_backend
|
||||
server livekit 127.0.0.1:7880
|
||||
http-request set-path %[path,regsub(^/livekit/sfu/,/)]
|
||||
@@ -186,44 +203,6 @@ backend mxrtc_auth_backend
|
||||
|
||||
```
|
||||
|
||||
#### MatrixRTC backend announcement
|
||||
|
||||
> [!IMPORTANT]
|
||||
> As defined in
|
||||
> [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143),
|
||||
> the MatrixRTC backend(s) must be announced to the client via your **Matrix site's
|
||||
> `.well-known/matrix/client`** file (e.g.
|
||||
> `example.com/.well-known/matrix/client` matching the site deployment example
|
||||
> from above). The configuration is a list of Foci configs:
|
||||
|
||||
```json
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"
|
||||
},
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://matrix-rtc-2.example.com/livekit/jwt"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Make sure this file is served with the correct MIME type (`application/json`).
|
||||
Additionally, ensure the appropriate CORS headers are set to allow web clients
|
||||
to access it across origins. For more details, refer to the
|
||||
[Matrix Client-Server API: 2. Web Browser Clients](https://spec.matrix.org/latest/client-server-api/#web-browser-clients).
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: X-Requested-With, Content-Type, Authorization
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Most `org.matrix.msc4143.rtc_foci` configurations will only have one entry in
|
||||
> the array.
|
||||
|
||||
## Building Element Call
|
||||
|
||||
> [!NOTE]
|
||||
@@ -277,7 +256,7 @@ runtime. Documentation and default values for `public/config.json` can be found
|
||||
in [ConfigOptions.ts](../src/config/ConfigOptions.ts).
|
||||
|
||||
> [!CAUTION]
|
||||
> Please note configuring MatrixRTC backend via `config.json` of
|
||||
> Please note configuring LiveKit backend via `config.json` of
|
||||
> Element Call is only available for developing and debug purposes. Relying on
|
||||
> it might break Element Call going forward!
|
||||
|
||||
|
||||
@@ -20,7 +20,12 @@ export default {
|
||||
// This is a shell built-in.
|
||||
"printf",
|
||||
],
|
||||
ignoreFiles: ["scripts/.pnpmfile.cjs"],
|
||||
ignoreFiles: [
|
||||
"scripts/.pnpmfile.cjs",
|
||||
// Deliberately added prior to any component or business logic
|
||||
// implementation
|
||||
"src/state/ServiceInterruptionsViewModel.ts",
|
||||
],
|
||||
ignoreDependencies: [
|
||||
// Used in CSS
|
||||
"normalize.css",
|
||||
|
||||
@@ -102,11 +102,15 @@
|
||||
"connection_lost_description": "Hovor byl přerušen.",
|
||||
"e2ee_unsupported": "Nekompatibilní prohlížeč",
|
||||
"e2ee_unsupported_description": "Váš webový prohlížeč nepodporuje šifrované hovory. Mezi podporované prohlížeče patří Chrome, Safari a Firefox 117+.",
|
||||
"failed_to_start_livekit": "Nepodařilo se navázat připojení k Livekitu",
|
||||
"generic": "Něco se pokazilo.",
|
||||
"generic_description": "Odeslání protokolů ladění nám pomůže vystopovat problém.",
|
||||
"insufficient_capacity": "Nedostatečná kapacita",
|
||||
"insufficient_capacity_description": "Server dosáhl své maximální kapacity a v tuto chvíli se nemůžete připojit k hovoru. Zkuste to později nebo se obraťte na správce serveru, pokud problém přetrvává.",
|
||||
"matrix_rtc_transport_missing": "Server není nakonfigurován pro práci s {{brand}}. Obraťte se na správce serveru (Doména: {{domain}}, Kód chyby: {{ errorCode }}).",
|
||||
"membership_manager": "Chyba Správce členství",
|
||||
"membership_manager_description": "Správce členství musel být ukončen. To je způsobeno mnoha po sobě jdoucími neúspěšnými síťovými požadavky.",
|
||||
"no_matrix_2_authorization_service": "Autorizační služba vašeho mediálního serveru (SFU) je zastaralá.",
|
||||
"open_elsewhere": "Otevřeno na jiné kartě",
|
||||
"open_elsewhere_description": "{{brand}} byl otevřen v jiné záložce. Pokud to nezní správně, zkuste stránku znovu načíst.",
|
||||
"room_creation_restricted": "Nepodařilo se vytvořit hovor",
|
||||
@@ -141,6 +145,7 @@
|
||||
},
|
||||
"layout_grid_label": "Mřížka",
|
||||
"layout_spotlight_label": "Soustředěný mód",
|
||||
"layout_switch_label": "Rozvržení",
|
||||
"lobby": {
|
||||
"ask_to_join": "Žádost o připojení k hovoru",
|
||||
"join_as_guest": "Připojte se jako host",
|
||||
@@ -241,11 +246,14 @@
|
||||
"version": "{{productName}}verze: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Vždy zobrazit",
|
||||
"call_ended": "Hovor ukončen",
|
||||
"calling": "Volání…",
|
||||
"camera_starting": "Načítání videa...",
|
||||
"collapse": "Sbalit",
|
||||
"expand": "Rozbalit",
|
||||
"mute_for_me": "Pro mě ztlumit",
|
||||
"muted_for_me": "Pro mě ztlumené",
|
||||
"screen_share_volume": "Hlasitost sdílení obrazovky",
|
||||
"volume": "Hlasitost",
|
||||
"waiting_for_media": "Čekání na média..."
|
||||
}
|
||||
|
||||
+17
-6
@@ -61,7 +61,7 @@
|
||||
"crypto_version": "Crypto version: {{version}}",
|
||||
"custom_livekit_url": {
|
||||
"current_url": "Currently set to: ",
|
||||
"from_config": "Currently, no overwrite is set. Url from well-known or config is used.",
|
||||
"from_config": "Currently, no overwrite is set. Url from config is used.",
|
||||
"label": "Custom Livekit-url",
|
||||
"reset": "Reset overwrite",
|
||||
"save": "Save",
|
||||
@@ -80,10 +80,6 @@
|
||||
"description": "Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)",
|
||||
"label": "Compatibility: state events & multi SFU"
|
||||
},
|
||||
"Legacy": {
|
||||
"description": "Compatible with old versions of EC that do not support multi SFU",
|
||||
"label": "Legacy: state events & oldest membership SFU"
|
||||
},
|
||||
"Matrix_2_0": {
|
||||
"description": "Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later",
|
||||
"label": "Matrix 2.0: sticky events & multi SFU"
|
||||
@@ -196,13 +192,23 @@
|
||||
"room_auth_view_ssla_caption": "By clicking \"Join call now\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",
|
||||
"screenshare_button_label": "Share screen",
|
||||
"settings": {
|
||||
"advanced_camera_description": "Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.",
|
||||
"advanced_camera_label": "Advanced camera settings",
|
||||
"advanced_screen_share_description": "Configure resolution, framerate, bitrate, and codec for screen sharing",
|
||||
"advanced_screen_share_label": "Advanced screen share settings",
|
||||
"audio_processing_description": "Changes apply on next call join.",
|
||||
"audio_processing_header": "Audio processing",
|
||||
"audio_tab": {
|
||||
"effect_volume_description": "Adjust the volume at which reactions and hand raised effects play.",
|
||||
"effect_volume_label": "Sound effect volume"
|
||||
},
|
||||
"auto_gain_control_label": "Automatic gain control",
|
||||
"background_blur_header": "Background",
|
||||
"background_blur_label": "Blur the background of the video",
|
||||
"bitrate_label": "Bitrate",
|
||||
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
|
||||
"camera_header": "Camera quality",
|
||||
"codec_label": "Codec",
|
||||
"developer_tab_title": "Developer",
|
||||
"devices": {
|
||||
"activating": "Activating…",
|
||||
@@ -218,12 +224,15 @@
|
||||
"speaker": "Speaker",
|
||||
"speaker_numbered": "Speaker {{n}}"
|
||||
},
|
||||
"echo_cancellation_label": "Echo cancellation",
|
||||
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
|
||||
"feedback_tab_description_label": "Your feedback",
|
||||
"feedback_tab_h4": "Submit feedback",
|
||||
"feedback_tab_send_logs_label": "Include debug logs",
|
||||
"feedback_tab_thank_you": "Thanks, we received your feedback!",
|
||||
"feedback_tab_title": "Feedback",
|
||||
"framerate_label": "Framerate",
|
||||
"noise_suppression_label": "Noise suppression",
|
||||
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
|
||||
"preferences_tab": {
|
||||
"developer_mode_label": "Developer mode",
|
||||
@@ -235,7 +244,9 @@
|
||||
"reactions_show_label": "Show reactions",
|
||||
"show_hand_raised_timer_description": "Show a timer when a participant raises their hand",
|
||||
"show_hand_raised_timer_label": "Show hand raise duration"
|
||||
}
|
||||
},
|
||||
"resolution_label": "Resolution",
|
||||
"screen_share_header": "Screen sharing"
|
||||
},
|
||||
"star_rating_input_label_one": "{{count}} star",
|
||||
"star_rating_input_label_other": "{{count}} stars",
|
||||
|
||||
+27
-1
@@ -3,6 +3,7 @@
|
||||
"user_menu": "Kasutajamenüü"
|
||||
},
|
||||
"action": {
|
||||
"blur_background": "Hägusta tausta",
|
||||
"close": "Sulge",
|
||||
"copy_link": "Kopeeri link",
|
||||
"edit": "Muuda",
|
||||
@@ -72,8 +73,20 @@
|
||||
"environment_variables": "Keskkonnamuutujad",
|
||||
"hostname": "Hosti nimi: {{hostname}}",
|
||||
"livekit_server_info": "LiveKiti serveri teave",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"livekit_sfu": "LiveKiti meediaedastusserver (SFU): {{url}}",
|
||||
"matrixRTCMode": {
|
||||
"Comptibility": {
|
||||
"description": "Ühildub koduserveritega, mis ei toeta määratud kestusega sündmuseid (kuid kõik teised EC kliendid on v0.17.0 või hilisemad)",
|
||||
"label": "Ühilduvus: olekusündmused ja mitu meediaedastusserverit (SFU)"
|
||||
},
|
||||
"Legacy": {
|
||||
"description": "Ühildub EC vanemate versioonidega, millel puudub mitme meediaedastusserveri (SFU) tugi",
|
||||
"label": "Vana lahendus: oleku üritused ja vanim meediaedastusserver (SFU)"
|
||||
},
|
||||
"Matrix_2_0": {
|
||||
"description": "Ühildub ainult määratud kestusega sündmuseid toetavate koduserveritega ja kõigi EC-klientidega alates versioonist 0.17.0",
|
||||
"label": "Matrix 2.0: määratud kestusega sündmused ja mitu meediaedastusserverit (SFU)"
|
||||
},
|
||||
"title": "MatrixRTC režiim"
|
||||
},
|
||||
"matrix_id": "Matrixi kasutajatunnus: {{id}}",
|
||||
@@ -95,13 +108,20 @@
|
||||
"generic_description": "Silumis- ja vealogide saatmine võib aidata meid vea põhjuseni jõuda.",
|
||||
"insufficient_capacity": "Mittepiisav jõudlus",
|
||||
"insufficient_capacity_description": "Serveri jõudluse ülempiir on hetkel ületatud ja sa ei saa hetkel selle kõnega liituda. Proovi hiljem uuesti või kui probleem kestab kauem, siis võta ühendust serveri haldajaga.",
|
||||
"livekit_connection_error": "Livekiti serveriga ühendamine ei õnnestunud",
|
||||
"livekit_connection_error_description": "Livekiti serveriga ühendamisel tekkis viga (<1>Põhjus: </1> <2>{{ reason }}</2>).",
|
||||
"matrix_rtc_transport_missing": "See server pole seadistatud töötama rakendusega {{brand}}. Palun võta ühendust serveri halduriga (domeen: {{domain}}, veakood: {{ errorCode }}).",
|
||||
"membership_manager": "Viga liikmelisuse haldamisel",
|
||||
"membership_manager_description": "Liikmelisuse haldur pidi oma töö lõpetama. Selle põhjuseks olid paljud järjestikused ebaõnnestunud võrgupäringud.",
|
||||
"no_matrix_2_authorization_service": "Sinu meediaedastusserveri (SFU) autoriseerimisteenus on aegunud.",
|
||||
"open_elsewhere": "Avatud teisel vahekaardil",
|
||||
"open_elsewhere_description": "{{brand}} on avatud teisel vahekaardil. Kui see ei tundu olema õige, proovi selle lehe uuesti laadimist.",
|
||||
"peer_connection_timeout": "Ühendus aegus",
|
||||
"peer_connection_timeout_description": "Meediaserveriga ühenduspäring aegus. Proovi kasutada muud võrku või keelata VPN-i kasutamine. Kui probleem püsib, vaata meie <0> veaotsingu juhendit</0> või võta ühendust oma serveri peakasutajaga.",
|
||||
"room_creation_restricted": "Kõne loomine ei õnnestunud",
|
||||
"room_creation_restricted_description": "Kõne loomine võib olla lubatud ainult volitatud kasutajatele. Proovi hiljem uuesti või probleemi püsimisel võta ühendust oma serveri haldajaga.",
|
||||
"sticky_events_required": "Koduserver ei toeta Matrix 2.0 kõnesid",
|
||||
"sticky_events_required_description": "See server on seadistatud kasutama Matrix 2.0 kõnerežiimi, kuid koduserver ei teata, et tal selleks vajalike sündmuste (MSC4354) tugi. Palu serveri peakasutajal see uuendada või kasuta koduserveris ühilduvat režiimi.",
|
||||
"unexpected_ec_error": "Tekkis ootamatu viga (<0>Veakood:</0> <1>{{ errorCode }}</1>). Palun võta ühendust serveri haldajaga."
|
||||
},
|
||||
"group_call_loader": {
|
||||
@@ -132,6 +152,7 @@
|
||||
},
|
||||
"layout_grid_label": "Ruudustik",
|
||||
"layout_spotlight_label": "Rambivalgus",
|
||||
"layout_switch_label": "Paigutus",
|
||||
"lobby": {
|
||||
"ask_to_join": "Küsi võimalust liituda kõnega",
|
||||
"join_as_guest": "Liitu külalisena",
|
||||
@@ -184,6 +205,7 @@
|
||||
"blur_not_supported_by_browser": "(Tausta hägustamine pole selles seadmes toetatud.)",
|
||||
"developer_tab_title": "Arendaja",
|
||||
"devices": {
|
||||
"activating": "Aktiveerin…",
|
||||
"camera": "Kaamera",
|
||||
"camera_numbered": "Kaamera {{n}}",
|
||||
"change_device_button": "Muuda heliseadet",
|
||||
@@ -223,6 +245,7 @@
|
||||
"stop_video_button_label": "Peata videovoog",
|
||||
"submitting": "Saadan…",
|
||||
"switch_camera": "Vaheta kaamerat",
|
||||
"technical_details": "Tehnilised üksikasjad",
|
||||
"unauthenticated_view_body": "Sa pole veel registreerunud? <2>Loo kasutajakonto</2>",
|
||||
"unauthenticated_view_login_button": "Logi oma kontosse sisse",
|
||||
"unauthenticated_view_ssla_caption": "Klõpsides „Jätka“ nõustud sa meie <2>Tarkvara ja teenuste litsentseerimise lepinguga (Software and Services License Agreement - SSLA)</2>",
|
||||
@@ -230,11 +253,14 @@
|
||||
"version": "{{productName}}, versioon: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Näita alati",
|
||||
"call_ended": "Kõne lõppes",
|
||||
"calling": "Helistan…",
|
||||
"camera_starting": "Video on laadimisel...",
|
||||
"collapse": "Näita vähem",
|
||||
"expand": "Näita rohkem",
|
||||
"mute_for_me": "Summuta minu jaoks",
|
||||
"muted_for_me": "Minule summutatud",
|
||||
"screen_share_volume": "Helivaljus ekraanijagaamisel",
|
||||
"volume": "Helivaljus",
|
||||
"waiting_for_media": "Ootame kuni meedia on olemas..."
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"user_menu": "Käyttäjävalikko"
|
||||
},
|
||||
"action": {
|
||||
"blur_background": "Sumenna tausta",
|
||||
"close": "Sulje",
|
||||
"copy_link": "Kopioi linkki",
|
||||
"edit": "Muokkaa",
|
||||
@@ -107,14 +108,20 @@
|
||||
"generic_description": "Vianmäärityslokien lähettäminen auttaa meitä jäljittämään ongelman.",
|
||||
"insufficient_capacity": "Riittämätön kapasiteetti",
|
||||
"insufficient_capacity_description": "Palvelin on saavuttanut maksimikapasiteettinsa, etkä voi liittyä puheluun tällä hetkellä. Yritä myöhemmin uudelleen tai ota yhteyttä palvelimen ylläpitäjään, jos ongelma jatkuu.",
|
||||
"livekit_connection_error": "Yhteyden muodostaminen Livekit-palvelimeen epäonnistui",
|
||||
"livekit_connection_error_description": "Livekit-palvelimeen yhteyden muodostamisessa tapahtui virhe (<1>Syy: </1> <2>{{ reason }}</2>).",
|
||||
"matrix_rtc_transport_missing": "Palvelinta ei ole määritetty toimimaan {{brand}} -sovelluksen kanssa. Ota yhteyttä palvelimen ylläpitäjään (Verkkotunnus: {{domain}}, Virhekoodi: {{ errorCode }}).",
|
||||
"membership_manager": "Jäsenyydenhallinnan virhe",
|
||||
"membership_manager_description": "Jäsenyyshallinta jouduttiin sulkemaan. Tämä johtui useista peräkkäisistä epäonnistuneista verkkopyynnöistä.",
|
||||
"no_matrix_2_authorization_service": "Mediapalvelimesi (SFU) valtuutuspalvelu on vanhentunut.",
|
||||
"open_elsewhere": "Avattu toisessa välilehdessä",
|
||||
"open_elsewhere_description": "{{brand}} on avattu toisessa välilehdessä. Jos tämä ei kuulosta oikealta, yritä ladata sivu uudelleen.",
|
||||
"peer_connection_timeout": "Yhteyden aikakatkaisu",
|
||||
"peer_connection_timeout_description": "Yhteys mediapalvelimeen aikakatkaistiin. Kokeile vaihtaa verkkoa tai poistaa VPN käytöstä. Jos ongelma jatkuu, katso <0>vianetsintäoppaamme</0> tai ota yhteyttä palvelimesi ylläpitäjään.",
|
||||
"room_creation_restricted": "Puhelun luominen epäonnistui",
|
||||
"room_creation_restricted_description": "Puheluiden luominen saattaa olla rajoitettu vain valtuutetuille käyttäjille. Yritä myöhemmin uudelleen tai ota yhteyttä palvelimen ylläpitäjään, jos ongelma jatkuu.",
|
||||
"sticky_events_required": "Kotipalvelin ei tue Matrix 2.0 -puheluita",
|
||||
"sticky_events_required_description": "Tämä asennus on määritetty käyttämään Matrix 2.0 -puhelutilaa, mutta kotipalvelin ei mainosta tukea tarttuville tapahtumille (MSC4354). Pyydä palvelimen järjestelmänvalvojaa päivittämään tai vaihtamaan asennus yhteensopivaan tilaan.",
|
||||
"unexpected_ec_error": "Tapahtui odottamaton virhe (<0>Virhekoodi:</0> <1>{{ errorCode }}</1>). Ota yhteyttä palvelimen ylläpitäjään."
|
||||
},
|
||||
"group_call_loader": {
|
||||
@@ -145,6 +152,7 @@
|
||||
},
|
||||
"layout_grid_label": "Ruudukko",
|
||||
"layout_spotlight_label": "Valokeila",
|
||||
"layout_switch_label": "Asettelu",
|
||||
"lobby": {
|
||||
"ask_to_join": "Pyydä liittymistä puheluun",
|
||||
"join_as_guest": "Liity vieraana",
|
||||
@@ -197,6 +205,7 @@
|
||||
"blur_not_supported_by_browser": "(Tämä laite ei tue taustan sumennusta.)",
|
||||
"developer_tab_title": "Kehittäjä",
|
||||
"devices": {
|
||||
"activating": "Aktivoidaan…",
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"change_device_button": "Vaihda äänilaite",
|
||||
@@ -236,6 +245,7 @@
|
||||
"stop_video_button_label": "Lopeta video",
|
||||
"submitting": "Lähetetään…",
|
||||
"switch_camera": "Vaihda kameraa",
|
||||
"technical_details": "Tekniset tiedot",
|
||||
"unauthenticated_view_body": "Etkö ole vielä rekisteröitynyt? <2>Luo tili</2>",
|
||||
"unauthenticated_view_login_button": "Kirjaudu tilillesi",
|
||||
"unauthenticated_view_ssla_caption": "Klikkaamalla \"Siirry\" hyväksyt <2>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</2>",
|
||||
|
||||
+57
-15
@@ -3,6 +3,7 @@
|
||||
"user_menu": "Menu użytkownika"
|
||||
},
|
||||
"action": {
|
||||
"blur_background": "Rozmycie tła",
|
||||
"close": "Zamknij",
|
||||
"copy_link": "Kopiuj link",
|
||||
"edit": "Edytuj",
|
||||
@@ -21,7 +22,7 @@
|
||||
"submit": "Wyślij",
|
||||
"upload_file": "Prześlij plik"
|
||||
},
|
||||
"analytics_notice": "Uczestnicząc w tej becie, upoważniasz nas do zbierania anonimowych danych, które wykorzystamy do ulepszenia produktu. Dowiedz się więcej na temat danych, które zbieramy w naszej <2>Polityce prywatności</2> i <5>Polityce ciasteczek</5>.",
|
||||
"analytics_notice": "Biorąc udział w wersji beta, wyrażasz zgodę na gromadzenie anonimowych danych, które wykorzystujemy w celu ulepszenia naszego produktu. Więcej informacji na temat danych, które śledzimy, znajdziesz w naszej <2>Polityce prywatności</2> dla i <6>Polityce ciasteczek</6>.",
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Utwórz konto",
|
||||
"create_account_prompt": "<0>Może zechcesz ustawić hasło, aby zachować swoje konto?</0><1>Będziesz w stanie utrzymać swoją nazwę i ustawić awatar do wyświetlania podczas połączeń w przyszłości</1>",
|
||||
@@ -41,7 +42,7 @@
|
||||
"display_name": "Nazwa wyświetlana",
|
||||
"encrypted": "Szyfrowane",
|
||||
"home": "Strona domowa",
|
||||
"loading": "Ładowanie…",
|
||||
"loading": "Wczytywanie…",
|
||||
"next": "Dalej",
|
||||
"options": "Opcje",
|
||||
"password": "Hasło",
|
||||
@@ -58,6 +59,14 @@
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Pokaż opcję słuchawki iPhone na wszystkich platformach",
|
||||
"crypto_version": "Wersja krypto: {{version}}",
|
||||
"custom_livekit_url": {
|
||||
"current_url": "Aktualnie ustawione na: ",
|
||||
"from_config": "Aktualnie nie nadpisano ustawienia. Używany jest adres URL z dobrego źródła lub konfiguracji.",
|
||||
"label": "Własny Livekit-url",
|
||||
"reset": "Zresetuj nadpisanie",
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisuję..."
|
||||
},
|
||||
"debug_tile_layout_label": "Układ kafelków Debug",
|
||||
"device_id": "ID urządzenia: {{id}}",
|
||||
"duplicate_tiles_label": "Liczba dodatkowych kopii kafelków na uczestnika",
|
||||
@@ -65,6 +74,21 @@
|
||||
"hostname": "Nazwa hosta: {{hostname}}",
|
||||
"livekit_server_info": "Informacje serwera LiveKit",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrixRTCMode": {
|
||||
"Comptibility": {
|
||||
"description": "Kompatybilny z serwerami domowymi, które nie obsługują sticky events (ale wszyscy inni klienci EC mają wersję v0.17.0 lub nowszą)",
|
||||
"label": "Kompatybilność: state events & multi SFU"
|
||||
},
|
||||
"Legacy": {
|
||||
"description": "Kompatybilny ze starszymi wersjami EC, które nie wspierają multi SFU",
|
||||
"label": "Przestarzałe: state events & oldest membership SFU"
|
||||
},
|
||||
"Matrix_2_0": {
|
||||
"description": "Kompatybilny tylko z serwerami domowymi obsługującymi sticky events i wszystkimi klientami EC w wersji v0.17.0 lub nowszej",
|
||||
"label": "Matrix 2.0: sticky events & multi SFU"
|
||||
},
|
||||
"title": "Tryb MatrixRTC"
|
||||
},
|
||||
"matrix_id": "ID Matrix: {{id}}",
|
||||
"mute_all_audio": "Wycisz cały dźwięk (uczestnicy, reakcje, dźwięki dołączenia)",
|
||||
"show_connection_stats": "Pokaż statystyki połączenia",
|
||||
@@ -74,25 +98,36 @@
|
||||
"error": {
|
||||
"call_is_not_supported": "Połączenie nie jest obsługiwane",
|
||||
"call_not_found": "Nie znaleziono połączenia",
|
||||
"call_not_found_description": "<0>Wygląda na to, że ten link nie należy do żadnego istniejącego połączenia. Sprawdź, czy posiadasz prawidłowy link lub <1>utwórz nowy</1>.</0>",
|
||||
"call_not_found_description": "<0>Wygląda na to, że ten link nie należy do żadnego istniejącego połączenia. Sprawdź, czy masz prawidłowy link lub <2> utwórz nowy</2>.</0>",
|
||||
"connection_lost": "Utracono połączenie",
|
||||
"connection_lost_description": "Zostałeś rozłączony z rozmowy.",
|
||||
"e2ee_unsupported": "Niekompatybilna przeglądarka",
|
||||
"e2ee_unsupported_description": "Twoja przeglądarka nie wspiera szyfrowanych połączeń. Obsługiwane przeglądarki obejmują Chrome, Safari i Firefox 117+.",
|
||||
"failed_to_start_livekit": "Nie udało się uruchomić połączenia Livekit",
|
||||
"generic": "Coś poszło nie tak",
|
||||
"generic_description": "Wysłanie dziennika debug, pozwoli nam namierzyć problem.",
|
||||
"insufficient_capacity": "Za mało miejsca",
|
||||
"insufficient_capacity_description": "Serwer osiągnął maksymalną pojemność, przez co nie możesz dołączyć do połączenia. Spróbuj ponownie później lub skontaktuj się z administratorem serwera, jeśli problem nie zniknie.",
|
||||
"livekit_connection_error": "Nie udało się połączyć z serwerem Livekit",
|
||||
"livekit_connection_error_description": "Wystąpił błąd podczas łączenia się z serwerem Livekit (<1>Przyczyna:</1> <2>{{ reason }}</2>).",
|
||||
"matrix_rtc_transport_missing": "Serwer nie jest skonfigurowany do pracy z {{brand}}. Prosimy o kontakt z administratorem serwera (Domena: {{domain}}, Kod błędu: {{ errorCode }}).",
|
||||
"membership_manager": "Błąd menedżera członkostwa",
|
||||
"membership_manager_description": "Menedżer członkostwa został zamknięty przez zbyt wiele powtarzających się błędów połączenia.",
|
||||
"no_matrix_2_authorization_service": "Usługa autoryzacji dla serwera multimediów (SFU) jest nieaktualna.",
|
||||
"open_elsewhere": "Otwarto w innej karcie",
|
||||
"open_elsewhere_description": "{{brand}} został otwarty w innej karcie. Jeśli tak nie jest, spróbuj odświeżyć stronę.",
|
||||
"peer_connection_timeout": "Upłynął limit czasu połączenia",
|
||||
"peer_connection_timeout_description": "Upłynął czas oczekiwania na połączenie z serwerem multimediów. Spróbuj połączyć się z inną siecią lub wyłączyć VPN. Jeśli problem nadal występuje, zapoznaj się z naszym <0>poradnikiem rozwiązywania problemów</0> lub skontaktuj się z administratorem serwera.",
|
||||
"room_creation_restricted": "Nie udało się utworzyć połączenia",
|
||||
"room_creation_restricted_description": "Tworzenie połączeń mogło zostać ograniczone tylko do autoryzowanych użytkowników. Spróbuj ponownie później lub skontaktuj się z administratorem serwera, jeśli problem nie minie.",
|
||||
"sticky_events_required": "Serwer domowy nie wspiera połączeń Matrix 2.0",
|
||||
"sticky_events_required_description": "Wybrana konfiguracja została ustawiona na korzystanie z połączeń Matrix 2.0, lecz serwer domowy nie wspiera ogłaszania sticky events (MSC4354). Poproś administratora serwera o aktualizację lub zmień konfigurację.",
|
||||
"unexpected_ec_error": "Wystąpił nieoczekiwany błąd (<0>Kod błędu:</0> <1>{{ errorCode }}</1>). Skontaktuj się z administratorem serwera."
|
||||
},
|
||||
"group_call_loader": {
|
||||
"banned_body": "Zostałeś zbanowany z pokoju.",
|
||||
"banned_heading": "Zbanowany",
|
||||
"call_ended_body": "Zostałeś usunięty z rozmowy.",
|
||||
"call_ended_body": "Zostałeś usunięty z połączenia.",
|
||||
"call_ended_heading": "Połączenie zakończone",
|
||||
"knock_reject_body": "Członkowie pokoju odrzucili Twoją prośbę o dołączenie.",
|
||||
"knock_reject_heading": "Nie możesz dołączyć",
|
||||
@@ -112,17 +147,18 @@
|
||||
},
|
||||
"join_existing_call_modal": {
|
||||
"join_button": "Tak, dołącz do połączenia",
|
||||
"text": "Te połączenie już istnieje, czy chcesz do niego dołączyć?",
|
||||
"text": "Połączenie już istnieje, czy chcesz do niej dołączyć?",
|
||||
"title": "Dołączyć do istniejącego połączenia?"
|
||||
},
|
||||
"layout_grid_label": "Siatka",
|
||||
"layout_spotlight_label": "Centrum uwagi",
|
||||
"layout_switch_label": "Układ",
|
||||
"lobby": {
|
||||
"ask_to_join": "Poproś o dołączenie do rozmowy",
|
||||
"ask_to_join": "Poproś o dołączenie do połączenia",
|
||||
"join_as_guest": "Dołącz jako gość",
|
||||
"join_button": "Dołącz do połączenia",
|
||||
"leave_button": "Wróć do ostatnie",
|
||||
"waiting_for_invite": "Żądanie wysłane"
|
||||
"waiting_for_invite": "Prośba wysłana! Oczekiwanie na zatwierdzenie…"
|
||||
},
|
||||
"log_in": "Zaloguj",
|
||||
"logging_in": "Logowanie…",
|
||||
@@ -162,14 +198,15 @@
|
||||
"screenshare_button_label": "Udostępnij ekran",
|
||||
"settings": {
|
||||
"audio_tab": {
|
||||
"effect_volume_description": "Dostosuj głośność, z jaką odtwarzane są efekty reakcji i podniesionej ręki",
|
||||
"effect_volume_label": "Głośność efektu dźwiękowego"
|
||||
"effect_volume_description": "Dostosuj głośność efektów reakcji i podniesienia ręki.",
|
||||
"effect_volume_label": "Głośność efektów dźwiękowych"
|
||||
},
|
||||
"background_blur_header": "Tło",
|
||||
"background_blur_label": "Rozmycie tła wideo",
|
||||
"blur_not_supported_by_browser": "(Rozmycie tła nie jest wspierane przez to urządzenie.)",
|
||||
"developer_tab_title": "Programista",
|
||||
"devices": {
|
||||
"activating": "Aktywuję…",
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"change_device_button": "Zmień urządzenie audio",
|
||||
@@ -192,7 +229,7 @@
|
||||
"preferences_tab": {
|
||||
"developer_mode_label": "Tryb programisty",
|
||||
"developer_mode_label_description": "Włącz tryb programisty i wyświetl kartę ustawień programisty.",
|
||||
"introduction": "Tutaj możesz skonfigurować dodatkowe opcje dla lepszych doświadczeń.",
|
||||
"introduction": "Tutaj możesz skonfigurować dodatkowe opcje dla lepszego komfortu użytkowania.",
|
||||
"reactions_play_sound_description": "Odtwórz efekt dźwiękowy, gdy ktoś wyślę reakcję w trakcie połączenia.",
|
||||
"reactions_play_sound_label": "Odtwórz dźwięki reakcji",
|
||||
"reactions_show_description": "Odtwórz animację, gdy ktoś wyślę reakcję.",
|
||||
@@ -201,26 +238,31 @@
|
||||
"show_hand_raised_timer_label": "Pokaż czas po podniesieniu ręki"
|
||||
}
|
||||
},
|
||||
"star_rating_input_label_one": "{{count}} gwiazdki",
|
||||
"star_rating_input_label_other": "{{count}} gwiazdki",
|
||||
"star_rating_input_label_one": "{{count}} gwiazdka",
|
||||
"star_rating_input_label_few": "{{count}} gwiazdki",
|
||||
"star_rating_input_label_many": "{{count}} gwiazdek",
|
||||
"start_new_call": "Rozpocznij nowe połączenie",
|
||||
"start_video_button_label": "Rozpocznij wideo",
|
||||
"stop_screenshare_button_label": "Udostępnianie ekranu",
|
||||
"stop_video_button_label": "Zakończ wideo",
|
||||
"submitting": "Wysyłanie…",
|
||||
"switch_camera": "Przełącz kamerę",
|
||||
"technical_details": "Szczegóły techniczne",
|
||||
"unauthenticated_view_body": "Nie masz konta? <2>Utwórz je</2>",
|
||||
"unauthenticated_view_login_button": "Zaloguj się do swojego konta",
|
||||
"unauthenticated_view_login_button": "Zaloguj się na swoje konto",
|
||||
"unauthenticated_view_ssla_caption": "Klikając \"Przejdź\", wyrażasz zgodę na naszą <2>Umowę licencyjną oprogramowania i usług (SSLA)</2>",
|
||||
"unmute_microphone_button_label": "Odcisz mikrofon",
|
||||
"version": "Wersja: {{version}}",
|
||||
"unmute_microphone_button_label": "Wyłącz wyciszenie mikrofonu",
|
||||
"version": "{{productName}} wersja: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Zawsze pokazuj",
|
||||
"call_ended": "Połączenie zakończone",
|
||||
"calling": "Łączenie...",
|
||||
"camera_starting": "Wczytywanie wideo...",
|
||||
"collapse": "Zwiń",
|
||||
"expand": "Rozwiń",
|
||||
"mute_for_me": "Wycisz dla mnie",
|
||||
"muted_for_me": "Wyciszone dla mnie",
|
||||
"screen_share_volume": "Głośność udostępniania ekranu",
|
||||
"volume": "Głośność",
|
||||
"waiting_for_media": "Oczekiwanie na media..."
|
||||
}
|
||||
|
||||
+12
-10
@@ -30,8 +30,8 @@
|
||||
"test:storybook": "vitest --project=storybook",
|
||||
"test:unit": "vitest --project=unit",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"backend": "docker-compose -f dev-backend-docker-compose.yml up",
|
||||
"backend-playwright": "docker-compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up",
|
||||
"backend": "docker-compose -f docker-compose-dev.yml up",
|
||||
"backend-playwright": "docker-compose -f docker-compose-dev.yml -f docker-compose-playwright.yml up",
|
||||
"test:playwright": "playwright test",
|
||||
"test:playwright:open": "pnpm test:playwright --ui",
|
||||
"links:on": "cp scripts/.pnpmfile.cjs .pnpmfile.cjs & pnpm install",
|
||||
@@ -54,6 +54,7 @@
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-visually-hidden": "^1.0.3",
|
||||
"@react-spring/web": "^10.0.0",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@sentry/vite-plugin": "^3.0.0",
|
||||
"@storybook/addon-docs": "^10.3.6",
|
||||
@@ -76,17 +77,18 @@
|
||||
"@typescript-eslint/utils": "^8.61.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||
"@vector-im/compound-web": "^9.3.0",
|
||||
"@vector-im/compound-web": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"eslint-plugin-element-call": "link:eslint",
|
||||
"eslint-plugin-storybook": "^10.3.6",
|
||||
"fetch-mock": "11.1.5",
|
||||
"i18next": "^25.0.0",
|
||||
"i18next": "^26.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-cli": "^1.61.0",
|
||||
"jsdom": "^26.0.0",
|
||||
@@ -95,17 +97,17 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"oxfmt": "^0.56.0",
|
||||
"oxfmt": "^0.61.0",
|
||||
"oxlint": "^1.70.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"oxlint-tsgolint": "^0.25.0",
|
||||
"pako": "^2.0.4",
|
||||
"postcss": "^8.4.41",
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"posthog-js": "1.374.0",
|
||||
"posthog-js": "1.408.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
@@ -129,5 +131,5 @@
|
||||
"vitest": "^4.1.5",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0+sha512.9a36518224080c6fe5165afdcfe79bfa118c29be703f3f462b1e32efe1e98e47e8750b148e08286250aad4113cc7993ca413c4e2cd447752708c2ee5751bc95f"
|
||||
"packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1"
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
services:
|
||||
synapse:
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
synapse-1:
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
@@ -1,2 +0,0 @@
|
||||
include:
|
||||
- dev-backend-docker-compose.yml
|
||||
@@ -101,9 +101,7 @@ async function setRtcModeFromSettings(
|
||||
|
||||
// Move to Developer tab now
|
||||
await page.getByRole("tab", { name: "Developer" }).click();
|
||||
if (mode == "legacy") {
|
||||
await page.getByText("Legacy: state events").click();
|
||||
} else if (mode == "2_0") {
|
||||
if (mode == "2_0") {
|
||||
await page.getByText("Matrix 2.0").click();
|
||||
} else {
|
||||
// compat
|
||||
|
||||
@@ -12,9 +12,7 @@ import { HOST1, HOST2, type RtcMode, TestHelpers } from "./test-helpers";
|
||||
|
||||
const modePairs: [RtcMode, RtcMode][] = [
|
||||
["compat", "compat"],
|
||||
["legacy", "legacy"],
|
||||
["legacy", "compat"],
|
||||
["compat", "legacy"],
|
||||
// TODO: Compatibility + Matrix 2.0?
|
||||
];
|
||||
|
||||
modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user";
|
||||
import { HOST1, HOST2, TestHelpers } from "./test-helpers";
|
||||
|
||||
widgetTest(
|
||||
"Bug new joiner was not publishing on correct SFU",
|
||||
async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"This is a bug in the old widget, not a browser problem.",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
// 2 users in federation
|
||||
const florian = await addUser("floriant", HOST1);
|
||||
const timo = await addUser("timo", HOST2);
|
||||
|
||||
// Florian creates a room and invites Timo to it
|
||||
const roomName = "Call Room";
|
||||
await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]);
|
||||
|
||||
// Timo joins the room
|
||||
await TestHelpers.acceptRoomInvite(roomName, timo.page);
|
||||
|
||||
// Ensure we are in legacy mode (should be the default)
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
"legacy",
|
||||
);
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
timo.page,
|
||||
"legacy",
|
||||
);
|
||||
|
||||
// Let timo create a call
|
||||
await TestHelpers.startCallInCurrentRoom(timo.page, false);
|
||||
await TestHelpers.joinCallFromLobby(timo.page);
|
||||
|
||||
// We want to simulate that the oldest membership authentication is way slower than
|
||||
// the preffered auth.
|
||||
// In this setup, timo advertised$ transport will be it's own, and the active will be the one from florian
|
||||
await florian.page.route(
|
||||
"**/matrix-rtc.othersite.m.localhost/livekit/jwt/**",
|
||||
async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // 5 second delay
|
||||
await route.continue();
|
||||
},
|
||||
);
|
||||
|
||||
// Florian joins the call
|
||||
await expect(florian.page.getByTestId("join-call-button")).toBeVisible();
|
||||
await florian.page.getByTestId("join-call-button").click();
|
||||
await TestHelpers.joinCallFromLobby(florian.page);
|
||||
|
||||
await florian.page.waitForTimeout(3000);
|
||||
await timo.page.waitForTimeout(3000);
|
||||
|
||||
// We should see 2 video tiles everywhere now
|
||||
for (const user of [timo, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
await expect(frame.locator("video")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,91 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user";
|
||||
import { HOST1, HOST2, TestHelpers } from "./test-helpers";
|
||||
|
||||
// ## Issue
|
||||
// This test reproduces an issue with the publisher.
|
||||
// When switching local focus, we need to recreate the publisher.
|
||||
// This failed because of a dead lock in the old publishers destruction.
|
||||
//
|
||||
// There are numerus ways to enforece this situation:
|
||||
// - oldest member swap (manually set the oldest member focus and leave with the prev oldest member)
|
||||
// This almost never happens in the real worls since clients will set their preferredFoci list to what the oldest member is.
|
||||
// - switch from oldest member to multi sfu as the NOT the first joiner + the first joiner is on a different sfu than your preferred sfu.
|
||||
//
|
||||
// This test uses the "switch from oldest member to multi sfu" approach.
|
||||
//
|
||||
// It is a copy of federated-call.test.ts in the `["legacy", "legacy"]` setup,
|
||||
// which once connected will make the second user switch to multi sfu.
|
||||
widgetTest(
|
||||
`Test swapping publisher from ${HOST1} to ${HOST2}`,
|
||||
async ({ addUser, browserName }) => {
|
||||
test.slow();
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
const florian = await addUser("floriant", HOST1);
|
||||
const timo = await addUser("timo", HOST2);
|
||||
|
||||
const roomName = "Call Room";
|
||||
|
||||
await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]);
|
||||
|
||||
await TestHelpers.acceptRoomInvite(roomName, timo.page);
|
||||
|
||||
await florian.page.pause();
|
||||
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
"legacy",
|
||||
);
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
timo.page,
|
||||
"legacy",
|
||||
);
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(florian.page, false);
|
||||
await TestHelpers.joinCallFromLobby(florian.page);
|
||||
|
||||
// timo joins
|
||||
await TestHelpers.joinCallInCurrentRoom(timo.page);
|
||||
|
||||
// We should see 2 video tiles everywhere now
|
||||
for (const user of [timo, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// Wait for "Waiting for media..." to disappear (with timeout)
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
|
||||
timeout: 10000, // Maximum time to wait
|
||||
});
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
await expect(frame.locator("video")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
|
||||
// now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1)
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat");
|
||||
await timo.page.waitForTimeout(3000);
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(
|
||||
timo.page.locator('iframe[title="Element Call"]').contentFrame(),
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -36,6 +36,7 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
|
||||
await valere.page.waitForTimeout(600);
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.expandRoomList(valere.page);
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom");
|
||||
|
||||
// We should see the PIP overlay
|
||||
|
||||
@@ -53,6 +53,7 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
|
||||
});
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.expandRoomList(valere.page);
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
|
||||
|
||||
// We should see the PIP overlay
|
||||
|
||||
@@ -21,7 +21,7 @@ const PASSWORD = "foobarbaz1!";
|
||||
export const HOST1 = "https://app.m.localhost/#/welcome";
|
||||
export const HOST2 = "https://app.othersite.m.localhost/#/welcome";
|
||||
|
||||
export type RtcMode = "legacy" | "compat" | "2_0";
|
||||
export type RtcMode = "compat" | "2_0";
|
||||
|
||||
export class TestHelpers {
|
||||
public static async startCallInCurrentRoom(
|
||||
@@ -73,9 +73,6 @@ export class TestHelpers {
|
||||
audioOnly: boolean = false,
|
||||
isDM: boolean = false,
|
||||
): Promise<void> {
|
||||
// XXX This using the notification toast to join the room.
|
||||
// Not the button in the header
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
const label = isDM
|
||||
? audioOnly
|
||||
@@ -85,7 +82,9 @@ export class TestHelpers {
|
||||
await expect(page.getByText(label)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Join" }).click({
|
||||
// XXX This using the notification toast to join the room.
|
||||
// Not the buttons in the header or timeline
|
||||
await page.getByRole("alert").getByRole("button", { name: "Join" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
@@ -310,9 +309,7 @@ export class TestHelpers {
|
||||
|
||||
// Move to Developer tab now
|
||||
await iframe.getByRole("tab", { name: "Developer" }).click();
|
||||
if (mode == "legacy") {
|
||||
await iframe.getByText("Legacy: state events").click();
|
||||
} else if (mode == "2_0") {
|
||||
if (mode == "2_0") {
|
||||
await iframe.getByText("Matrix 2.0").click();
|
||||
} else {
|
||||
// compat
|
||||
@@ -347,6 +344,12 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
public static async expandRoomList(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole("separator", { name: "Click or drag to expand" })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
|
||||
@@ -39,7 +39,10 @@ widgetTest(
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Join" }).click();
|
||||
await whistler.page
|
||||
.getByRole("alert")
|
||||
.getByRole("button", { name: "Join" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
@@ -129,7 +132,10 @@ widgetTest(
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Join" }).click();
|
||||
await whistler.page
|
||||
.getByRole("alert")
|
||||
.getByRole("button", { name: "Join" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
|
||||
Generated
+2157
-2435
File diff suppressed because it is too large
Load Diff
+29
-6
@@ -1,10 +1,15 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- "@vector-im/compound-design-tokens"
|
||||
- "@vector-im/compound-web"
|
||||
- matrix-js-sdk
|
||||
- matrix-widget-api
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
"@sentry/cli": true
|
||||
"@swc/core": true
|
||||
"core-js": true
|
||||
"esbuild": true
|
||||
"matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8c95727b6278fe7942c20d0b9485f984dd0694b7": true
|
||||
"matrix-js-sdk@git+https://github.com/matrix-org/matrix-js-sdk.git": true
|
||||
"protobufjs": true
|
||||
overrides:
|
||||
# We need compatible versions of RxJS in our dependencies and LiveKit's dependencies, but
|
||||
@@ -24,12 +29,30 @@ overrides:
|
||||
# Security fixes:
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/110
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/122
|
||||
"qs": "^6.14.1"
|
||||
# Security fix: https://github.com/element-hq/element-call/security/dependabot/106
|
||||
"js-yaml": "^4.1.1"
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/179
|
||||
"qs": "^6.15.2"
|
||||
# Security fixes:
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/106
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/203
|
||||
"js-yaml": "^4.2.0"
|
||||
# Storybook declares support for 0.27.0 only but empirically works fine with 0.28.0.
|
||||
"esbuild": "^0.28.0"
|
||||
# Multiple security fixes: https://github.com/nodejs/undici/releases/tag/v6.24.0
|
||||
"undici": "^6.24.0"
|
||||
# Security fixes:
|
||||
# - https://github.com/nodejs/undici/releases/tag/v6.24.0
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/201
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/199
|
||||
"undici": "^6.27.0"
|
||||
# Security fix: https://github.com/advisories/GHSA-rf6f-7fwh-wjgh
|
||||
"flatted": "^3.4.2"
|
||||
# Security fix: https://github.com/element-hq/element-call/security/dependabot/184
|
||||
"ws": "^8.21.0"
|
||||
# Security fixes:
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/191
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/190
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/192
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/194
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/198
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/196
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/195
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/193
|
||||
"dompurify": "^3.4.11"
|
||||
|
||||
@@ -61,6 +61,15 @@
|
||||
{
|
||||
"groupName": "Pnpm",
|
||||
"matchDepNames": ["pnpm"]
|
||||
},
|
||||
{
|
||||
"description": "Update the element-web image digest daily, in its own PR",
|
||||
"groupName": "Element Web",
|
||||
"matchPackageNames": ["ghcr.io/element-hq/element-web"],
|
||||
"matchUpdateTypes": ["digest"],
|
||||
"schedule": ["* 0-3 * * *"],
|
||||
"minimumReleaseAge": null,
|
||||
"automerge": true
|
||||
}
|
||||
],
|
||||
"semanticCommits": "disabled",
|
||||
|
||||
+32
-23
@@ -46,7 +46,10 @@ import {
|
||||
// Can this be done in the tsconfig.json
|
||||
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
|
||||
import { type Behavior, constant } from "../src/state/Behavior";
|
||||
import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
callViewModelOptionsFromParams,
|
||||
createCallViewModel$,
|
||||
} from "../src/state/CallViewModel/CallViewModel";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { getUrlParams } from "../src/UrlParams";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
@@ -54,12 +57,9 @@ import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
widget as _widget,
|
||||
initializeWidget,
|
||||
} from "../src/widget";
|
||||
import { initializeWidget } from "../src/widget";
|
||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||
|
||||
interface MatrixRTCSdk {
|
||||
/**
|
||||
@@ -109,14 +109,15 @@ export async function createMatrixRTCSdk(
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// widget client
|
||||
initializeWidget(application, true);
|
||||
const widget = _widget;
|
||||
const widget = initializeWidget(application, true);
|
||||
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
||||
const client = await widget.client;
|
||||
const hostBridge = createWidgetHostBridge(widget);
|
||||
logger.info("client created");
|
||||
|
||||
// url params
|
||||
const { roomId } = getUrlParams();
|
||||
const urlParams = getUrlParams();
|
||||
const { roomId, controlledAudioDevices, callIntent } = urlParams;
|
||||
if (roomId === null) throw Error("could not get roomId from url params");
|
||||
const room = client.getRoom(roomId);
|
||||
if (room === null) throw Error("could not get room from client");
|
||||
@@ -128,11 +129,16 @@ export async function createMatrixRTCSdk(
|
||||
const rtcSession = rtcSessionManager.getRoomSession(room);
|
||||
|
||||
// media devices
|
||||
const mediaDevices = new MediaDevices(scope);
|
||||
const muteStates = new MuteStates(scope, mediaDevices, {
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
const mediaDevices = new MediaDevices(scope, {
|
||||
controlledAudioDevices,
|
||||
callIntent,
|
||||
});
|
||||
const muteStates = new MuteStates(
|
||||
scope,
|
||||
mediaDevices,
|
||||
{ audioEnabled: false, videoEnabled: false },
|
||||
hostBridge,
|
||||
);
|
||||
|
||||
// call view model
|
||||
const callViewModel = createCallViewModel$(
|
||||
@@ -141,7 +147,11 @@ export async function createMatrixRTCSdk(
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
hostBridge,
|
||||
},
|
||||
of({}),
|
||||
of({}),
|
||||
constant({ supported: false, processor: undefined }),
|
||||
@@ -282,18 +292,17 @@ export async function createMatrixRTCSdk(
|
||||
});
|
||||
await leaveResolver.promise;
|
||||
logger.info("send Unstick");
|
||||
await widget.api
|
||||
await hostBridge
|
||||
.setAlwaysOnScreen(false)
|
||||
.catch((e) =>
|
||||
logger.error(
|
||||
"Failed to set call widget `alwaysOnScreen` to false",
|
||||
e,
|
||||
),
|
||||
.catch((e: unknown) =>
|
||||
logger.error("Failed to set `alwaysOnScreen` to false", e),
|
||||
);
|
||||
logger.info("send Close");
|
||||
await widget.api.transport
|
||||
.send(ElementWidgetActions.Close, {})
|
||||
.catch((e) => logger.error("Failed to send close action", e));
|
||||
await hostBridge
|
||||
.close?.()
|
||||
.catch((e: unknown) =>
|
||||
logger.error("Failed to ask the host to close", e),
|
||||
);
|
||||
};
|
||||
|
||||
// schedule close first and then leave (scope.end)
|
||||
|
||||
+96
-53
@@ -5,18 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type FC,
|
||||
type JSX,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type FC, type JSX, Suspense, useEffect, useState } from "react";
|
||||
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
|
||||
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";
|
||||
@@ -25,13 +20,26 @@ import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { widget } from "./widget";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
import { useTheme } from "./useTheme";
|
||||
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
|
||||
import { type AppViewModel } from "./state/AppViewModel";
|
||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
|
||||
import {
|
||||
HeaderStyle,
|
||||
UrlParamsProvider,
|
||||
useUrlParams,
|
||||
useUrlParamsFromLocation,
|
||||
} from "./UrlParams";
|
||||
import { AppBar } from "./AppBar";
|
||||
import { i18n } from "./utils/i18n";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
import {
|
||||
createWidgetHostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
import { useInitial } from "./useInitial";
|
||||
|
||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||
|
||||
@@ -39,15 +47,24 @@ interface SimpleProviderProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the URL-derived params to the rest of the app. Only the standalone
|
||||
* and widget builds own the URL, so this lives here in the app shell rather
|
||||
* than alongside the context itself.
|
||||
*/
|
||||
const LocationUrlParamsProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const urlParams = useUrlParamsFromLocation();
|
||||
return <UrlParamsProvider value={urlParams}>{children}</UrlParamsProvider>;
|
||||
};
|
||||
|
||||
const BackgroundProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const { pathname } = useLocation();
|
||||
const { background } = useUrlParams();
|
||||
const rootElement = useRootElement();
|
||||
|
||||
useEffect(() => {
|
||||
document
|
||||
.getElementsByTagName("body")[0]
|
||||
.setAttribute("data-background", background);
|
||||
}, [pathname, background]);
|
||||
rootElement.setAttribute("data-background", background);
|
||||
}, [pathname, background, rootElement]);
|
||||
|
||||
return children;
|
||||
};
|
||||
@@ -57,61 +74,87 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
return children;
|
||||
};
|
||||
|
||||
/** Wraps the app in an {@link AppBar}, if the params ask for one. */
|
||||
const MaybeAppBar: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const { header } = useUrlParams();
|
||||
return header === HeaderStyle.AppBar ? <AppBar>{children}</AppBar> : children;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
vm: AppViewModel;
|
||||
/** A point of access to the widget API, if running as a widget. */
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const App: FC<Props> = ({ vm }) => {
|
||||
export const App: FC<Props> = ({ vm, widget }) => {
|
||||
// The standalone build has no host; the widget build's host is the client it
|
||||
// is a widget of.
|
||||
const hostBridge = useInitial(() =>
|
||||
widget === null ? nullHostBridge : createWidgetHostBridge(widget),
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
Initializer.init()
|
||||
?.then(async () => {
|
||||
if (loaded) return;
|
||||
setLoaded(true);
|
||||
await widget?.api.sendContentLoaded();
|
||||
await hostBridge.contentLoaded();
|
||||
})
|
||||
.catch(logger.error);
|
||||
});
|
||||
|
||||
// Since we are outside the router component, we cannot use useUrlParams here
|
||||
const { header } = useMemo(getUrlParams, []);
|
||||
|
||||
const content = loaded ? (
|
||||
<ClientProvider>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
||||
>
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
<SentryRoute path="/login" element={<LoginPage />} />
|
||||
<SentryRoute path="/register" element={<RegisterPage />} />
|
||||
<SentryRoute path="*" element={<RoomPage />} />
|
||||
</Routes>
|
||||
</Sentry.ErrorBoundary>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
// As a widget, the client comes from the host over the widget API. Standalone,
|
||||
// Element Call finds one itself, so there is nothing to wait for here.
|
||||
const [widgetClient, setWidgetClient] = useState<MatrixClient | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (widget === null) return;
|
||||
widget.client
|
||||
.then(setWidgetClient)
|
||||
.catch((e) => logger.error("Failed to obtain the host's client", e));
|
||||
}, [widget]);
|
||||
const clientReady = widget === null || widgetClient !== undefined;
|
||||
|
||||
const content =
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
>
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
<SentryRoute path="/login" element={<LoginPage />} />
|
||||
<SentryRoute path="/register" element={<RegisterPage />} />
|
||||
<SentryRoute path="*" element={<RoomPage />} />
|
||||
</Routes>
|
||||
</Sentry.ErrorBoundary>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<BackgroundProvider>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<Suspense fallback={null}>
|
||||
{header === HeaderStyle.AppBar ? (
|
||||
<AppBar>{content}</AppBar>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</BackgroundProvider>
|
||||
</BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<BrowserRouter>
|
||||
<LocationUrlParamsProvider>
|
||||
<BackgroundProvider>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<Suspense fallback={null}>
|
||||
<MaybeAppBar>{content}</MaybeAppBar>
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</BackgroundProvider>
|
||||
</LocationUrlParamsProvider>
|
||||
</BrowserRouter>
|
||||
</HostBridgeProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
+14
-49
@@ -9,18 +9,22 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { type WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { ClientContextProvider } from "./ClientContext";
|
||||
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||
import { widget } from "./widget";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
|
||||
const TestComponent: FC<
|
||||
PropsWithChildren<{
|
||||
client: MatrixClient;
|
||||
hostBridge?: HostBridge;
|
||||
}>
|
||||
> = ({ client, children }) => {
|
||||
> = ({ client, hostBridge = nullHostBridge, children }) => {
|
||||
return (
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
@@ -38,17 +42,11 @@ const TestComponent: FC<
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
|
||||
</ClientContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
vi.mock("./widget", () => ({
|
||||
widget: {
|
||||
api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -135,7 +133,7 @@ test("should attempt to fetch authenticated media from the server", async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test("should attempt to use widget API if running as a widget", async () => {
|
||||
test("should download media through the host when it offers to", async () => {
|
||||
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
||||
const expectedObjectURL = "my-object-url";
|
||||
const theBlob = new Blob([]);
|
||||
@@ -151,8 +149,8 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
getAccessToken: () => undefined,
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi;
|
||||
vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob });
|
||||
const downloadMedia = vi.fn().mockResolvedValue(theBlob);
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia };
|
||||
const member = mockMatrixRoomMember(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
@@ -161,7 +159,7 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
);
|
||||
const displayName = "Alice";
|
||||
render(
|
||||
<TestComponent client={client}>
|
||||
<TestComponent client={client} hostBridge={hostBridge}>
|
||||
<Avatar
|
||||
id={member.userId}
|
||||
name={displayName}
|
||||
@@ -176,38 +174,5 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
document.querySelector(`img[src='${expectedObjectURL}']`),
|
||||
);
|
||||
|
||||
expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl);
|
||||
});
|
||||
|
||||
test("Supports download files as base64", async () => {
|
||||
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
||||
const expectedBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" +
|
||||
"+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" +
|
||||
"ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" +
|
||||
"UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" +
|
||||
"Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" +
|
||||
"oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" +
|
||||
"A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" +
|
||||
"nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" +
|
||||
"ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" +
|
||||
"Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" +
|
||||
"2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" +
|
||||
"M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" +
|
||||
"kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" +
|
||||
"uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" +
|
||||
"XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" +
|
||||
"YII=";
|
||||
const mockWidgetAPI = {
|
||||
downloadFile: vi.fn().mockImplementation(async (contentUri) => {
|
||||
if (contentUri !== expectedMXCUrl) {
|
||||
return Promise.reject(new Error("Unexpected content URI"));
|
||||
}
|
||||
return { file: expectedBase64 };
|
||||
}),
|
||||
} as unknown as WidgetApi;
|
||||
|
||||
const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl);
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(downloadMedia).toBeCalledWith(expectedMXCUrl);
|
||||
});
|
||||
|
||||
+7
-27
@@ -14,10 +14,9 @@ import {
|
||||
} from "react";
|
||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { useClientState } from "./ClientContext";
|
||||
import { widget } from "./widget";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
|
||||
export enum Size {
|
||||
XS = "xs",
|
||||
@@ -76,6 +75,7 @@ export const Avatar: FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const clientState = useClientState();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const sizePx = useMemo(
|
||||
() =>
|
||||
@@ -87,7 +87,8 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs.
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra media
|
||||
// downloads, but in practice they should be stable long before this code runs.
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setAvatarUrl(undefined);
|
||||
@@ -96,8 +97,8 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
let blob: Promise<Blob>;
|
||||
|
||||
if (widget?.api) {
|
||||
blob = getAvatarFromWidgetAPI(widget.api, src);
|
||||
if (hostBridge.downloadMedia) {
|
||||
blob = hostBridge.downloadMedia(src);
|
||||
} else if (
|
||||
clientState?.state === "valid" &&
|
||||
clientState.authenticated?.client &&
|
||||
@@ -132,7 +133,7 @@ export const Avatar: FC<Props> = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [clientState, src, sizePx]);
|
||||
}, [clientState, hostBridge, src, sizePx]);
|
||||
|
||||
return (
|
||||
<CompoundAvatar
|
||||
@@ -172,24 +173,3 @@ async function getAvatarFromServer(
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
// export for testing
|
||||
export async function getAvatarFromWidgetAPI(
|
||||
api: WidgetApi,
|
||||
src: string,
|
||||
): Promise<Blob> {
|
||||
const response = await api.downloadFile(src);
|
||||
const file = response.file;
|
||||
|
||||
// element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that
|
||||
if (file instanceof Blob) {
|
||||
return file;
|
||||
} else if (typeof file === "string") {
|
||||
// it is a base64 string
|
||||
const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0));
|
||||
return new Blob([bytes]);
|
||||
}
|
||||
throw new Error(
|
||||
"Downloaded file format is not supported: " + typeof file + "",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
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, waitFor } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC } from "react";
|
||||
|
||||
import { ClientProvider, useClientState } from "./ClientContext";
|
||||
|
||||
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
({
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => userId,
|
||||
getDeviceId: () => "AAAA",
|
||||
stopClient: vi.fn(),
|
||||
}) as Partial<MatrixClient> as 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>
|
||||
);
|
||||
};
|
||||
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const client = mockClient();
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={client}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// 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>,
|
||||
);
|
||||
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}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
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>,
|
||||
);
|
||||
|
||||
// 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"));
|
||||
|
||||
vi.doUnmock("./utils/spa");
|
||||
});
|
||||
+51
-49
@@ -21,9 +21,8 @@ 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 { WidgetApi } from "matrix-widget-api";
|
||||
import { ErrorPage } from "./FullScreenView";
|
||||
import { widget } from "./widget";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
import {
|
||||
PosthogAnalytics,
|
||||
RegistrationType,
|
||||
@@ -134,18 +133,48 @@ const loadChannel =
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
/**
|
||||
* The client Element Call should use.
|
||||
*
|
||||
* When a host embeds Element Call it already has a client, and owns the
|
||||
* user's session; supplying it here means Element Call neither authenticates
|
||||
* anyone nor manages their session. Left out, Element Call finds a client
|
||||
* itself — from the widget API, or by restoring or creating a session of its
|
||||
* own.
|
||||
*/
|
||||
client?: MatrixClient;
|
||||
}
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
const navigate = useNavigate();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
// null = signed out, undefined = loading
|
||||
const [initClientState, setInitClientState] = useState<
|
||||
InitResult | null | undefined
|
||||
>(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.
|
||||
@@ -160,7 +189,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
})
|
||||
.catch((err) => logger.error(err))
|
||||
.finally(() => (initializing.current = false));
|
||||
}, []);
|
||||
}, [client]);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
@@ -201,7 +230,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
saveSession(session);
|
||||
setInitClientState({
|
||||
widgetApi: null,
|
||||
client,
|
||||
passwordlessUser: session.passwordlessUser,
|
||||
});
|
||||
@@ -228,11 +256,13 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
// 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. This isn't necessary if the app is running in
|
||||
// a widget though, since then it'll be mostly stateless.
|
||||
// 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 (!widget) loadChannel?.postMessage({});
|
||||
}, []);
|
||||
if (ownsSession) loadChannel?.postMessage({});
|
||||
}, [ownsSession]);
|
||||
|
||||
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
|
||||
undefined,
|
||||
@@ -307,64 +337,36 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||
}
|
||||
|
||||
if (initClientState.widgetApi) {
|
||||
const reactSend = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.send.event:m.reaction",
|
||||
);
|
||||
const redactSend = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||
);
|
||||
const reactRcv = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.reaction",
|
||||
);
|
||||
const redactRcv = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||
);
|
||||
|
||||
if (!reactSend || !reactRcv || !redactSend || !redactRcv) {
|
||||
logger.warn("Widget does not support reactions");
|
||||
setSupportsReactions(false);
|
||||
} else {
|
||||
setSupportsReactions(true);
|
||||
}
|
||||
} else {
|
||||
setSupportsReactions(true);
|
||||
}
|
||||
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]);
|
||||
}, [initClientState, onSync, hostBridge]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
|
||||
return <ErrorPage error={alreadyOpenedErr} />;
|
||||
}
|
||||
|
||||
return <ClientContext value={state}>{children}</ClientContext>;
|
||||
};
|
||||
|
||||
export type InitResult = {
|
||||
widgetApi: WidgetApi | null;
|
||||
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> {
|
||||
if (widget) {
|
||||
// We're inside a widget, so let's engage *matryoshka mode*
|
||||
logger.log("Using a matryoshka client");
|
||||
const client = await widget.client;
|
||||
return {
|
||||
widgetApi: widget.api,
|
||||
client,
|
||||
passwordlessUser: false,
|
||||
};
|
||||
} else {
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
|
||||
+13
-19
@@ -21,7 +21,7 @@ import { RageshakeButton } from "./settings/RageshakeButton";
|
||||
import styles from "./ErrorView.module.css";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { LinkButton } from "./button";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts";
|
||||
import { useHostBridge } from "./HostBridge.ts";
|
||||
|
||||
interface Props {
|
||||
Icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
@@ -38,7 +38,6 @@ interface Props {
|
||||
*/
|
||||
fatal?: boolean;
|
||||
children: ReactNode;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const ErrorView: FC<Props> = ({
|
||||
@@ -47,32 +46,27 @@ export const ErrorView: FC<Props> = ({
|
||||
rageshake,
|
||||
fatal,
|
||||
children,
|
||||
widget,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { confineToRoom } = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const onReload = useCallback(() => {
|
||||
window.location.href = "/";
|
||||
}, []);
|
||||
|
||||
const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({
|
||||
widget,
|
||||
const CloseButton: FC<{ close: () => Promise<void> }> = ({
|
||||
close,
|
||||
}): ReactElement => {
|
||||
// in widget mode we don't want to show the return home button but a close button
|
||||
const closeWidget = (): void => {
|
||||
widget.api.transport
|
||||
.send(ElementWidgetActions.Close, {})
|
||||
.catch((e) => {
|
||||
// What to do here?
|
||||
logger.error("Failed to send close action", e);
|
||||
})
|
||||
.finally(() => {
|
||||
widget.api.transport.stop();
|
||||
});
|
||||
// When the host can dismiss us, offer that instead of a link home
|
||||
const onClose = (): void => {
|
||||
close().catch((e) => {
|
||||
// What to do here?
|
||||
logger.error("Failed to ask the host to close Element Call", e);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Button kind="primary" onClick={closeWidget}>
|
||||
<Button kind="primary" onClick={onClose}>
|
||||
{t("action.close")}
|
||||
</Button>
|
||||
);
|
||||
@@ -108,8 +102,8 @@ export const ErrorView: FC<Props> = ({
|
||||
{rageshake && (
|
||||
<RageshakeButton description={`***Error View***: ${title}`} />
|
||||
)}
|
||||
{widget ? (
|
||||
<CloseWidgetButton widget={widget} />
|
||||
{hostBridge.close ? (
|
||||
<CloseButton close={hostBridge.close} />
|
||||
) : (
|
||||
!confineToRoom && <ReturnToHomeButton />
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,6 @@ import styles from "./FullScreenView.module.css";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { RichError } from "./RichError";
|
||||
import { ErrorView } from "./ErrorView";
|
||||
import { type WidgetHelpers } from "./widget.ts";
|
||||
|
||||
interface FullScreenViewProps {
|
||||
className?: string;
|
||||
@@ -48,12 +47,11 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: unknown;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
// Due to this component being used as the crash fallback for Sentry, which has
|
||||
// weird type requirements, we can't just give this a type of FC<ErrorPageProps>
|
||||
export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
||||
export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
logger.error(error);
|
||||
@@ -66,7 +64,6 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
||||
error.richMessage
|
||||
) : (
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={ErrorSolidIcon}
|
||||
title={t("error.generic")}
|
||||
rageshake
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api";
|
||||
import EventEmitter from "events";
|
||||
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
createWidgetHostBridge,
|
||||
type HostBridge,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "./widget";
|
||||
|
||||
function mockWidget(api: Partial<WidgetApi>): WidgetHelpers {
|
||||
return {
|
||||
api: api as WidgetApi,
|
||||
lazyActions: new EventEmitter(),
|
||||
client: Promise.resolve(),
|
||||
} as unknown as WidgetHelpers;
|
||||
}
|
||||
|
||||
/** A widget whose transport records what Element Call sends it. */
|
||||
function mockTransport(): {
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
reply: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
reply: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createWidgetHostBridge", () => {
|
||||
describe("telling the host what Element Call is doing", () => {
|
||||
test("asks to be kept on screen, and to stop being", async () => {
|
||||
const setAlwaysOnScreen = vi.fn().mockResolvedValue(true);
|
||||
const bridge = createWidgetHostBridge(mockWidget({ setAlwaysOnScreen }));
|
||||
|
||||
await bridge.setAlwaysOnScreen(true);
|
||||
await bridge.setAlwaysOnScreen(false);
|
||||
|
||||
expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(1, true);
|
||||
expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
test("reports that it has loaded", async () => {
|
||||
const sendContentLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
const bridge = createWidgetHostBridge(mockWidget({ sendContentLoaded }));
|
||||
|
||||
await bridge.contentLoaded();
|
||||
|
||||
expect(sendContentLoaded).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["notifyJoined", ElementWidgetActions.JoinCall, {}],
|
||||
["notifyHungUp", ElementWidgetActions.HangupCall, {}],
|
||||
] as const)("sends %s as %s", async (method, action, payload) => {
|
||||
const transport = mockTransport();
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
await bridge[method]();
|
||||
|
||||
expect(transport.send).toHaveBeenCalledWith(action, payload);
|
||||
});
|
||||
|
||||
test("sends the mute state the host needs to mirror", async () => {
|
||||
const transport = mockTransport();
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
await bridge.notifyDeviceMute({
|
||||
audio_enabled: true,
|
||||
video_enabled: false,
|
||||
});
|
||||
|
||||
expect(transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.DeviceMute,
|
||||
{ audio_enabled: true, video_enabled: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relaying what the host asks for", () => {
|
||||
/** Emits a widget action the way widget.ts does, and returns the event. */
|
||||
function askHost(
|
||||
widget: WidgetHelpers,
|
||||
action: string,
|
||||
data: unknown,
|
||||
): CustomEvent {
|
||||
const ev = new CustomEvent(action, { detail: { action, data } });
|
||||
widget.lazyActions.emit(action, ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
// Selectors rather than keys, so each stream keeps its own request type
|
||||
const inboundStreams: [
|
||||
name: string,
|
||||
select: (bridge: HostBridge) => Observable<{ data: unknown }>,
|
||||
action: string,
|
||||
][] = [
|
||||
[
|
||||
"themeChange$",
|
||||
(bridge) => bridge.themeChange$,
|
||||
WidgetApiToWidgetAction.ThemeChange,
|
||||
],
|
||||
["join$", (bridge) => bridge.join$, ElementWidgetActions.JoinCall],
|
||||
["hangUp$", (bridge) => bridge.hangUp$, ElementWidgetActions.HangupCall],
|
||||
[
|
||||
"deviceMute$",
|
||||
(bridge) => bridge.deviceMute$,
|
||||
ElementWidgetActions.DeviceMute,
|
||||
],
|
||||
];
|
||||
|
||||
test.each(inboundStreams)(
|
||||
"surfaces %s with the host's data",
|
||||
(_name, select, action) => {
|
||||
const widget = mockWidget({ transport: mockTransport() } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
const seen: unknown[] = [];
|
||||
select(bridge).subscribe((request) => seen.push(request.data));
|
||||
|
||||
askHost(widget, action, { some: "payload" });
|
||||
|
||||
expect(seen).toEqual([{ some: "payload" }]);
|
||||
},
|
||||
);
|
||||
|
||||
test("replies to the host against the request it made", () => {
|
||||
const transport = mockTransport();
|
||||
const widget = mockWidget({ transport } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
bridge.deviceMute$.subscribe((request) =>
|
||||
request.reply({ audio_enabled: false, video_enabled: true }),
|
||||
);
|
||||
|
||||
const ev = askHost(widget, ElementWidgetActions.DeviceMute, {
|
||||
audio_enabled: false,
|
||||
});
|
||||
|
||||
expect(transport.reply).toHaveBeenCalledWith(ev.detail, {
|
||||
audio_enabled: false,
|
||||
video_enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("still replies when there is nothing to say", () => {
|
||||
const transport = mockTransport();
|
||||
const widget = mockWidget({ transport } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
bridge.hangUp$.subscribe((request) => request.reply());
|
||||
|
||||
const ev = askHost(widget, ElementWidgetActions.HangupCall, {});
|
||||
|
||||
// The widget API requires an answer, so an empty reply becomes {}
|
||||
expect(transport.reply).toHaveBeenCalledWith(ev.detail, {});
|
||||
});
|
||||
|
||||
test("stops listening once unsubscribed", () => {
|
||||
const widget = mockWidget({ transport: mockTransport() } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
const seen: unknown[] = [];
|
||||
const subscription = bridge.hangUp$.subscribe((r) => seen.push(r.data));
|
||||
|
||||
subscription.unsubscribe();
|
||||
askHost(widget, ElementWidgetActions.HangupCall, {});
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMedia", () => {
|
||||
const mxcUri = "mxc://example.org/alice-avatar";
|
||||
|
||||
test("passes a Blob through unchanged", async () => {
|
||||
const file = new Blob([]);
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file }) }),
|
||||
);
|
||||
|
||||
await expect(bridge.downloadMedia!(mxcUri)).resolves.toBe(file);
|
||||
});
|
||||
|
||||
test("decodes a base64 string into a Blob", async () => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({
|
||||
// "hello" in base64
|
||||
downloadFile: vi.fn().mockResolvedValue({ file: "aGVsbG8=" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const blob = await bridge.downloadMedia!(mxcUri);
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
// The five decoded bytes, rather than the eight characters of base64 —
|
||||
// which is what we'd get if the string were stored verbatim.
|
||||
expect(blob.size).toBe(5);
|
||||
});
|
||||
|
||||
test("rejects a file format it does not understand", async () => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file: 42 }) }),
|
||||
);
|
||||
|
||||
await expect(bridge.downloadMedia!(mxcUri)).rejects.toThrow(
|
||||
"Downloaded file format is not supported",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close", () => {
|
||||
test("asks the host to close, then stops the transport", async () => {
|
||||
const transport = {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
await bridge.close!();
|
||||
|
||||
expect(transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
{},
|
||||
);
|
||||
expect(transport.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("stops the transport even when the host refuses to close", async () => {
|
||||
const transport = {
|
||||
send: vi.fn().mockRejectedValue(new Error("no")),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
// Leaving the messaging live would leave the close affordance dead
|
||||
await expect(bridge.close!()).rejects.toThrow("no");
|
||||
expect(transport.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsReactions", () => {
|
||||
const capabilities = [
|
||||
"org.matrix.msc2762.send.event:m.reaction",
|
||||
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||
"org.matrix.msc2762.receive.event:m.reaction",
|
||||
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||
];
|
||||
|
||||
test("is true when the host grants every reaction capability", () => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ hasCapability: () => true }),
|
||||
);
|
||||
|
||||
expect(bridge.supportsReactions).toBe(true);
|
||||
});
|
||||
|
||||
test.each(capabilities)("is false without %s", (missing) => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ hasCapability: (c) => c !== missing }),
|
||||
);
|
||||
|
||||
expect(bridge.supportsReactions).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("nullHostBridge", () => {
|
||||
test("offers no way to close, so the interface falls back to navigation", () => {
|
||||
expect(nullHostBridge.close).toBeUndefined();
|
||||
});
|
||||
|
||||
test("offers no media download, so Element Call uses its own client", () => {
|
||||
expect(nullHostBridge.downloadMedia).toBeUndefined();
|
||||
});
|
||||
|
||||
test("supports reactions, since nothing is mediating its homeserver access", () => {
|
||||
expect(nullHostBridge.supportsReactions).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
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 { createContext, use } from "react";
|
||||
import { fromEvent, map, NEVER, type Observable } from "rxjs";
|
||||
import {
|
||||
type IWidgetApiRequest,
|
||||
type IWidgetApiRequestData,
|
||||
WidgetApiToWidgetAction,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
type JoinCallData,
|
||||
type WidgetHelpers,
|
||||
} from "./widget";
|
||||
|
||||
// Note: these are type aliases rather than interfaces so that they satisfy the
|
||||
// widget API's index-signature payload types.
|
||||
|
||||
/** The mute state Element Call and its host exchange. */
|
||||
export type DeviceMuteState = {
|
||||
audio_enabled: boolean;
|
||||
video_enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A mute state change requested by the host. An absent field means "leave this
|
||||
* one as it is".
|
||||
*/
|
||||
export type DeviceMuteRequest = {
|
||||
audio_enabled?: boolean;
|
||||
video_enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Something the host has asked of Element Call, which it is expected to
|
||||
* acknowledge.
|
||||
*/
|
||||
export interface HostRequest<Data, Reply = void> {
|
||||
data: Data;
|
||||
/** Acknowledges the request. Should be called exactly once. */
|
||||
reply(reply: Reply): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element Call's view of the application hosting it.
|
||||
*
|
||||
* Element Call can run as its own page, as a widget inside a Matrix client, or
|
||||
* embedded directly into one. Only the last two give it a host, and each of
|
||||
* them reaches it by a different route — so everything Element Call needs from
|
||||
* whatever is hosting it goes through this interface, rather than being
|
||||
* expressed in terms of the widget API.
|
||||
*
|
||||
* This covers the interactions Element Call has with its host while running.
|
||||
* The Matrix client it talks to is supplied separately, at startup.
|
||||
*/
|
||||
export interface HostBridge {
|
||||
// What Element Call tells the host.
|
||||
|
||||
/**
|
||||
* Asks the host to keep Element Call on screen (or stop doing so), so that a
|
||||
* call in progress is not torn down when the user navigates elsewhere.
|
||||
*/
|
||||
setAlwaysOnScreen(alwaysOnScreen: boolean): Promise<void>;
|
||||
/** Tells the host that Element Call has finished loading. */
|
||||
contentLoaded(): Promise<void>;
|
||||
/** Tells the host that the user has joined the call. */
|
||||
notifyJoined(): Promise<void>;
|
||||
/** Tells the host that the user has hung up. */
|
||||
notifyHungUp(): Promise<void>;
|
||||
/** Tells the host the user's current audio and video mute state. */
|
||||
notifyDeviceMute(state: DeviceMuteState): Promise<void>;
|
||||
/**
|
||||
* Asks the host to close Element Call, and stops communicating with it. No
|
||||
* further calls should be made on this bridge afterwards.
|
||||
*
|
||||
* Absent when the host has no way to dismiss Element Call — standalone, the
|
||||
* user navigates away instead — so its presence is what tells the interface
|
||||
* whether to offer a close affordance.
|
||||
*/
|
||||
close?(): Promise<void>;
|
||||
|
||||
// What the host asks of Element Call.
|
||||
|
||||
/** The host has changed the theme Element Call should use. */
|
||||
themeChange$: Observable<HostRequest<{ name?: string }>>;
|
||||
/** The host wants a preloaded Element Call to join the call now. */
|
||||
join$: Observable<HostRequest<JoinCallData>>;
|
||||
/** The host wants Element Call to leave the call. */
|
||||
hangUp$: Observable<HostRequest<Record<string, never>>>;
|
||||
/** The host wants to change, or read back, the device mute state. */
|
||||
deviceMute$: Observable<HostRequest<DeviceMuteRequest, DeviceMuteState>>;
|
||||
|
||||
// What the host is capable of.
|
||||
|
||||
/** Whether the host permits Element Call to send and receive reactions. */
|
||||
readonly supportsReactions: boolean;
|
||||
/**
|
||||
* Fetches media on Element Call's behalf, for hosts that do not give it
|
||||
* direct access to the homeserver. Absent when Element Call should fetch
|
||||
* media itself using its own client.
|
||||
*/
|
||||
downloadMedia?(mxcUri: string): Promise<Blob>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bridge to nowhere, for when Element Call has no host — that is, when it is
|
||||
* running as its own page and talks to the homeserver directly.
|
||||
*/
|
||||
export const nullHostBridge: HostBridge = {
|
||||
setAlwaysOnScreen: async () => {},
|
||||
contentLoaded: async () => {},
|
||||
notifyJoined: async () => {},
|
||||
notifyHungUp: async () => {},
|
||||
notifyDeviceMute: async () => {},
|
||||
themeChange$: NEVER,
|
||||
join$: NEVER,
|
||||
hangUp$: NEVER,
|
||||
deviceMute$: NEVER,
|
||||
// Standalone Element Call reaches the homeserver itself, so nothing is
|
||||
// withholding these from it.
|
||||
supportsReactions: true,
|
||||
};
|
||||
|
||||
/** Bridges to a host that Element Call is a widget of. */
|
||||
export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge {
|
||||
const requests = <Data, Reply>(
|
||||
action: string,
|
||||
): Observable<HostRequest<Data, Reply>> =>
|
||||
(
|
||||
fromEvent(widget.lazyActions, action) as Observable<
|
||||
CustomEvent<IWidgetApiRequest>
|
||||
>
|
||||
).pipe(
|
||||
map((ev) => ({
|
||||
data: ev.detail.data as Data,
|
||||
// The widget API requires a reply for every request, and carries the
|
||||
// payload as a plain object, so an empty reply becomes {}.
|
||||
reply: (reply: Reply): void =>
|
||||
widget.api.transport.reply(ev.detail, reply ?? {}),
|
||||
})),
|
||||
);
|
||||
|
||||
const send = async (
|
||||
action: ElementWidgetActions,
|
||||
data: IWidgetApiRequestData = {},
|
||||
): Promise<void> => {
|
||||
await widget.api.transport.send(action, data);
|
||||
};
|
||||
|
||||
return {
|
||||
setAlwaysOnScreen: async (alwaysOnScreen) => {
|
||||
await widget.api.setAlwaysOnScreen(alwaysOnScreen);
|
||||
},
|
||||
contentLoaded: async () => widget.api.sendContentLoaded(),
|
||||
notifyJoined: async () => send(ElementWidgetActions.JoinCall),
|
||||
notifyHungUp: async () => send(ElementWidgetActions.HangupCall),
|
||||
notifyDeviceMute: async (state) =>
|
||||
send(ElementWidgetActions.DeviceMute, state),
|
||||
close: async () => {
|
||||
try {
|
||||
await send(ElementWidgetActions.Close);
|
||||
} finally {
|
||||
// Stop regardless of whether the host acknowledged the request. A host
|
||||
// that rejects or never answers would otherwise leave the messaging
|
||||
// live, and the close affordance doing nothing at all.
|
||||
widget.api.transport.stop();
|
||||
}
|
||||
},
|
||||
themeChange$: requests(WidgetApiToWidgetAction.ThemeChange),
|
||||
join$: requests(ElementWidgetActions.JoinCall),
|
||||
hangUp$: requests(ElementWidgetActions.HangupCall),
|
||||
deviceMute$: requests(ElementWidgetActions.DeviceMute),
|
||||
// Element Call needs the host's permission to send reactions on its behalf.
|
||||
// Read on access rather than up front: the widget API negotiates its
|
||||
// capabilities asynchronously, and the bridge is built before that settles.
|
||||
get supportsReactions(): boolean {
|
||||
return (
|
||||
widget.api.hasCapability("org.matrix.msc2762.send.event:m.reaction") &&
|
||||
widget.api.hasCapability(
|
||||
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||
) &&
|
||||
widget.api.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.reaction",
|
||||
) &&
|
||||
widget.api.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||
)
|
||||
);
|
||||
},
|
||||
downloadMedia: async (mxcUri) => {
|
||||
const { file } = await widget.api.downloadFile(mxcUri);
|
||||
if (file instanceof Blob) return file;
|
||||
if (typeof file === "string")
|
||||
// it is a base64 string
|
||||
return new Blob([Uint8Array.from(atob(file), (c) => c.charCodeAt(0))]);
|
||||
throw new Error(
|
||||
`Downloaded file format is not supported: ${typeof file}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const HostBridgeContext = createContext<HostBridge | null>(null);
|
||||
|
||||
export const HostBridgeProvider = HostBridgeContext.Provider;
|
||||
|
||||
/**
|
||||
* The application hosting Element Call.
|
||||
*
|
||||
* Defaults to {@link nullHostBridge}, so that tests and stories, which have no
|
||||
* host, need no provider.
|
||||
*/
|
||||
export const useHostBridge = (): HostBridge =>
|
||||
use(HostBridgeContext) ?? nullHostBridge;
|
||||
+4
-2
@@ -24,6 +24,7 @@ import { Heading, Glass } from "@vector-im/compound-web";
|
||||
import styles from "./Modal.module.css";
|
||||
import overlayStyles from "./Overlay.module.css";
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -78,6 +79,7 @@ export const Modal: FC<Props> = ({
|
||||
...rest
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const rootElement = useRootElement();
|
||||
// Empirically, Chrome on Android can end up not matching (hover: none), but
|
||||
// still matching (pointer: coarse) :/
|
||||
const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)");
|
||||
@@ -100,7 +102,7 @@ export const Modal: FC<Props> = ({
|
||||
onOpenChange={onOpenChange}
|
||||
dismissible={onDismiss !== undefined}
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Portal container={rootElement}>
|
||||
<Drawer.Overlay className={classNames(overlayStyles.bg)} />
|
||||
<Drawer.Content
|
||||
className={classNames(
|
||||
@@ -155,7 +157,7 @@ export const Modal: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogPortal container={rootElement}>
|
||||
<DialogOverlay
|
||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
||||
/>
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { toDataURL } from "qrcode";
|
||||
import classNames from "classnames";
|
||||
import { t } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./QrCode.module.css";
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const QrCode: FC<Props> = ({ data, className }) => {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+1
-6
@@ -10,7 +10,6 @@ import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { ErrorView } from "./ErrorView";
|
||||
import { widget } from "./widget.ts";
|
||||
|
||||
/**
|
||||
* An error consisting of a terse message to be logged to the console and a
|
||||
@@ -32,11 +31,7 @@ const OpenElsewhere: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={PopOutIcon}
|
||||
title={t("error.open_elsewhere")}
|
||||
>
|
||||
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}>
|
||||
<p>
|
||||
{t("error.open_elsewhere_description", {
|
||||
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
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 { createContext, use } from "react";
|
||||
|
||||
/**
|
||||
* The element that Element Call treats as the root of its own interface.
|
||||
*
|
||||
* Element Call decorates this element with the theme, layout and background
|
||||
* attributes its stylesheets key off, and portals its modals into it. When
|
||||
* Element Call owns the page this is simply the document body; the intent is
|
||||
* that when embedded in a host application it becomes the container the host
|
||||
* mounted it into, so that Element Call does not reach outside its own subtree.
|
||||
*
|
||||
* That intent is not yet achievable: several selectors still name `body`
|
||||
* directly — `body[data-background="gradient"]` and `body[data-platform=…]` in
|
||||
* `index.css`, and `body[data-platform="ios"]` in `AppBar.module.css` and
|
||||
* `Modal.module.css` — and `Initializer.initBeforeReact` writes
|
||||
* `data-platform` straight onto the body. So anything other than the body will
|
||||
* be decorated correctly and styled incorrectly, silently. Until those are
|
||||
* scoped, treat this as preparation rather than a working seam.
|
||||
*/
|
||||
// No provider is exported yet: nothing supplies a root element, so every
|
||||
// consumer falls back to the document body. M1 adds one along with the
|
||||
// component that mounts Element Call into a container.
|
||||
const RootElementContext = createContext<HTMLElement | null>(null);
|
||||
|
||||
/**
|
||||
* The element Element Call should decorate and portal into.
|
||||
*
|
||||
* Defaults to the document body, so that the standalone and widget builds work
|
||||
* without a provider.
|
||||
*/
|
||||
export const useRootElement = (): HTMLElement =>
|
||||
use(RootElementContext) ?? document.body;
|
||||
+14
-1
@@ -31,6 +31,11 @@ interface Props {
|
||||
max: number;
|
||||
step: number;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Custom formatter for the tooltip label. If not provided, the value is
|
||||
* displayed as a percentage.
|
||||
*/
|
||||
tooltipFormatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +51,7 @@ export const Slider: FC<Props> = ({
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
tooltipFormatter,
|
||||
}) => {
|
||||
const onValueChange = useCallback(
|
||||
([v]: number[]) => onValueChangeProp(v),
|
||||
@@ -71,7 +77,14 @@ export const Slider: FC<Props> = ({
|
||||
<Range className={styles.highlight} />
|
||||
</Track>
|
||||
{/* Note: This is expected not to be visible on mobile.*/}
|
||||
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
label={
|
||||
tooltipFormatter
|
||||
? tooltipFormatter(value)
|
||||
: Math.round(value * 100).toString() + "%"
|
||||
}
|
||||
>
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
</Tooltip>
|
||||
</Root>
|
||||
|
||||
+7
-1
@@ -25,6 +25,7 @@ import { Text } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./Toast.module.css";
|
||||
import overlayStyles from "./Overlay.module.css";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -64,6 +65,7 @@ export const Toast: FC<Props> = ({
|
||||
Icon,
|
||||
modal = true,
|
||||
}) => {
|
||||
const rootElement = useRootElement();
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) onDismiss();
|
||||
@@ -104,7 +106,11 @@ export const Toast: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
|
||||
{modal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||
{modal ? (
|
||||
<DialogPortal container={rootElement}>{content}</DialogPortal>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</DialogRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,12 +16,15 @@ import {
|
||||
HeaderStyle,
|
||||
getUrlParams,
|
||||
} from "../src/UrlParams";
|
||||
import { mockConfig } from "./utils/test";
|
||||
|
||||
const ROOM_NAME = "roomNameHere";
|
||||
const ROOM_ID = "!d45f138fsd";
|
||||
const ORIGIN = "https://call.element.io";
|
||||
const HOMESERVER = "localhost";
|
||||
|
||||
mockConfig();
|
||||
|
||||
describe("UrlParams", () => {
|
||||
describe("handles URL with /room/", () => {
|
||||
it("and nothing else", () => {
|
||||
|
||||
+41
-4
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
@@ -59,6 +59,17 @@ export interface UrlProperties {
|
||||
// Widget api related params
|
||||
widgetId: string | null;
|
||||
parentUrl: string | null;
|
||||
/**
|
||||
* Whether Element Call was started as a widget of a Matrix client, which is
|
||||
* to say whether it was given a widget ID and a parent to talk to.
|
||||
*
|
||||
* Only meaningful to the standalone and widget builds, which own the URL —
|
||||
* so use it for decisions that belong to the app shell, such as whether
|
||||
* Element Call is responsible for authenticating the user. Anything the call
|
||||
* interface itself needs to know about its host should come from the host
|
||||
* bridge instead.
|
||||
*/
|
||||
isWidget: boolean;
|
||||
/**
|
||||
* Anything about what room we're pointed to should be from useRoomIdentifier which
|
||||
* parses the path and resolves alias with respect to the default server name, however
|
||||
@@ -448,6 +459,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
const properties: UrlProperties = {
|
||||
widgetId,
|
||||
parentUrl,
|
||||
isWidget,
|
||||
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
|
||||
// what would we do if it were invalid? If the widget API says that's what
|
||||
// the room ID is, then that's what it is.
|
||||
@@ -519,11 +531,36 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
};
|
||||
};
|
||||
|
||||
const UrlParamsContext = createContext<UrlParams | null>(null);
|
||||
|
||||
/**
|
||||
* Hook to simplify use of getUrlParams.
|
||||
* @returns The app parameters for the current URL
|
||||
* Supplies the parameters Element Call should run with.
|
||||
*
|
||||
* The standalone and widget builds derive these from the URL, but an embedder
|
||||
* has no URL of its own to put them in, so it provides them directly instead.
|
||||
*
|
||||
* TODO: `UrlParams` is no longer an accurate name now that these need not come
|
||||
* from a URL. Renaming it touches every consumer, so it is left until the rest
|
||||
* of the de-globalisation work has settled.
|
||||
*/
|
||||
export const useUrlParams = (): UrlParams => {
|
||||
export const UrlParamsProvider = UrlParamsContext.Provider;
|
||||
|
||||
/**
|
||||
* The parameters Element Call is running with.
|
||||
*
|
||||
* Falls back to parsing `window.location` when no provider is present, so that
|
||||
* tests and stories keep working without one.
|
||||
*/
|
||||
export const useUrlParams = (): UrlParams =>
|
||||
use(UrlParamsContext) ?? getUrlParams();
|
||||
|
||||
/**
|
||||
* Derives {@link UrlParams} from the current router location.
|
||||
*
|
||||
* Only meaningful when Element Call owns the URL; embedders provide the params
|
||||
* directly through {@link UrlParamsProvider}.
|
||||
*/
|
||||
export const useUrlParamsFromLocation = (): UrlParams => {
|
||||
const { search, hash } = useLocation();
|
||||
return useMemo(() => getUrlParams(search, hash), [search, hash]);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
import posthog, { type CaptureResult } from "posthog-js";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import {
|
||||
Anonymity,
|
||||
@@ -22,75 +23,122 @@ import {
|
||||
PosthogAnalytics,
|
||||
} from "./PosthogAnalytics";
|
||||
import { mockConfig } from "../utils/test";
|
||||
import { analyticsConfigFromEnvironment } from "../initializer";
|
||||
import { optInAnalytics } from "../settings/settings";
|
||||
|
||||
describe("PosthogAnalytics", () => {
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
|
||||
describe("enablement", () => {
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value or URL params", () => {
|
||||
it("stays off until it is configured", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores config value and does not create instance", () => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
it("stays off when configured without credentials", () => {
|
||||
PosthogAnalytics.configure({ matrixBackend: "jssdk" });
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("stays off when given only a key", () => {
|
||||
PosthogAnalytics.configure({
|
||||
matrixBackend: "jssdk",
|
||||
apiKey: "api_key",
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses URL params if both set", () => {
|
||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
||||
it("turns on when given both a key and a host", () => {
|
||||
PosthogAnalytics.configure({
|
||||
matrixBackend: "jssdk",
|
||||
apiKey: "api_key",
|
||||
apiHost: "https://api.example.com.localhost",
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
// Which of the URL and config.json the credentials come from is a deliberate
|
||||
// policy: an embedder is responsible for its own users' telemetry, so it must
|
||||
// not pick up the deployment's, and vice versa.
|
||||
describe("analyticsConfigFromEnvironment", () => {
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
const urlCredentials = `posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=url_key`;
|
||||
const configCredentials = {
|
||||
posthog: {
|
||||
api_host: "https://config.example.com.localhost",
|
||||
api_key: "config_key",
|
||||
},
|
||||
};
|
||||
|
||||
it("ignores URL params and does not create instance", () => {
|
||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("creates instance with config value", () => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
|
||||
it("has no credentials without URL params", () => {
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes the credentials from the URL", () => {
|
||||
window.location.hash = `#?${urlCredentials}`;
|
||||
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||
apiKey: "url_key",
|
||||
apiHost: "https://url.example.com.localhost",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the deployment's config", () => {
|
||||
mockConfig(configCredentials);
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
it("has no credentials without config", () => {
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes the credentials from the config", () => {
|
||||
mockConfig(configCredentials);
|
||||
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||
apiKey: "config_key",
|
||||
apiHost: "https://config.example.com.localhost",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the URL params", () => {
|
||||
window.location.hash = `#?${urlCredentials}`;
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Who owns the user's analytics identity depends on how Element Call is
|
||||
// running, not on which package it was built as.
|
||||
it("reports the embedded backend when running as a widget", () => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
window.location.hash = `#?widgetId=id&parentUrl=${encodeURIComponent("https://host.example.com.localhost")}&posthogUserId=given_id`;
|
||||
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||
matrixBackend: "embedded",
|
||||
hostAnalyticsId: "given_id",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the jssdk backend when running standalone", () => {
|
||||
expect(analyticsConfigFromEnvironment().matrixBackend).toBe("jssdk");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,22 +252,13 @@ describe("PosthogAnalytics", () => {
|
||||
// posthog-js bumps renaming/removing the hook. The filter logic itself is
|
||||
// covered by the applyPrivacyFilters block above.
|
||||
describe("posthog.init wiring", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
});
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
PosthogAnalytics.configure({
|
||||
matrixBackend: "jssdk",
|
||||
apiKey: "api_key",
|
||||
apiHost: "https://api.example.com.localhost",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes events through the privacy filter via before_send", () => {
|
||||
@@ -244,3 +283,86 @@ describe("PosthogAnalytics", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("identifying the user", () => {
|
||||
const credentials = {
|
||||
apiKey: "api_key",
|
||||
apiHost: "https://api.example.com.localhost",
|
||||
};
|
||||
|
||||
function mockClient(accountDataId: string | null): MatrixClient {
|
||||
return {
|
||||
isGuest: () => false,
|
||||
getCrypto: () => undefined,
|
||||
getAccountDataFromServer: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
accountDataId === null ? null : { id: accountDataId },
|
||||
),
|
||||
setAccountData: vi.fn().mockResolvedValue({}),
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
PosthogAnalytics.resetInstance();
|
||||
optInAnalytics.setValue(true);
|
||||
});
|
||||
|
||||
it("reports under the ID its host assigned, and stores nothing", async () => {
|
||||
const client = mockClient(null);
|
||||
window.matrixclient = client;
|
||||
PosthogAnalytics.configure({
|
||||
...credentials,
|
||||
matrixBackend: "embedded",
|
||||
hostAnalyticsId: "assigned-by-host",
|
||||
});
|
||||
const identify = vi.spyOn(posthog, "identify");
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
await vi.waitFor(() =>
|
||||
expect(identify).toHaveBeenCalledWith("assigned-by-host"),
|
||||
);
|
||||
|
||||
// The host owns the user's account, so Element Call must not write to it
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps its own ID in account data when it owns the session", async () => {
|
||||
const client = mockClient(null);
|
||||
window.matrixclient = client;
|
||||
PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" });
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
|
||||
// No ID on the server yet, so one is minted and stored for other devices
|
||||
await vi.waitFor(() => expect(client.setAccountData).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("reuses the ID already in account data", async () => {
|
||||
const client = mockClient("stored-earlier");
|
||||
window.matrixclient = client;
|
||||
PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" });
|
||||
const identify = vi.spyOn(posthog, "identify");
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
await vi.waitFor(() =>
|
||||
expect(identify).toHaveBeenCalledWith("stored-earlier"),
|
||||
);
|
||||
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records how it reaches Matrix as a super property", async () => {
|
||||
window.matrixclient = mockClient("stored-earlier");
|
||||
PosthogAnalytics.configure({ ...credentials, matrixBackend: "embedded" });
|
||||
const register = vi.spyOn(posthog, "register");
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ matrixBackend: "embedded" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Subscription } from "rxjs";
|
||||
|
||||
import { widget } from "../widget";
|
||||
import {
|
||||
CallEndedTracker,
|
||||
CallStartedTracker,
|
||||
@@ -29,8 +28,6 @@ import {
|
||||
CallConnectDurationTracker,
|
||||
CallReconnectingTracker,
|
||||
} from "./PosthogEvents";
|
||||
import { Config } from "../config/Config";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { optInAnalytics } from "../settings/settings";
|
||||
|
||||
/* Posthog analytics tracking.
|
||||
@@ -140,6 +137,27 @@ interface PlatformProperties {
|
||||
cryptoVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How analytics reporting should be set up, supplied by whoever is starting
|
||||
* Element Call rather than discovered from the page it happens to be on.
|
||||
*/
|
||||
export interface AnalyticsConfig {
|
||||
/** The PostHog project key. Without one, analytics stay switched off. */
|
||||
apiKey?: string;
|
||||
apiHost?: string;
|
||||
/**
|
||||
* How Element Call reaches Matrix. When `embedded`, the host owns the user's
|
||||
* identity: it supplies the analytics ID, and Element Call must not store one
|
||||
* in the user's account data.
|
||||
*/
|
||||
matrixBackend: "embedded" | "jssdk";
|
||||
/** The analytics ID the host has assigned to this user, when embedded. */
|
||||
hostAnalyticsId?: string | null;
|
||||
}
|
||||
|
||||
/** Analytics are off until someone asks for them. */
|
||||
const analyticsDisabled: AnalyticsConfig = { matrixBackend: "jssdk" };
|
||||
|
||||
export class PosthogAnalytics {
|
||||
/* Wrapper for Posthog analytics.
|
||||
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||
@@ -167,13 +185,32 @@ export class PosthogAnalytics {
|
||||
private registrationType: RegistrationType = RegistrationType.Guest;
|
||||
private optInListener: Subscription | null = null;
|
||||
|
||||
private static analyticsConfig: AnalyticsConfig = analyticsDisabled;
|
||||
|
||||
/**
|
||||
* Sets up analytics reporting. Must be called before the instance is first
|
||||
* used; without it, analytics stay switched off.
|
||||
*/
|
||||
public static configure(config: AnalyticsConfig): void {
|
||||
if (this.internalInstance)
|
||||
// Configuration is read once, when the instance is built, so arriving
|
||||
// late means analytics are already running unconfigured.
|
||||
logger.warn(
|
||||
"Analytics were configured after they had already been started; the new configuration will not take effect",
|
||||
);
|
||||
this.analyticsConfig = config;
|
||||
}
|
||||
|
||||
public static hasInstance(): boolean {
|
||||
return Boolean(this.internalInstance);
|
||||
}
|
||||
|
||||
public static get instance(): PosthogAnalytics {
|
||||
if (!this.internalInstance) {
|
||||
this.internalInstance = new PosthogAnalytics(posthog);
|
||||
this.internalInstance = new PosthogAnalytics(
|
||||
posthog,
|
||||
PosthogAnalytics.analyticsConfig,
|
||||
);
|
||||
}
|
||||
return this.internalInstance;
|
||||
}
|
||||
@@ -181,20 +218,14 @@ export class PosthogAnalytics {
|
||||
public static resetInstance(): void {
|
||||
// Reset the singleton instance
|
||||
this.internalInstance = null;
|
||||
this.analyticsConfig = analyticsDisabled;
|
||||
}
|
||||
|
||||
private constructor(private readonly posthog: PostHog) {
|
||||
let apiKey: string | undefined;
|
||||
let apiHost: string | undefined;
|
||||
if (import.meta.env.VITE_PACKAGE === "embedded") {
|
||||
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
|
||||
apiKey = getUrlParams().posthogApiKey ?? undefined;
|
||||
apiHost = getUrlParams().posthogApiHost ?? undefined;
|
||||
} else if (import.meta.env.VITE_PACKAGE === "full") {
|
||||
// in full package it is the server responsible for the analytics
|
||||
apiKey = Config.get().posthog?.api_key;
|
||||
apiHost = Config.get().posthog?.api_host;
|
||||
}
|
||||
private constructor(
|
||||
private readonly posthog: PostHog,
|
||||
private readonly config: AnalyticsConfig,
|
||||
) {
|
||||
const { apiKey, apiHost } = config;
|
||||
|
||||
if (apiKey && apiHost) {
|
||||
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
|
||||
@@ -225,15 +256,15 @@ export class PosthogAnalytics {
|
||||
}
|
||||
}
|
||||
|
||||
private static getPlatformProperties(): PlatformProperties {
|
||||
private getPlatformProperties(): PlatformProperties {
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
return {
|
||||
appVersion,
|
||||
matrixBackend: widget ? "embedded" : "jssdk",
|
||||
matrixBackend: this.config.matrixBackend,
|
||||
callBackend: "livekit",
|
||||
cryptoVersion: widget
|
||||
? undefined
|
||||
: window.matrixclient?.getCrypto()?.getVersion(),
|
||||
// Undefined when Element Call has no crypto of its own, which is the case
|
||||
// whenever a host is doing the encrypting for it.
|
||||
cryptoVersion: window.matrixclient?.getCrypto()?.getVersion(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,8 +314,8 @@ export class PosthogAnalytics {
|
||||
// different devices to send the same ID.
|
||||
let analyticsID = await this.getAnalyticsId();
|
||||
try {
|
||||
if (!analyticsID && !widget) {
|
||||
// only try setting up a new analytics ID in the standalone app.
|
||||
if (!analyticsID && this.config.matrixBackend !== "embedded") {
|
||||
// only mint an analytics ID when we are the ones storing it.
|
||||
|
||||
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
|
||||
// Note there's a race condition here - if two devices do these steps at the same time, last write
|
||||
@@ -313,8 +344,8 @@ export class PosthogAnalytics {
|
||||
|
||||
private async getAnalyticsId(): Promise<string | null> {
|
||||
const client: MatrixClient = window.matrixclient;
|
||||
if (widget) {
|
||||
return getUrlParams().posthogUserId;
|
||||
if (this.config.matrixBackend === "embedded") {
|
||||
return this.config.hostAnalyticsId ?? null;
|
||||
} else {
|
||||
const accountData = await client.getAccountDataFromServer(
|
||||
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
||||
@@ -324,7 +355,7 @@ export class PosthogAnalytics {
|
||||
}
|
||||
|
||||
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
||||
if (!widget) {
|
||||
if (this.config.matrixBackend !== "embedded") {
|
||||
const client = window.matrixclient;
|
||||
|
||||
// the analytics ID only needs to be set in the standalone version.
|
||||
@@ -362,7 +393,7 @@ export class PosthogAnalytics {
|
||||
// These properties will be subsequently passed in every event.
|
||||
//
|
||||
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
||||
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties();
|
||||
this.platformSuperProperties = this.getPlatformProperties();
|
||||
this.registerSuperProperties({
|
||||
...this.platformSuperProperties,
|
||||
registrationType:
|
||||
|
||||
@@ -17,7 +17,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { type Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { widget } from "../widget";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export const useInteractiveRegistration = (
|
||||
oldClient?: MatrixClient,
|
||||
@@ -32,6 +32,7 @@ export const useInteractiveRegistration = (
|
||||
passwordlessUser: boolean,
|
||||
) => Promise<[MatrixClient, Session]>;
|
||||
} => {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -47,7 +48,7 @@ export const useInteractiveRegistration = (
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (widget) return;
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
authClient.current!.registerRequest({}).catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
@@ -55,7 +56,7 @@ export const useInteractiveRegistration = (
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, []);
|
||||
}, [isWidget]);
|
||||
|
||||
const register = useCallback(
|
||||
async (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useClient } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { widget } from "../widget";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
interface UseRegisterPasswordlessUserType {
|
||||
privacyPolicyUrl?: string;
|
||||
@@ -22,6 +22,7 @@ interface UseRegisterPasswordlessUserType {
|
||||
|
||||
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
const { setClient } = useClient();
|
||||
const { isWidget } = useUrlParams();
|
||||
const { privacyPolicyUrl, recaptchaKey, register } =
|
||||
useInteractiveRegistration();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
@@ -31,7 +32,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
if (!setClient) {
|
||||
throw new Error("No client context");
|
||||
}
|
||||
if (widget) {
|
||||
if (isWidget) {
|
||||
throw new Error(
|
||||
"Registration was skipped: We should never try to register password-less user in embedded mode.",
|
||||
);
|
||||
@@ -53,7 +54,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[execute, reset, register, setClient],
|
||||
[execute, reset, register, setClient, isWidget],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
||||
|
||||
@@ -5,10 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.endCall > svg {
|
||||
color: var(--stopgap-color-on-solid-accent);
|
||||
}
|
||||
|
||||
.rotate > svg {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
+2
-11
@@ -135,21 +135,12 @@ interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
export const EndCallButton: FC<EndCallButtonProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip label={t("hangup_button_label")}>
|
||||
<CpdButton
|
||||
className={classNames(className, styles.endCall)}
|
||||
iconOnly
|
||||
Icon={EndCallIcon}
|
||||
destructive
|
||||
{...props}
|
||||
/>
|
||||
<CpdButton iconOnly Icon={EndCallIcon} destructive {...props} />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,10 +16,12 @@ import inCallViewStyles from "../room/InCallView.module.css";
|
||||
import { useStaticViewModel } from "../state/ViewModel";
|
||||
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
||||
|
||||
// consts for tests
|
||||
const reactionIdentifier = "@user:example.com:DEVICE";
|
||||
const reactionData = {
|
||||
@@ -27,11 +29,14 @@ const reactionData = {
|
||||
reactions$: new BehaviorSubject({}),
|
||||
};
|
||||
|
||||
const mediaDevices = new MediaDevices(globalScope);
|
||||
const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 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.
|
||||
@@ -40,11 +45,18 @@ const mediaDevices = new MediaDevices(globalScope);
|
||||
*/
|
||||
function CallFooterStoryWrapper({
|
||||
children,
|
||||
layout,
|
||||
setLayout,
|
||||
...vmSnapshot
|
||||
}: FooterSnapshot & {
|
||||
}: Omit<FooterSnapshot, "layoutSwitchVm"> & {
|
||||
children?: false | JSX.Element | JSX.Element[] | undefined;
|
||||
layout: LayoutMode | null;
|
||||
setLayout: (value: LayoutMode) => void;
|
||||
}): ReactNode {
|
||||
const vm = useStaticViewModel(vmSnapshot);
|
||||
const vm = useStaticViewModel({
|
||||
...vmSnapshot,
|
||||
layoutSwitchVm: layout && { layout$: constant(layout), setLayout },
|
||||
});
|
||||
return (
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<div className={inCallViewStyles.inRoom}>
|
||||
@@ -62,28 +74,50 @@ function CallFooterStoryWrapper({
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
component: CallFooterStoryWrapper,
|
||||
} satisfies Meta<typeof CallFooterStoryWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const fnArgType = {
|
||||
control: { type: "select" as const },
|
||||
options: ["MockedCallback", "undefined"],
|
||||
mapping: { MockedCallback: fn(), undefined: undefined },
|
||||
};
|
||||
|
||||
const meta = {
|
||||
component: CallFooterStoryWrapper,
|
||||
argTypes: {
|
||||
layout: {
|
||||
control: "radio",
|
||||
options: ["grid", "spotlight"] satisfies LayoutMode[],
|
||||
},
|
||||
audioOutputSwitcher: {
|
||||
control: "select",
|
||||
options: ["NoOutputCallback", "speaker", "earpiece"],
|
||||
table: { defaultValue: { summary: "NoOutputCallback" } },
|
||||
mapping: {
|
||||
NoOutputCallback: undefined,
|
||||
// This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one.
|
||||
speaker: { targetOutput: "earpiece", switch: fn() },
|
||||
earpiece: { targetOutput: "speaker", switch: fn() },
|
||||
},
|
||||
},
|
||||
toggleScreenSharing: fnArgType,
|
||||
openSettings: fnArgType,
|
||||
toggleAudio: fnArgType,
|
||||
toggleVideo: fnArgType,
|
||||
hangup: fnArgType,
|
||||
},
|
||||
} satisfies Meta<typeof CallFooterStoryWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
showLogo: false,
|
||||
layoutMode: "grid",
|
||||
layout: "grid",
|
||||
setLayout: fn(),
|
||||
audioEnabled: true,
|
||||
audioBusy: false,
|
||||
videoEnabled: true,
|
||||
videoBusy: false,
|
||||
setLayoutMode: fn(),
|
||||
openSettings: fn(),
|
||||
toggleAudio: fn(),
|
||||
toggleVideo: fn(),
|
||||
@@ -95,6 +129,7 @@ export const Default: Story = {
|
||||
showFooter: true,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
@@ -111,29 +146,6 @@ export const Default: Story = {
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
argTypes: {
|
||||
layoutMode: {
|
||||
control: "radio",
|
||||
options: ["grid", "spotlight"] satisfies GridMode[],
|
||||
},
|
||||
audioOutputSwitcher: {
|
||||
control: "select",
|
||||
options: ["NoOutputCallback", "speaker", "earpiece"],
|
||||
table: { defaultValue: { summary: "NoOutputCallback" } },
|
||||
mapping: {
|
||||
NoOutputCallback: undefined,
|
||||
// This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one.
|
||||
speaker: { targetOutput: "earpiece", switch: fn() },
|
||||
earpiece: { targetOutput: "speaker", switch: fn() },
|
||||
},
|
||||
},
|
||||
toggleScreenSharing: fnArgType,
|
||||
setLayoutMode: fnArgType,
|
||||
openSettings: fnArgType,
|
||||
toggleAudio: fnArgType,
|
||||
toggleVideo: fnArgType,
|
||||
hangup: fnArgType,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudioAndVideoOptions: Story = {
|
||||
@@ -194,7 +206,7 @@ export const AudioVideoEnabled: Story = {
|
||||
|
||||
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
|
||||
await userEvent.click(spotlightRadio);
|
||||
await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight");
|
||||
await expect(args.setLayout).toHaveBeenCalledWith("spotlight");
|
||||
|
||||
const micButtonMute = canvas.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
@@ -225,14 +237,14 @@ export const SpotlightMode: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
layoutMode: "spotlight",
|
||||
layout: "spotlight",
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
|
||||
await userEvent.click(spotlightRadio);
|
||||
await expect(args.setLayoutMode).toHaveBeenCalledWith("grid");
|
||||
await expect(args.setLayout).toHaveBeenCalledWith("grid");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -264,7 +276,7 @@ export const Pip: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
buttonSize: "md",
|
||||
layoutMode: undefined,
|
||||
layout: null,
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -348,7 +360,7 @@ export const Lobby: Story = {
|
||||
...Default.args,
|
||||
showLogo: false,
|
||||
openSettings: undefined,
|
||||
setLayoutMode: undefined,
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
parameters: {
|
||||
@@ -362,7 +374,7 @@ export const LobbyMobile: Story = {
|
||||
...Default.args,
|
||||
showLogo: false,
|
||||
|
||||
setLayoutMode: undefined,
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
globals: {
|
||||
@@ -379,7 +391,7 @@ export const LobbyRecentButton: Story = {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
showLogo: false,
|
||||
setLayoutMode: undefined,
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
parameters: {
|
||||
@@ -393,7 +405,7 @@ export const LobbyRecentButtonMobile: Story = {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
showLogo: false,
|
||||
setLayoutMode: undefined,
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
globals: {
|
||||
|
||||
@@ -7,12 +7,6 @@ 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 {
|
||||
SpotlightIcon,
|
||||
GridIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { Switch } from "@vector-im/compound-web";
|
||||
import { t } from "i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -28,13 +22,15 @@ import {
|
||||
type ReactionData,
|
||||
} from "../button";
|
||||
import styles from "./CallFooter.module.css";
|
||||
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||
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";
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
@@ -61,8 +57,6 @@ export interface FooterActions {
|
||||
/** Also controls if the videoMute button is disabled */
|
||||
toggleVideo: (() => void) | undefined;
|
||||
toggleBlur: (() => void) | undefined;
|
||||
/** Also controls if the layout button is visible */
|
||||
setLayoutMode: ((mode: GridMode) => void) | undefined;
|
||||
toggleScreenSharing: (() => void) | undefined;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings: (() => void) | undefined;
|
||||
@@ -83,11 +77,13 @@ export interface FooterState {
|
||||
/** The footer should be used as an overlay.
|
||||
* (Over the Call Grid) This saves spaces on small screens. */
|
||||
asOverlay: boolean;
|
||||
showModals: boolean;
|
||||
|
||||
buttonSize: "md" | "lg";
|
||||
showLogo: boolean;
|
||||
|
||||
layoutMode: GridMode | undefined;
|
||||
/** Also controls if the layout switch is visible */
|
||||
layoutSwitchVm: LayoutSwitchViewModel | null;
|
||||
|
||||
sharingScreen: boolean;
|
||||
|
||||
@@ -126,8 +122,8 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
const layoutMode = useBehavior(vm.layoutMode$);
|
||||
const setLayoutMode = useBehavior(vm.setLayoutMode$);
|
||||
const showModals = useBehavior(vm.showModals$);
|
||||
const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
|
||||
const openSettings = useBehavior(vm.openSettings$);
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const audioBusy = useBehavior(vm.audioBusy$);
|
||||
@@ -142,7 +138,6 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
const hangup = useBehavior(vm.hangup$);
|
||||
const debugTileLayout = useBehavior(vm.debugTileLayout$);
|
||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||
const videoOptions = useBehavior(vm.videoOptions$);
|
||||
const selectedVideo = useBehavior(vm.selectedVideo$);
|
||||
const audioOptions = useBehavior(vm.audioOptions$);
|
||||
@@ -242,7 +237,8 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (reactionIdentifier && reactionData) {
|
||||
// Reaction button contains a pretty large menu, so treat it like a modal
|
||||
if (reactionIdentifier && reactionData && showModals) {
|
||||
buttons.push(
|
||||
<ReactionToggleButton
|
||||
size={buttonSize}
|
||||
@@ -290,7 +286,9 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
|
||||
{debugTileLayout ? (
|
||||
<TilesDebugInfo generation$={vm.tileStoreGeneration$} />
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -317,21 +315,20 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
{(showLogo || debugTileLayout) && logoDebugContainer}
|
||||
</div>
|
||||
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
|
||||
{!hideControls && setLayoutMode && layoutMode && (
|
||||
<Switch<"spotlight", "grid">
|
||||
name="layoutMode"
|
||||
aria-label={t("layout_switch_label")}
|
||||
leftLabel={t("layout_spotlight_label")}
|
||||
leftValue="spotlight"
|
||||
leftIcon={SpotlightIcon}
|
||||
rightLabel={t("layout_grid_label")}
|
||||
rightValue="grid"
|
||||
rightIcon={GridIcon}
|
||||
className={styles.layout}
|
||||
value={layoutMode}
|
||||
onChange={setLayoutMode}
|
||||
/>
|
||||
{!hideControls && layoutSwitchVm && (
|
||||
<LayoutSwitch vm={layoutSwitchVm} className={styles.layout} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TilesDebugInfoProps {
|
||||
generation$: Behavior<number | undefined>;
|
||||
}
|
||||
|
||||
// Isolated in its own component since the layout generation updates frequently
|
||||
// and we can avoid re-rendering the footer this way
|
||||
const TilesDebugInfo: FC<TilesDebugInfoProps> = ({ generation$ }) => {
|
||||
const generation = useBehavior(generation$);
|
||||
return `Tiles generation: ${generation}`;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Alignment, Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import type { DeviceLabel } from "../state/MediaDevices";
|
||||
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
@@ -49,6 +50,7 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
reactions$: constant({}),
|
||||
tileStoreGeneration$: constant(0),
|
||||
showFooter$: constant(true),
|
||||
showModals$: constant(true),
|
||||
settingsOpen$: constant(false),
|
||||
setSettingsOpen$: constant(() => {}),
|
||||
} as unknown as CallViewModel;
|
||||
@@ -104,6 +106,7 @@ describe("createCallFooterViewModel", () => {
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$.value).toEqual([]);
|
||||
@@ -125,6 +128,7 @@ describe("createCallFooterViewModel", () => {
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$?.value).toEqual([
|
||||
|
||||
@@ -19,7 +19,7 @@ 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 { getUrlParams, HeaderStyle } from "../UrlParams";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { type FooterSnapshot } from "./CallFooter";
|
||||
|
||||
@@ -138,6 +138,8 @@ function buildDeviceBehaviors(
|
||||
* @param mediaDevices - Available and selected input devices.
|
||||
* @param reactionIdentifier - The local user's reaction identifier string, or
|
||||
* undefined when reactions are not supported (hides the reaction button).
|
||||
* @param options - `showControls`: whether the call controls should be shown.
|
||||
* `header`: the style of header, which decides whether to show the logo.
|
||||
*/
|
||||
export function createCallFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
@@ -145,8 +147,9 @@ export function createCallFooterViewModel(
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
reactionIdentifier: string | undefined,
|
||||
options: { showControls: boolean; header: HeaderStyle },
|
||||
): ViewModel<FooterSnapshot> {
|
||||
const { showControls, header: headerStyle } = getUrlParams();
|
||||
const { showControls, header: headerStyle } = options;
|
||||
const showLogo = headerStyle === HeaderStyle.Standard;
|
||||
|
||||
const isPip$ = scope.behavior(
|
||||
@@ -161,6 +164,7 @@ export function createCallFooterViewModel(
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
hideControls$: constant(!showControls),
|
||||
showModals$: callModel.showModals$,
|
||||
asOverlay$: callModel.edgeToEdge$,
|
||||
buttonSize$: scope.behavior(
|
||||
isPip$.pipe(map<boolean, "md" | "lg">((pip) => (pip ? "md" : "lg"))),
|
||||
@@ -168,12 +172,12 @@ export function createCallFooterViewModel(
|
||||
|
||||
openSettings$: scope.behavior(
|
||||
combineLatest([
|
||||
isPip$,
|
||||
callModel.showModals$,
|
||||
callModel.showHeader$,
|
||||
callModel.setSettingsOpen$,
|
||||
]).pipe(
|
||||
map(([isPip, showHeader, setSettingsOpen]) =>
|
||||
!isPip && headerStyle !== HeaderStyle.AppBar && showControls
|
||||
map(([showModals, showHeader, setSettingsOpen]) =>
|
||||
showModals && headerStyle !== HeaderStyle.AppBar && showControls
|
||||
? (): void => setSettingsOpen(true)
|
||||
: undefined,
|
||||
),
|
||||
@@ -182,14 +186,7 @@ export function createCallFooterViewModel(
|
||||
|
||||
showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))),
|
||||
|
||||
layoutMode$: callModel.gridMode$,
|
||||
setLayoutMode$: scope.behavior(
|
||||
isPip$.pipe(
|
||||
map((isPip) =>
|
||||
!isPip && showControls ? callModel.setGridMode : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
layoutSwitchVm$: callModel.layoutSwitchVm$,
|
||||
|
||||
sharingScreen$: callModel.sharingScreen$,
|
||||
toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined),
|
||||
@@ -246,21 +243,20 @@ export function createLobbyFooterViewModel(
|
||||
showLogo,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
buttonSize: "lg",
|
||||
showLayoutSwitcher: false,
|
||||
openSettings,
|
||||
hangup,
|
||||
debugTileLayout: false,
|
||||
showFooter: true,
|
||||
toggleAudio: undefined,
|
||||
toggleVideo: undefined,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
audioEnabled: undefined,
|
||||
audioBusy: false,
|
||||
videoEnabled: undefined,
|
||||
videoBusy: false,
|
||||
layoutMode: undefined,
|
||||
layoutSwitchVm: null,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
|
||||
@@ -14,7 +14,9 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
|
||||
const mediaDevices = new MediaDevices(globalScope);
|
||||
const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
component: MediaMuteAndSwitchButton,
|
||||
|
||||
@@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details.
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { validateConfig } from "./Config";
|
||||
import { MatrixRTCMode } from "./ConfigOptions";
|
||||
import { Config, validateConfig } from "./Config";
|
||||
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
|
||||
|
||||
describe("validateConfig", () => {
|
||||
afterEach(() => {
|
||||
@@ -52,3 +52,52 @@ describe("validateConfig", () => {
|
||||
expect(result.ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config.initWith", () => {
|
||||
// vitest.setup.ts has already called initDefault(), so every test here is
|
||||
// free to re-initialize; the last call wins.
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Config.initDefault();
|
||||
});
|
||||
|
||||
it("makes the supplied config readable", () => {
|
||||
Config.initWith({ ssla: "https://example.invalid/ssla" });
|
||||
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
|
||||
it("fills in defaults for keys the embedder did not supply", () => {
|
||||
Config.initWith({ ssla: "https://example.invalid/ssla" });
|
||||
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
|
||||
});
|
||||
|
||||
it("validates the supplied config just as a fetched one would be", () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
Config.initWith({
|
||||
matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode,
|
||||
});
|
||||
expect(Config.get().matrix_rtc_mode).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not share nested state with DEFAULT_CONFIG", () => {
|
||||
Config.initWith({});
|
||||
expect(Config.get().media_quality).not.toBe(DEFAULT_CONFIG.media_quality);
|
||||
});
|
||||
|
||||
it("stops a later init() from fetching over the top of it", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
Config.initWith({ ssla: "https://example.invalid/ssla" });
|
||||
|
||||
await Config.init();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
|
||||
it("replaces a config initialized earlier", () => {
|
||||
Config.initWith({ ssla: "https://first.invalid/ssla" });
|
||||
Config.initWith({ ssla: "https://second.invalid/ssla" });
|
||||
expect(Config.get().ssla).toBe("https://second.invalid/ssla");
|
||||
});
|
||||
});
|
||||
|
||||
+42
-7
@@ -30,6 +30,14 @@ export class Config {
|
||||
return this.internalInstance.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the config by fetching `config.json`, locating it relative to
|
||||
* the current page.
|
||||
*
|
||||
* Does nothing if the config has already been initialized, including by
|
||||
* {@link Config.initWith}, so that the regular startup path can run unchanged
|
||||
* when an embedder has already supplied the config.
|
||||
*/
|
||||
public static async init(): Promise<void> {
|
||||
if (!Config.internalInstance?.initPromise) {
|
||||
const internalInstance = new Config();
|
||||
@@ -50,17 +58,36 @@ export class Config {
|
||||
|
||||
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
|
||||
(config) => {
|
||||
internalInstance.config = merge(
|
||||
{},
|
||||
DEFAULT_CONFIG,
|
||||
validateConfig(config),
|
||||
);
|
||||
internalInstance.config = resolveConfig(config);
|
||||
},
|
||||
);
|
||||
}
|
||||
return Config.internalInstance.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the config from an object supplied by the embedder, instead of
|
||||
* fetching `config.json`.
|
||||
*
|
||||
* {@link Config.init} derives the location of `config.json` from
|
||||
* `window.location`, which only makes sense while Element Call owns the page.
|
||||
* When it is embedded in a host application the host owns the configuration
|
||||
* and passes it in here.
|
||||
*
|
||||
* The config goes through the same validation and defaulting as a fetched
|
||||
* one, so that an injected config behaves identically to a hosted one.
|
||||
*
|
||||
* Replaces any config initialized earlier.
|
||||
*/
|
||||
public static initWith(config: ConfigOptions): void {
|
||||
const internalInstance = new Config();
|
||||
internalInstance.config = resolveConfig(config);
|
||||
// Mark initialization as already done, so that a later init() resolves
|
||||
// immediately rather than fetching config.json over the top of this.
|
||||
internalInstance.initPromise = Promise.resolve();
|
||||
Config.internalInstance = internalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a alternative initializer that does not load anything
|
||||
* from a hosted config file but instead just initializes the config using the
|
||||
@@ -69,8 +96,7 @@ export class Config {
|
||||
* It is supposed to only be used in tests. (It is executed in `vite.setup.js`)
|
||||
*/
|
||||
public static initDefault(): void {
|
||||
Config.internalInstance = new Config();
|
||||
Config.internalInstance.config = { ...DEFAULT_CONFIG };
|
||||
Config.initWith({});
|
||||
}
|
||||
|
||||
// Convenience accessors
|
||||
@@ -94,6 +120,15 @@ export class Config {
|
||||
private initPromise?: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies validation and the built-in defaults to a config, however it was
|
||||
* obtained. Deep-merges onto a fresh object so that the result never shares
|
||||
* nested state with {@link DEFAULT_CONFIG}.
|
||||
*/
|
||||
function resolveConfig(config: ConfigOptions): ResolvedConfigOptions {
|
||||
return merge({}, DEFAULT_CONFIG, validateConfig(config));
|
||||
}
|
||||
|
||||
export function validateConfig(config: ConfigOptions): ConfigOptions {
|
||||
const mode = config.matrix_rtc_mode;
|
||||
if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {
|
||||
|
||||
+103
-32
@@ -12,9 +12,7 @@ Please see LICENSE in the repository root for full details.
|
||||
* Settings, or pinned for a deployment via `matrix_rtc_mode` in config.json.
|
||||
*/
|
||||
export enum MatrixRTCMode {
|
||||
/** Legacy single-SFU + user-keyed memberships + legacy JWT endpoint. */
|
||||
Legacy = "legacy",
|
||||
/** Multi-SFU transport, legacy JWT endpoint, no sticky events. */
|
||||
/** Multi-SFU transport, legacy JWT endpoint, state events. */
|
||||
Compatibility = "compatibility",
|
||||
/**
|
||||
* Multi-SFU transport with:
|
||||
@@ -74,10 +72,7 @@ export interface ConfigOptions {
|
||||
livekit?: {
|
||||
// The link to the service that returns a livekit url and token to use it.
|
||||
// This is a fallback link in case the homeserver in use does not advertise
|
||||
// a livekit service url in the client well-known.
|
||||
// The well known needs to be formatted like so:
|
||||
// {"type":"livekit", "livekit_service_url":"https://livekit.example.com"}
|
||||
// and stored under the key: "org.matrix.msc4143.rtc_foci"
|
||||
// a livekit service url over the transports endpoint.
|
||||
livekit_service_url: string;
|
||||
};
|
||||
|
||||
@@ -89,15 +84,6 @@ export interface ConfigOptions {
|
||||
* Allow to join group calls without audio and video.
|
||||
*/
|
||||
feature_group_calls_without_video_and_audio?: boolean;
|
||||
|
||||
/**
|
||||
* Send device-specific call session membership state events instead of
|
||||
* legacy user-specific call membership state events.
|
||||
* This setting has no effect when the user joins an active call with
|
||||
* legacy state events. For compatibility, Element Call will always join
|
||||
* active legacy calls with legacy state events.
|
||||
*/
|
||||
feature_use_device_session_member_events?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -105,6 +91,61 @@ export interface ConfigOptions {
|
||||
*/
|
||||
ssla?: string;
|
||||
|
||||
/**
|
||||
* Media quality settings for video and screen sharing.
|
||||
* These override the hardcoded LiveKit defaults.
|
||||
*/
|
||||
media_quality?: {
|
||||
/**
|
||||
* Video codec preference. The server must also have the codec enabled.
|
||||
* @default "vp8"
|
||||
*/
|
||||
video_codec?: "vp8" | "vp9" | "h264" | "av1";
|
||||
|
||||
/**
|
||||
* Camera video settings.
|
||||
*/
|
||||
video?: {
|
||||
/** Max resolution height in pixels (e.g. 720, 1080, 1440). @default 720 */
|
||||
max_resolution?: number;
|
||||
/** Max bitrate in bits per second. @default 1700000 */
|
||||
max_bitrate?: number;
|
||||
/** Max framerate. @default 30 */
|
||||
max_framerate?: number;
|
||||
/**
|
||||
* Simulcast layers as an array of {height, bitrate} objects,
|
||||
* ordered from lowest to highest quality.
|
||||
* @default [{height: 180, bitrate: 160000}, {height: 360, bitrate: 450000}]
|
||||
*/
|
||||
simulcast_layers?: Array<{
|
||||
height: number;
|
||||
bitrate: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen share settings.
|
||||
*/
|
||||
screen_share?: {
|
||||
/** Max resolution height in pixels. @default 1080 */
|
||||
max_resolution?: number;
|
||||
/** Max bitrate in bits per second. @default 5000000 */
|
||||
max_bitrate?: number;
|
||||
/** Max framerate. @default 30 */
|
||||
max_framerate?: number;
|
||||
/**
|
||||
* Simulcast layers for screen sharing as an array of {height, bitrate, framerate} objects,
|
||||
* ordered from lowest to highest quality. If omitted, LiveKit SDK defaults apply (1 extra
|
||||
* layer at half resolution).
|
||||
*/
|
||||
simulcast_layers?: Array<{
|
||||
height: number;
|
||||
bitrate: number;
|
||||
framerate?: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
media_devices?: {
|
||||
/**
|
||||
* Defines whether participants should start with audio enabled by default.
|
||||
@@ -127,7 +168,8 @@ export interface ConfigOptions {
|
||||
/**
|
||||
* Pins the {@link MatrixRTCMode} for all clients on this deployment,
|
||||
* overriding any per-user choice from the Developer Settings. If unset,
|
||||
* the user's Developer Settings choice (or its default of `Legacy`) wins.
|
||||
* the user's Developer Settings choice (or its default of `Compatibility`)
|
||||
* wins.
|
||||
*/
|
||||
matrix_rtc_mode?: MatrixRTCMode;
|
||||
|
||||
@@ -177,20 +219,44 @@ export interface ConfigOptions {
|
||||
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
|
||||
*/
|
||||
membership_event_expiry_ms?: number;
|
||||
|
||||
/**
|
||||
* The number of participants in the session at which the media encryption key will no longer
|
||||
* be rotated.
|
||||
*
|
||||
* Rotating a key requires sending it to every participant device, so in large sessions the
|
||||
* cost of rotating on every join/leave becomes prohibitive. At this limit the current key is
|
||||
* kept and distributed to new joiners; no new keys are generated for joiners/leavers.
|
||||
*
|
||||
* Defaults to the js-sdk default (undefined). Which means that rotation will always happen.
|
||||
*/
|
||||
key_rotation_participant_limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Overrides members from ConfigOptions that are always provided by the
|
||||
// default config and are therefore non-optional.
|
||||
export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
default_server_config: {
|
||||
["m.homeserver"]: {
|
||||
base_url: string;
|
||||
server_name: string;
|
||||
};
|
||||
};
|
||||
sync_disconnect_grace_period_ms: number;
|
||||
ssla: string;
|
||||
media_quality: Required<
|
||||
Pick<NonNullable<ConfigOptions["media_quality"]>, "video_codec">
|
||||
> & {
|
||||
video: Required<
|
||||
Pick<
|
||||
NonNullable<NonNullable<ConfigOptions["media_quality"]>["video"]>,
|
||||
"max_resolution" | "max_bitrate" | "max_framerate"
|
||||
>
|
||||
>;
|
||||
screen_share: Required<
|
||||
Pick<
|
||||
NonNullable<
|
||||
NonNullable<ConfigOptions["media_quality"]>["screen_share"]
|
||||
>,
|
||||
"max_resolution" | "max_bitrate" | "max_framerate"
|
||||
>
|
||||
>;
|
||||
};
|
||||
matrix_rtc_session: {
|
||||
wait_for_key_rotation_ms?: number;
|
||||
delayed_leave_event_delay_ms: number;
|
||||
@@ -198,21 +264,26 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
delayed_leave_event_restart_ms?: number;
|
||||
network_error_retry_ms: number;
|
||||
membership_event_expiry_ms?: number;
|
||||
key_rotation_participant_limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
default_server_config: {
|
||||
["m.homeserver"]: {
|
||||
base_url: "http://localhost:8008",
|
||||
server_name: "localhost",
|
||||
},
|
||||
},
|
||||
features: {
|
||||
feature_use_device_session_member_events: true,
|
||||
},
|
||||
sync_disconnect_grace_period_ms: 10000,
|
||||
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
media_quality: {
|
||||
video_codec: "vp8",
|
||||
video: {
|
||||
max_resolution: 720,
|
||||
max_bitrate: 1_700_000,
|
||||
max_framerate: 30,
|
||||
},
|
||||
screen_share: {
|
||||
max_resolution: 1080,
|
||||
max_bitrate: 5_000_000,
|
||||
max_framerate: 30,
|
||||
},
|
||||
},
|
||||
matrix_rtc_session: {
|
||||
delayed_leave_event_delay_ms: 10000,
|
||||
network_error_retry_ms: 1000,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
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, test } from "vitest";
|
||||
|
||||
import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement";
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
|
||||
describe("getKeyForRoom", () => {
|
||||
afterEach(() => {
|
||||
window.location.hash = "#";
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test("prefers a key given in the parameters over the stored one", () => {
|
||||
saveKeyForRoom(roomId, "stored");
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`;
|
||||
|
||||
expect(getKeyForRoom(roomId)).toBe("from-the-link");
|
||||
});
|
||||
|
||||
test("falls back to the stored key", () => {
|
||||
saveKeyForRoom(roomId, "stored");
|
||||
|
||||
expect(getKeyForRoom(roomId)).toBe("stored");
|
||||
});
|
||||
|
||||
test("has no key to offer for a room it has never seen", () => {
|
||||
expect(getKeyForRoom(roomId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
setLocalStorageItemReactive,
|
||||
useLocalStorage,
|
||||
} from "../useLocalStorage";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { getUrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "./e2eeType";
|
||||
import { useClient } from "../ClientContext";
|
||||
|
||||
@@ -57,19 +57,31 @@ const useRoomSharedKey = (
|
||||
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
|
||||
};
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
const { roomId: urlRoomId, password } = getUrlParams();
|
||||
if (roomId !== urlRoomId)
|
||||
/**
|
||||
* The shared key for a room, preferring one supplied in the parameters Element
|
||||
* Call was started with over whatever is in local storage.
|
||||
*/
|
||||
function keyForRoom(
|
||||
roomId: string,
|
||||
paramsRoomId: string | null,
|
||||
password: string | null,
|
||||
): string | null {
|
||||
if (roomId !== paramsRoomId)
|
||||
logger.warn(
|
||||
"requested key for a roomId which is not the current call room id (from the URL)",
|
||||
roomId,
|
||||
urlRoomId,
|
||||
paramsRoomId,
|
||||
);
|
||||
return (
|
||||
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
|
||||
);
|
||||
}
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
const { roomId: paramsRoomId, password } = getUrlParams();
|
||||
return keyForRoom(roomId, paramsRoomId, password);
|
||||
}
|
||||
|
||||
export type Unencrypted = { kind: E2eeType.NONE };
|
||||
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||
@@ -77,10 +89,15 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||
|
||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { client } = useClient();
|
||||
const { roomId: paramsRoomId, password } = useUrlParams();
|
||||
|
||||
const [storedPassword] = useRoomSharedKey(
|
||||
// TODO: this passes an already-prefixed key where a room ID is expected, so
|
||||
// the local storage key ends up prefixed twice and never matches what
|
||||
// saveKeyForRoom writes. Preserved as-is here to keep this commit a pure
|
||||
// refactor; the reactive read is effectively dead until it is fixed.
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
getKeyForRoom(roomId) ?? undefined,
|
||||
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
|
||||
);
|
||||
|
||||
const room = client?.getRoom(roomId);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,16 +0,0 @@
|
||||
<svg width="1440" height="800" viewBox="0 0 1440 800" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_4162_80259)">
|
||||
<path d="M-37.0486 666.028C267.76 138.867 944.304 -46.1945 1477.05 260.929" stroke="url(#paint0_linear_4162_80259)" stroke-width="192" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_4162_80259" x="-333.118" y="-183.694" width="2106.24" height="1145.68" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_4162_80259"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_4162_80259" x1="1255.31" y1="320.254" x2="184.899" y2="607.497" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00C59E"/>
|
||||
<stop offset="0.75" stop-color="#0044A5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 938 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,86 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="1200"
|
||||
height="285"
|
||||
viewBox="0 0 1200 285"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<g
|
||||
filter="url(#filter0_f_3970_9366)"
|
||||
id="g2"
|
||||
transform="translate(422.00201)">
|
||||
<path
|
||||
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
|
||||
stroke="url(#paint0_linear_3970_9366)"
|
||||
stroke-width="235.517"
|
||||
stroke-linecap="round"
|
||||
id="path1"
|
||||
style="stroke:url(#paint0_linear_3970_9366)" />
|
||||
<path
|
||||
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
|
||||
stroke="url(#paint1_linear_3970_9366)"
|
||||
style="mix-blend-mode:overlay;stroke:url(#paint1_linear_3970_9366)"
|
||||
stroke-width="235.517"
|
||||
stroke-linecap="round"
|
||||
id="path2" />
|
||||
</g>
|
||||
<defs
|
||||
id="defs5">
|
||||
<filter
|
||||
id="filter0_f_3970_9366"
|
||||
x="-517.617"
|
||||
y="-0.00012207"
|
||||
width="1391.23"
|
||||
height="848.409"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feFlood
|
||||
flood-opacity="0"
|
||||
result="BackgroundImageFix"
|
||||
id="feFlood2" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
id="feBlend2" />
|
||||
<feGaussianBlur
|
||||
stdDeviation="117.758"
|
||||
result="effect1_foregroundBlur_3970_9366"
|
||||
id="feGaussianBlur2" />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_3970_9366"
|
||||
x1="349.17099"
|
||||
y1="323.96301"
|
||||
x2="6.82898"
|
||||
y2="666.30499"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#0D5CBD"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="0.730863"
|
||||
stop-color="#0DBDA8"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_3970_9366"
|
||||
x1="349.17099"
|
||||
y1="323.96301"
|
||||
x2="6.82898"
|
||||
y2="666.30499"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#0D5CBD"
|
||||
id="stop4" />
|
||||
<stop
|
||||
offset="0.730863"
|
||||
stop-color="#0DBDA8"
|
||||
id="stop5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 B |
+12
-8
@@ -41,6 +41,8 @@ import { TileWrapper } from "./TileWrapper";
|
||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||
import { useInitial } from "../useInitial";
|
||||
|
||||
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -285,7 +287,6 @@ export function Grid<
|
||||
const [visibleTilesCallback, setVisibleTilesCallback] =
|
||||
useState<VisibleTilesCallback | null>(null);
|
||||
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
const Slot: FC<SlotProps<TileModel>> = useMemo(
|
||||
() =>
|
||||
@@ -372,6 +373,10 @@ export function Grid<
|
||||
// react-spring's imperative API during gestures to improve responsiveness
|
||||
const dragState = useRef<DragState | null>(null);
|
||||
|
||||
// If true, disables animations
|
||||
const immediate =
|
||||
usePrefersReducedMotion() || placedTiles.length > MAX_ANIMATED_TILES;
|
||||
|
||||
const [tileTransitions, springRef] = useTransition(
|
||||
placedTiles,
|
||||
() => ({
|
||||
@@ -389,9 +394,9 @@ export function Grid<
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
immediate: prefersReducedMotion,
|
||||
immediate,
|
||||
}),
|
||||
enter: { opacity: 1, scale: 1, immediate: prefersReducedMotion },
|
||||
enter: { opacity: 1, scale: 1, immediate },
|
||||
update: ({
|
||||
id,
|
||||
x,
|
||||
@@ -406,9 +411,9 @@ export function Grid<
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
immediate: prefersReducedMotion,
|
||||
immediate,
|
||||
},
|
||||
leave: { opacity: 0, scale: 0, immediate: prefersReducedMotion },
|
||||
leave: { opacity: 0, scale: 0, immediate },
|
||||
config: { mass: 0.7, tension: 252, friction: 25 },
|
||||
}),
|
||||
// react-spring's types are bugged and can't infer the spring type
|
||||
@@ -441,8 +446,7 @@ export function Grid<
|
||||
y: tile.y,
|
||||
width: tile.width,
|
||||
height: tile.height,
|
||||
immediate:
|
||||
prefersReducedMotion || ((key): boolean => key === "zIndex"),
|
||||
immediate: immediate || ((key): boolean => key === "zIndex"),
|
||||
// Allow the tile's position to settle before pushing its
|
||||
// z-index back down
|
||||
delay: (key): number => (key === "zIndex" ? 500 : 0),
|
||||
@@ -453,7 +457,7 @@ export function Grid<
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
immediate:
|
||||
prefersReducedMotion ||
|
||||
immediate ||
|
||||
((key): boolean =>
|
||||
key === "zIndex" || key === "x" || key === "y"),
|
||||
},
|
||||
|
||||
@@ -10,28 +10,28 @@ import { type ReactNode, useCallback, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneLandscapeLayout as OneOnOneLandscapeLayoutModel } from "../state/layout-types.ts";
|
||||
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneLandscapeLayout.module.css";
|
||||
import styles from "./OneOnOneDesktopLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout for landscape screens, in which
|
||||
* An implementation of the "one-on-one" layout for desktop platforms, in which
|
||||
* the remote participant is shown at maximum size, overlaid by a small view of
|
||||
* the local participant.
|
||||
*/
|
||||
export const makeOneOnOneLandscapeLayout: CallLayout<
|
||||
OneOnOneLandscapeLayoutModel
|
||||
export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
OneOnOneDesktopLayoutModel
|
||||
> = ({ minBounds$ }) => ({
|
||||
foreground: "fixed",
|
||||
|
||||
fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode {
|
||||
fixed: function OneOnOneDesktopLayoutFixed({ ref }): ReactNode {
|
||||
useUpdateLayout();
|
||||
return <div ref={ref} />;
|
||||
},
|
||||
|
||||
scrolling: function OneOnOneLandscapeLayoutScrolling({
|
||||
scrolling: function OneOnOneDesktopLayoutScrolling({
|
||||
ref,
|
||||
model,
|
||||
Slot,
|
||||
+18
-4
@@ -19,14 +19,28 @@ Please see LICENSE in the repository root for full details.
|
||||
inset: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
/* Give the PiP a landscape aspect ratio */
|
||||
.pip[data-size="sm"] {
|
||||
inline-size: 88px;
|
||||
block-size: 132px;
|
||||
inline-size: 132px;
|
||||
block-size: 88px;
|
||||
}
|
||||
|
||||
.pip[data-size="lg"] {
|
||||
inline-size: 140px;
|
||||
block-size: 210px;
|
||||
inline-size: 210px;
|
||||
block-size: 140px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
/* Give the PiP a portrait aspect ratio */
|
||||
.pip[data-size="sm"] {
|
||||
inline-size: 88px;
|
||||
block-size: 132px;
|
||||
}
|
||||
|
||||
.pip[data-size="lg"] {
|
||||
inline-size: 140px;
|
||||
block-size: 210px;
|
||||
}
|
||||
}
|
||||
|
||||
.pip[data-block-alignment="start"] {
|
||||
@@ -9,23 +9,23 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts";
|
||||
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import styles from "./OneOnOnePortraitLayout.module.css";
|
||||
import styles from "./OneOnOneMobileLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout for portrait screens, in which
|
||||
* An implementation of the "one-on-one" layout for mobile platforms, in which
|
||||
* the remote participant is shown at maximum size, overlaid by a small view of
|
||||
* the local participant.
|
||||
*/
|
||||
export const makeOneOnOnePortraitLayout: CallLayout<
|
||||
OneOnOnePortraitLayoutModel
|
||||
export const makeOneOnOneMobileLayout: CallLayout<
|
||||
OneOnOneMobileLayoutModel
|
||||
> = () => ({
|
||||
foreground: "scrolling",
|
||||
|
||||
fixed: function OneOnOnePortraitLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
fixed: function OneOnOneMobileLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
@@ -38,7 +38,7 @@ export const makeOneOnOnePortraitLayout: CallLayout<
|
||||
);
|
||||
},
|
||||
|
||||
scrolling: function OneOnOnePortraitLayoutScrolling({
|
||||
scrolling: function OneOnOneMobileLayoutScrolling({
|
||||
ref,
|
||||
model,
|
||||
Slot,
|
||||
@@ -5,7 +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 ReactNode } from "react";
|
||||
import { type FC, type ReactNode } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
@@ -13,6 +13,9 @@ import { type CallLayout } from "./CallLayout";
|
||||
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/layout-types.ts";
|
||||
import styles from "./SpotlightLandscapeLayout.module.css";
|
||||
import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { type MediaViewModel } from "../state/media/MediaViewModel.ts";
|
||||
import { type Behavior } from "../state/Behavior.ts";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
|
||||
/**
|
||||
* An implementation of the "spotlight landscape" layout, in which the spotlight
|
||||
@@ -54,16 +57,10 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
useObservableEagerState(minBounds$);
|
||||
const withIndicators =
|
||||
useObservableEagerState(model.spotlight.media$).length > 1;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
<div
|
||||
className={classNames(styles.spotlight, {
|
||||
[styles.withIndicators]: withIndicators,
|
||||
})}
|
||||
/>
|
||||
<SpotlightSlot media$={model.spotlight.media$} />
|
||||
<div className={styles.grid}>
|
||||
{model.grid.map((m) => (
|
||||
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
|
||||
@@ -73,3 +70,20 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
interface SpotlightSlotProps {
|
||||
media$: Behavior<MediaViewModel[]>;
|
||||
}
|
||||
|
||||
// This component isolates the subscription to the spotlight media so that it
|
||||
// can change without causing the whole layout to re-render
|
||||
const SpotlightSlot: FC<SpotlightSlotProps> = ({ media$ }) => {
|
||||
const withIndicators = useBehavior(media$).length > 1;
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.spotlight, {
|
||||
[styles.withIndicators]: withIndicators,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ErrorPage, LoadingPage } from "../FullScreenView";
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
import { RegisteredView } from "./RegisteredView";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { widget } from "../widget.ts";
|
||||
|
||||
export const HomePage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,7 +23,7 @@ export const HomePage: FC = () => {
|
||||
if (!clientState) {
|
||||
return <LoadingPage />;
|
||||
} else if (clientState.state === "error") {
|
||||
return <ErrorPage widget={widget} error={clientState.error} />;
|
||||
return <ErrorPage error={clientState.error} />;
|
||||
} else {
|
||||
return clientState.authenticated ? (
|
||||
<RegisteredView client={clientState.authenticated.client} />
|
||||
|
||||
+9
-13
@@ -15,8 +15,7 @@ Please see LICENSE in the repository root for full details.
|
||||
@import url("@fontsource/inconsolata/700.css");
|
||||
|
||||
@import url("normalize.css/normalize.css") layer(normalize);
|
||||
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css")
|
||||
layer(compound);
|
||||
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
|
||||
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
|
||||
|
||||
:root {
|
||||
@@ -28,12 +27,6 @@ layer(compound);
|
||||
--font-size-title: calc(24px * var(--font-scale));
|
||||
--font-size-headline: calc(32px * var(--font-scale));
|
||||
|
||||
/* These colors are needed during the transitionary period between the old and
|
||||
new Compound design systems, but should be removed ASAP */
|
||||
--stopgap-color-on-solid-accent: var(--cpd-color-bg-canvas-default);
|
||||
--stopgap-background-85: rgba(255, 255, 255, 0.85);
|
||||
--stopgap-bgColor3: #444;
|
||||
|
||||
--cpd-color-border-accent: var(--cpd-color-green-800);
|
||||
/* The distance to inset non-full-width content from the edge of the window
|
||||
along the inline axis. This ramps up from 16px for typical mobile windows, to
|
||||
@@ -85,16 +78,19 @@ body {
|
||||
body[data-background="gradient"]::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("graphics/mobile-gradient.svg");
|
||||
background-size: auto;
|
||||
/* Chromium abruptly fades our images to fully transparent at the edge of
|
||||
the element. If we just make the element a little bigger than the viewport,
|
||||
this is no longer visible. */
|
||||
inset: -20px;
|
||||
background-image: url("graphics/mobile-gradient.png");
|
||||
background-size: 1400px 305px;
|
||||
background-position: bottom;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
body[data-background="gradient"][data-platform="desktop"]::before {
|
||||
background-image: url("graphics/desktop-gradient.svg");
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
background-image: url("graphics/desktop-gradient.png");
|
||||
background-size: max(1440px, 100vw) max(1440px, 100vh);
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
+47
-6
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import i18n, {
|
||||
import {
|
||||
type BackendModule,
|
||||
type ReadCallback,
|
||||
type ResourceKey,
|
||||
} from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
@@ -30,10 +29,16 @@ import {
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "./config/Config";
|
||||
import { seedSettingsFromConfig } from "./settings/settings";
|
||||
import { platform } from "./Platform";
|
||||
import { isFailure } from "./utils/fetch";
|
||||
import { initializeWidget } from "./widget";
|
||||
import { initializeWidget, type WidgetHelpers } from "./widget";
|
||||
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
|
||||
import {
|
||||
type AnalyticsConfig,
|
||||
PosthogAnalytics,
|
||||
} from "./analytics/PosthogAnalytics.ts";
|
||||
import { i18n } from "./utils/i18n.ts";
|
||||
|
||||
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||
// {
|
||||
@@ -97,6 +102,36 @@ const Backend = {
|
||||
},
|
||||
} satisfies BackendModule;
|
||||
|
||||
/**
|
||||
* Where analytics reporting is configured from.
|
||||
*
|
||||
* Note the two halves are decided differently, and deliberately so. *Where the
|
||||
* PostHog credentials come from* depends on the package: an embedder passes
|
||||
* them in through the URL because it is responsible for its own users'
|
||||
* telemetry, whereas a standalone deployment is configured by whoever operates
|
||||
* it. *Who owns the user's analytics identity*, on the other hand, depends on
|
||||
* how Element Call is actually running right now — the full package can be used
|
||||
* as a widget too.
|
||||
*/
|
||||
// Exported for testing
|
||||
export function analyticsConfigFromEnvironment(): AnalyticsConfig {
|
||||
const { posthogApiKey, posthogApiHost, posthogUserId, isWidget } =
|
||||
getUrlParams();
|
||||
return {
|
||||
matrixBackend: isWidget ? "embedded" : "jssdk",
|
||||
hostAnalyticsId: posthogUserId,
|
||||
...(import.meta.env.VITE_PACKAGE === "embedded"
|
||||
? {
|
||||
apiKey: posthogApiKey ?? undefined,
|
||||
apiHost: posthogApiHost ?? undefined,
|
||||
}
|
||||
: {
|
||||
apiKey: Config.get().posthog?.api_key,
|
||||
apiHost: Config.get().posthog?.api_host,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
enum LoadState {
|
||||
None,
|
||||
Loading,
|
||||
@@ -120,8 +155,8 @@ export class Initializer {
|
||||
return !!Initializer.internalInstance?.isInitialized;
|
||||
}
|
||||
|
||||
public static async initBeforeReact(): Promise<void> {
|
||||
initializeWidget();
|
||||
public static async initBeforeReact(): Promise<WidgetHelpers | null> {
|
||||
const widget = initializeWidget();
|
||||
|
||||
const polyfills: Promise<unknown>[] = [];
|
||||
if (shouldPolyfillSegmenter()) {
|
||||
@@ -147,10 +182,12 @@ export class Initializer {
|
||||
document.documentElement.lang = lng;
|
||||
});
|
||||
|
||||
// Note: deliberately no `.use(initReactI18next)` — that would register this
|
||||
// instance as react-i18next's global default, which is the very global we
|
||||
// are avoiding. Components receive it through `<I18nextProvider>` instead.
|
||||
await i18n
|
||||
.use(Backend)
|
||||
.use(languageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
defaultNS: "app",
|
||||
@@ -206,6 +243,8 @@ export class Initializer {
|
||||
});
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
return widget;
|
||||
}
|
||||
|
||||
public static init(): Promise<void> | null {
|
||||
@@ -237,6 +276,8 @@ export class Initializer {
|
||||
this.loadStates.config = LoadState.Loading;
|
||||
Config.init().then(
|
||||
() => {
|
||||
seedSettingsFromConfig(Config.get().media_quality);
|
||||
PosthogAnalytics.configure(analyticsConfigFromEnvironment());
|
||||
this.loadStates.config = LoadState.Loaded;
|
||||
this.initStep(resolve);
|
||||
},
|
||||
|
||||
@@ -173,14 +173,6 @@ Please see LICENSE in the repository root for full details.
|
||||
border-color: var(--cpd-color-border-disabled);
|
||||
}
|
||||
|
||||
.checkbox svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox svg * {
|
||||
stroke: var(--stopgap-color-on-solid-accent);
|
||||
}
|
||||
|
||||
.checkboxField input[type="checkbox"]:checked + label > .checkbox {
|
||||
background: var(--cpd-color-text-action-accent);
|
||||
border-color: var(--cpd-color-text-action-accent);
|
||||
|
||||
@@ -99,7 +99,7 @@ function renderTestComponent(
|
||||
),
|
||||
} as unknown as Room;
|
||||
|
||||
if (explicitTracks?.length ?? 0 > 0) {
|
||||
if ((explicitTracks?.length ?? 0) > 0) {
|
||||
tracks = explicitTracks!.map(({ participantId, source, kind }) => {
|
||||
const participant =
|
||||
liveKitParticipants.find((p) => p.identity === participantId) ??
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { VideoPresets, type VideoPreset } from "livekit-client";
|
||||
|
||||
import { buildLiveKitOptions, getLiveKitOptions } from "./options";
|
||||
import { Config } from "../config/Config";
|
||||
|
||||
vi.mock("../config/Config", () => ({
|
||||
Config: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("buildLiveKitOptions", () => {
|
||||
it("returns sensible defaults with no config", () => {
|
||||
const opts = buildLiveKitOptions();
|
||||
expect(opts.adaptiveStream).toBe(true);
|
||||
expect(opts.dynacast).toBe(true);
|
||||
expect(opts.videoCaptureDefaults?.resolution).toEqual(
|
||||
VideoPresets.h720.resolution,
|
||||
);
|
||||
expect(opts.publishDefaults?.videoCodec).toBe("vp8");
|
||||
expect(opts.publishDefaults?.videoEncoding).toEqual({
|
||||
maxBitrate: 1_700_000,
|
||||
maxFramerate: 30,
|
||||
});
|
||||
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
|
||||
maxBitrate: 5_000_000,
|
||||
maxFramerate: 30,
|
||||
});
|
||||
expect(opts.publishDefaults?.videoSimulcastLayers).toEqual([
|
||||
VideoPresets.h180,
|
||||
VideoPresets.h360,
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies video codec from config", () => {
|
||||
const opts = buildLiveKitOptions({ video_codec: "vp9" });
|
||||
expect(opts.publishDefaults?.videoCodec).toBe("vp9");
|
||||
});
|
||||
|
||||
it("applies video resolution and encoding from config", () => {
|
||||
const baseVideoConfig = {
|
||||
max_resolution: 1080,
|
||||
max_bitrate: 3_000_000,
|
||||
max_framerate: 60,
|
||||
};
|
||||
const opts1080 = buildLiveKitOptions({
|
||||
video: baseVideoConfig,
|
||||
});
|
||||
const opts1440 = buildLiveKitOptions({
|
||||
video: { ...baseVideoConfig, max_resolution: 1440 },
|
||||
});
|
||||
const opts2160 = buildLiveKitOptions({
|
||||
video: { ...baseVideoConfig, max_resolution: 2160 },
|
||||
});
|
||||
expect(opts1080.videoCaptureDefaults?.resolution).toEqual(
|
||||
VideoPresets.h1080.resolution,
|
||||
);
|
||||
expect(opts1440.videoCaptureDefaults?.resolution).toEqual(
|
||||
VideoPresets.h1440.resolution,
|
||||
);
|
||||
expect(opts2160.videoCaptureDefaults?.resolution).toEqual(
|
||||
VideoPresets.h2160.resolution,
|
||||
);
|
||||
expect(opts1080.publishDefaults?.videoEncoding).toEqual({
|
||||
maxBitrate: 3_000_000,
|
||||
maxFramerate: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies screen share encoding from config", () => {
|
||||
const opts = buildLiveKitOptions({
|
||||
screen_share: {
|
||||
max_bitrate: 8_000_000,
|
||||
max_framerate: 15,
|
||||
},
|
||||
});
|
||||
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
|
||||
maxBitrate: 8_000_000,
|
||||
maxFramerate: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses DEFAULT_CONFIG defaults when only resolution is set", () => {
|
||||
const opts = buildLiveKitOptions({
|
||||
screen_share: {
|
||||
max_resolution: 720,
|
||||
},
|
||||
});
|
||||
// Bitrate and framerate fall back to DEFAULT_CONFIG, not the preset
|
||||
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
|
||||
maxBitrate: 5_000_000,
|
||||
maxFramerate: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps low resolutions to the closest preset, rounding up", () => {
|
||||
const expectations: [number, VideoPreset][] = [
|
||||
[180, VideoPresets.h180],
|
||||
[360, VideoPresets.h360],
|
||||
[480, VideoPresets.h540],
|
||||
[540, VideoPresets.h540],
|
||||
[720, VideoPresets.h720],
|
||||
];
|
||||
for (const [height, preset] of expectations) {
|
||||
const opts = buildLiveKitOptions({ video: { max_resolution: height } });
|
||||
expect(opts.videoCaptureDefaults?.resolution).toEqual(preset.resolution);
|
||||
}
|
||||
});
|
||||
|
||||
it("screen share layers fall back to max_framerate, then 30", () => {
|
||||
const fromMax = buildLiveKitOptions({
|
||||
screen_share: {
|
||||
max_framerate: 15,
|
||||
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
fromMax.publishDefaults?.screenShareSimulcastLayers?.[0],
|
||||
).toMatchObject({ encoding: { maxFramerate: 15 } });
|
||||
|
||||
const fromDefault = buildLiveKitOptions({
|
||||
screen_share: {
|
||||
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
fromDefault.publishDefaults?.screenShareSimulcastLayers?.[0],
|
||||
).toMatchObject({ encoding: { maxFramerate: 30 } });
|
||||
});
|
||||
|
||||
it("applies custom video simulcast layers", () => {
|
||||
const opts = buildLiveKitOptions({
|
||||
video: {
|
||||
simulcast_layers: [
|
||||
{ height: 180, bitrate: 100_000 },
|
||||
{ height: 360, bitrate: 300_000 },
|
||||
{ height: 540, bitrate: 600_000 },
|
||||
],
|
||||
max_framerate: 24,
|
||||
},
|
||||
});
|
||||
const layers = opts.publishDefaults?.videoSimulcastLayers;
|
||||
expect(layers).toHaveLength(3);
|
||||
expect(layers?.[0]).toMatchObject({
|
||||
width: 320,
|
||||
height: 180,
|
||||
encoding: { maxBitrate: 100_000, maxFramerate: 24 },
|
||||
});
|
||||
expect(layers?.[2]).toMatchObject({
|
||||
width: 960,
|
||||
height: 540,
|
||||
encoding: { maxBitrate: 600_000, maxFramerate: 24 },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies custom screen share simulcast layers", () => {
|
||||
const opts = buildLiveKitOptions({
|
||||
screen_share: {
|
||||
simulcast_layers: [{ height: 540, bitrate: 1_000_000, framerate: 5 }],
|
||||
},
|
||||
});
|
||||
const layers = opts.publishDefaults?.screenShareSimulcastLayers;
|
||||
expect(layers).toHaveLength(1);
|
||||
expect(layers?.[0]).toMatchObject({
|
||||
width: 960,
|
||||
height: 540,
|
||||
encoding: { maxBitrate: 1_000_000, maxFramerate: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not include screenShareSimulcastLayers when not configured", () => {
|
||||
const opts = buildLiveKitOptions();
|
||||
expect(opts.publishDefaults?.screenShareSimulcastLayers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("backupCodec always uses stock VP8 720p encoding", () => {
|
||||
const opts = buildLiveKitOptions({
|
||||
video_codec: "av1",
|
||||
video: { max_bitrate: 10_000_000, max_framerate: 60 },
|
||||
});
|
||||
const backup = opts.publishDefaults?.backupCodec as {
|
||||
codec: string;
|
||||
encoding: { maxBitrate: number; maxFramerate: number };
|
||||
};
|
||||
expect(backup.codec).toBe("vp8");
|
||||
expect(backup.encoding).toEqual(VideoPresets.h720.encoding);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLiveKitOptions", () => {
|
||||
it("reads from Config singleton", () => {
|
||||
vi.mocked(Config.get).mockReturnValue({
|
||||
media_quality: { video_codec: "h264" },
|
||||
} as ReturnType<typeof Config.get>);
|
||||
const opts = getLiveKitOptions();
|
||||
expect(opts.publishDefaults?.videoCodec).toBe("h264");
|
||||
});
|
||||
|
||||
it("throws when Config is not initialized", () => {
|
||||
vi.mocked(Config.get).mockImplementation(() => {
|
||||
throw new Error("Config not initialized");
|
||||
});
|
||||
expect(() => getLiveKitOptions()).toThrow("Config not initialized");
|
||||
});
|
||||
});
|
||||
+131
-33
@@ -9,46 +9,144 @@ import {
|
||||
AudioPresets,
|
||||
DefaultReconnectPolicy,
|
||||
type RoomOptions,
|
||||
ScreenSharePresets,
|
||||
type TrackPublishDefaults,
|
||||
type VideoPreset,
|
||||
VideoPresets,
|
||||
VideoPreset as VideoPresetClass,
|
||||
} from "livekit-client";
|
||||
|
||||
const defaultLiveKitPublishOptions: TrackPublishDefaults = {
|
||||
audioPreset: AudioPresets.music,
|
||||
dtx: true,
|
||||
// disable red because the livekit server strips out red packets for clients
|
||||
// that don't support it (firefox) but of course that doesn't work with e2ee.
|
||||
red: false,
|
||||
forceStereo: false,
|
||||
simulcast: true,
|
||||
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360] as VideoPreset[],
|
||||
screenShareEncoding: ScreenSharePresets.h1080fps30.encoding,
|
||||
stopMicTrackOnMute: false,
|
||||
videoCodec: "vp8",
|
||||
videoEncoding: VideoPresets.h720.encoding,
|
||||
backupCodec: { codec: "vp8", encoding: VideoPresets.h720.encoding },
|
||||
} as const;
|
||||
import { Config } from "../config/Config";
|
||||
import { DEFAULT_CONFIG, type ConfigOptions } from "../config/ConfigOptions";
|
||||
|
||||
export const defaultLiveKitOptions: RoomOptions = {
|
||||
// automatically manage subscribed video quality
|
||||
adaptiveStream: true,
|
||||
/**
|
||||
* Find the closest matching VideoPreset for a given height.
|
||||
*/
|
||||
function videoPresetForHeight(height: number): VideoPreset {
|
||||
if (height <= 180) return VideoPresets.h180;
|
||||
if (height <= 360) return VideoPresets.h360;
|
||||
if (height <= 540) return VideoPresets.h540;
|
||||
if (height <= 720) return VideoPresets.h720;
|
||||
if (height <= 1080) return VideoPresets.h1080;
|
||||
if (height <= 1440) return VideoPresets.h1440;
|
||||
return VideoPresets.h2160;
|
||||
}
|
||||
|
||||
// optimize publishing bandwidth and CPU for published tracks
|
||||
dynacast: true,
|
||||
/**
|
||||
* Build LiveKit publish options from config, falling back to sensible defaults.
|
||||
*/
|
||||
function buildPublishOptions(
|
||||
mediaQuality: ConfigOptions["media_quality"],
|
||||
): TrackPublishDefaults {
|
||||
const defaults = DEFAULT_CONFIG.media_quality;
|
||||
const videoConf = mediaQuality?.video;
|
||||
const screenConf = mediaQuality?.screen_share;
|
||||
const codec = mediaQuality?.video_codec ?? defaults.video_codec;
|
||||
|
||||
// capture settings
|
||||
videoCaptureDefaults: {
|
||||
resolution: VideoPresets.h720.resolution,
|
||||
},
|
||||
// Camera video encoding
|
||||
const videoEncoding = {
|
||||
maxBitrate: videoConf?.max_bitrate ?? defaults.video.max_bitrate,
|
||||
maxFramerate: videoConf?.max_framerate ?? defaults.video.max_framerate,
|
||||
};
|
||||
|
||||
// publish settings
|
||||
publishDefaults: defaultLiveKitPublishOptions,
|
||||
// Camera simulcast layers
|
||||
let videoSimulcastLayers: VideoPreset[];
|
||||
if (videoConf?.simulcast_layers) {
|
||||
videoSimulcastLayers = videoConf.simulcast_layers.map(
|
||||
(layer) =>
|
||||
new VideoPresetClass(
|
||||
Math.round((layer.height * 16) / 9),
|
||||
layer.height,
|
||||
layer.bitrate,
|
||||
videoConf?.max_framerate ?? defaults.video.max_framerate,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
videoSimulcastLayers = [VideoPresets.h180, VideoPresets.h360];
|
||||
}
|
||||
|
||||
// default LiveKit options that seem to be sane
|
||||
stopLocalTrackOnUnpublish: true,
|
||||
reconnectPolicy: new DefaultReconnectPolicy(),
|
||||
disconnectOnPageLeave: true,
|
||||
webAudioMix: false,
|
||||
};
|
||||
// Screen share encoding
|
||||
const screenShareEncoding = {
|
||||
maxBitrate: screenConf?.max_bitrate ?? defaults.screen_share.max_bitrate,
|
||||
maxFramerate:
|
||||
screenConf?.max_framerate ?? defaults.screen_share.max_framerate,
|
||||
};
|
||||
|
||||
// Screen share simulcast layers
|
||||
let screenShareSimulcastLayers: VideoPreset[] | undefined;
|
||||
if (screenConf?.simulcast_layers) {
|
||||
screenShareSimulcastLayers = screenConf.simulcast_layers.map(
|
||||
(layer) =>
|
||||
new VideoPresetClass(
|
||||
Math.round((layer.height * 16) / 9),
|
||||
layer.height,
|
||||
layer.bitrate,
|
||||
layer.framerate ?? screenConf?.max_framerate ?? 30,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
audioPreset: AudioPresets.music,
|
||||
dtx: true,
|
||||
// disable red because the livekit server strips out red packets for clients
|
||||
// that don't support it (firefox) but of course that doesn't work with e2ee.
|
||||
red: false,
|
||||
forceStereo: false,
|
||||
simulcast: true,
|
||||
videoSimulcastLayers: videoSimulcastLayers as VideoPreset[],
|
||||
screenShareEncoding,
|
||||
...(screenShareSimulcastLayers && {
|
||||
screenShareSimulcastLayers: screenShareSimulcastLayers as VideoPreset[],
|
||||
}),
|
||||
stopMicTrackOnMute: false,
|
||||
videoCodec: codec,
|
||||
videoEncoding,
|
||||
backupCodec: {
|
||||
codec: "vp8",
|
||||
encoding: VideoPresets.h720.encoding,
|
||||
},
|
||||
} as TrackPublishDefaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LiveKit RoomOptions from config.
|
||||
* Call this after Config.init() has resolved.
|
||||
*/
|
||||
export function buildLiveKitOptions(
|
||||
mediaQuality?: ConfigOptions["media_quality"],
|
||||
): RoomOptions {
|
||||
const videoHeight =
|
||||
mediaQuality?.video?.max_resolution ??
|
||||
DEFAULT_CONFIG.media_quality.video.max_resolution;
|
||||
const basePreset = videoPresetForHeight(videoHeight);
|
||||
|
||||
return {
|
||||
// automatically manage subscribed video quality
|
||||
adaptiveStream: true,
|
||||
|
||||
// optimize publishing bandwidth and CPU for published tracks
|
||||
dynacast: true,
|
||||
|
||||
// capture settings
|
||||
videoCaptureDefaults: {
|
||||
resolution: basePreset.resolution,
|
||||
},
|
||||
|
||||
// publish settings
|
||||
publishDefaults: buildPublishOptions(mediaQuality),
|
||||
|
||||
// default LiveKit options that seem to be sane
|
||||
stopLocalTrackOnUnpublish: true,
|
||||
reconnectPolicy: new DefaultReconnectPolicy(),
|
||||
disconnectOnPageLeave: true,
|
||||
webAudioMix: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LiveKit options, reading from the loaded Config singleton.
|
||||
* Requires Config.init() to have resolved first.
|
||||
*/
|
||||
export function getLiveKitOptions(): RoomOptions {
|
||||
return buildLiveKitOptions(Config.get().media_quality);
|
||||
}
|
||||
|
||||
+12
-2
@@ -21,6 +21,7 @@ import { init as initRageshake } from "./settings/rageshake";
|
||||
import { Initializer } from "./initializer";
|
||||
import { AppViewModel } from "./state/AppViewModel";
|
||||
import { globalScope } from "./state/ObservableScope";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
|
||||
initRageshake().catch((e) => {
|
||||
logger.error("Failed to initialize rageshake", e);
|
||||
@@ -48,10 +49,19 @@ if (fatalError !== null) {
|
||||
}
|
||||
|
||||
Initializer.initBeforeReact()
|
||||
.then(() => {
|
||||
.then((widget) => {
|
||||
const { controlledAudioDevices, callIntent } = getUrlParams();
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App vm={new AppViewModel(globalScope)} />
|
||||
<App
|
||||
vm={
|
||||
new AppViewModel(globalScope, {
|
||||
controlledAudioDevices,
|
||||
callIntent,
|
||||
})
|
||||
}
|
||||
widget={widget}
|
||||
/>
|
||||
</StrictMode>,
|
||||
);
|
||||
})
|
||||
|
||||
@@ -36,7 +36,11 @@ import {
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { mockConfig } from "../utils/test.ts";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "../HostBridge.ts";
|
||||
|
||||
test.each([
|
||||
{
|
||||
@@ -79,7 +83,6 @@ test.each([
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -108,7 +111,6 @@ test("should render the error page with link back to home", async () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -154,10 +156,7 @@ test("ConnectionLostError: Action handling should reset error state", async () =
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
recoveryActionHandler={reconnectCallback}
|
||||
widget={null}
|
||||
>
|
||||
<GroupCallErrorBoundary recoveryActionHandler={reconnectCallback}>
|
||||
<TestComponent fail={failState} />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>
|
||||
@@ -199,7 +198,6 @@ describe("Rageshake button", () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -224,29 +222,27 @@ describe("Rageshake button", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should have a close button in widget mode", async () => {
|
||||
test("should have a close button when the host can dismiss us", async () => {
|
||||
const error = new MatrixRTCTransportMissingError("example.com");
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const mockWidget = {
|
||||
api: {
|
||||
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
|
||||
},
|
||||
} as unknown as WidgetHelpers;
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, close };
|
||||
|
||||
const user = userEvent.setup();
|
||||
const onErrorMock = vi.fn();
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
widget={mockWidget}
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</HostBridgeProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
@@ -258,11 +254,7 @@ test("should have a close button in widget mode", async () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should show technical details when error has a matrixError cause", async () => {
|
||||
@@ -282,11 +274,7 @@ test("should show technical details when error has a matrixError cause", async (
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
@@ -315,11 +303,7 @@ test("should not show technical details when error has no matrix error cause", a
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
@@ -376,7 +360,6 @@ describe("LiveKit ConnectionError variants", () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -406,7 +389,6 @@ describe("LiveKit ConnectionError variants", () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
} from "../utils/errors.ts";
|
||||
import { FullScreenView } from "../FullScreenView.tsx";
|
||||
import { ErrorView } from "../ErrorView.tsx";
|
||||
import { type WidgetHelpers } from "../widget.ts";
|
||||
import styles from "../ErrorView.module.css";
|
||||
|
||||
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
||||
@@ -47,13 +46,11 @@ interface ErrorPageProps {
|
||||
error: ElementCallError;
|
||||
recoveryActionHandler: RecoveryActionHandler;
|
||||
resetError: () => void;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
const ErrorPage: FC<ErrorPageProps> = ({
|
||||
error,
|
||||
recoveryActionHandler,
|
||||
widget,
|
||||
}: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
logger.error("Error boundary caught:", error);
|
||||
@@ -89,7 +86,6 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
Icon={icon}
|
||||
title={error.localisedTitle}
|
||||
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
|
||||
widget={widget}
|
||||
>
|
||||
<p>
|
||||
{error.localisedMessageKey ? (
|
||||
@@ -148,14 +144,12 @@ interface BoundaryProps {
|
||||
children: ReactNode | (() => ReactNode);
|
||||
recoveryActionHandler: RecoveryActionHandler;
|
||||
onError?: (error: unknown) => void;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const GroupCallErrorBoundary = ({
|
||||
recoveryActionHandler,
|
||||
onError,
|
||||
children,
|
||||
widget,
|
||||
}: BoundaryProps): ReactElement => {
|
||||
const fallbackRenderer: FallbackRender = useCallback(
|
||||
({ error, resetError }): ReactElement => {
|
||||
@@ -165,7 +159,6 @@ export const GroupCallErrorBoundary = ({
|
||||
: new UnknownCallError(error instanceof Error ? error : new Error());
|
||||
return (
|
||||
<ErrorPage
|
||||
widget={widget ?? null}
|
||||
error={callError}
|
||||
resetError={resetError}
|
||||
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
|
||||
@@ -175,7 +168,7 @@ export const GroupCallErrorBoundary = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[recoveryActionHandler, widget],
|
||||
[recoveryActionHandler],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -36,7 +36,6 @@ import userEvent, {
|
||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
import { useState } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { type ITransport } from "matrix-widget-api";
|
||||
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
@@ -52,8 +51,11 @@ import {
|
||||
} from "../utils/test";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
|
||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "../HostBridge";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
@@ -134,7 +136,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
function createGroupCallView(
|
||||
widget: WidgetHelpers | null,
|
||||
hostBridge: HostBridge,
|
||||
joined = true,
|
||||
options: {
|
||||
withErrorBoundary?: boolean;
|
||||
@@ -184,7 +186,6 @@ function createGroupCallView(
|
||||
skipLobby={false}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
||||
joined={true}
|
||||
setJoined={function (value: boolean): void {}}
|
||||
@@ -192,22 +193,21 @@ function createGroupCallView(
|
||||
);
|
||||
const { getByText } = render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
{groupCallView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
groupCallView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
||||
{groupCallView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
groupCallView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
</HostBridgeProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
return {
|
||||
@@ -218,7 +218,7 @@ function createGroupCallView(
|
||||
|
||||
test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByText, rtcSession } = createGroupCallView(null);
|
||||
const { getByText, rtcSession } = createGroupCallView(nullHostBridge);
|
||||
const leaveButton = getByText("Leave");
|
||||
await user.click(leaveButton);
|
||||
expect(playSound).toHaveBeenCalledWith("left");
|
||||
@@ -235,12 +235,7 @@ test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async
|
||||
|
||||
test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const widget = {
|
||||
api: {
|
||||
setAlwaysOnScreen: async () => Promise.resolve(true),
|
||||
} as Partial<WidgetHelpers["api"]>,
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
};
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() };
|
||||
let resolvePlaySound: () => void;
|
||||
playSound = vi
|
||||
.fn()
|
||||
@@ -253,9 +248,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
||||
soundDuration: {},
|
||||
});
|
||||
|
||||
const { getByText, rtcSession } = createGroupCallView(
|
||||
widget as WidgetHelpers,
|
||||
);
|
||||
const { getByText, rtcSession } = createGroupCallView(hostBridge);
|
||||
const leaveButton = getByText("Leave");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
@@ -272,28 +265,13 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
||||
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("Should close widget when all other left and play a sound", async () => {
|
||||
test("Should ask the host to close when all other left and play a sound", async () => {
|
||||
const user = userEvent.setup();
|
||||
let widgetClosedCalled = false;
|
||||
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
|
||||
Promise.withResolvers<void>();
|
||||
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
|
||||
if (action === ElementWidgetActions.Close) {
|
||||
widgetClosedCalled = true;
|
||||
widgetClosedResolver();
|
||||
}
|
||||
});
|
||||
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
||||
const widget = {
|
||||
api: {
|
||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
|
||||
transport: {
|
||||
send: widgetSendMock,
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
stop: widgetStopMock,
|
||||
} as unknown as ITransport,
|
||||
} as Partial<WidgetHelpers["api"]>,
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const hostBridge: HostBridge = {
|
||||
...nullHostBridge,
|
||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined),
|
||||
close,
|
||||
};
|
||||
const resolvePlaySound = Promise.withResolvers<void>();
|
||||
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
|
||||
@@ -303,50 +281,38 @@ test("Should close widget when all other left and play a sound", async () => {
|
||||
soundDuration: {},
|
||||
});
|
||||
|
||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
||||
const { getByText } = createGroupCallView(hostBridge);
|
||||
const leaveButton = getByText("SimulateOtherLeft");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
expect(widgetClosedCalled).toBeFalsy();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
resolvePlaySound.resolve();
|
||||
|
||||
expect(playSound).toHaveBeenCalledWith("left", 0);
|
||||
await widgetClosedPromise;
|
||||
await flushPromises();
|
||||
expect(widgetClosedCalled).toBeTruthy();
|
||||
expect(widgetStopMock).toHaveBeenCalledOnce();
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
}, 80000);
|
||||
|
||||
test("Should not close widget when auto leave due to error", async () => {
|
||||
test("Should not ask the host to close when auto leave due to error", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
||||
const widgetSendMock = vi.fn().mockResolvedValue(undefined);
|
||||
const widget = {
|
||||
api: {
|
||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
|
||||
transport: {
|
||||
send: widgetSendMock,
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
stop: widgetStopMock,
|
||||
} as unknown as ITransport,
|
||||
} as Partial<WidgetHelpers["api"]>,
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined);
|
||||
const hostBridge: HostBridge = {
|
||||
...nullHostBridge,
|
||||
setAlwaysOnScreen,
|
||||
close,
|
||||
};
|
||||
|
||||
const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen");
|
||||
|
||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
||||
const { getByText } = createGroupCallView(hostBridge);
|
||||
const leaveButton = getByText("SimulateErrorLeft");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
|
||||
// When onLeft is called, we first set always on screen to false
|
||||
await waitFor(() => expect(alwaysOnScreenSpy).toHaveBeenCalledWith(false));
|
||||
await waitFor(() => expect(setAlwaysOnScreen).toHaveBeenCalledWith(false));
|
||||
await flushPromises();
|
||||
// But then we do not close the widget automatically
|
||||
expect(widgetStopMock).not.toHaveBeenCalledOnce();
|
||||
expect(widgetSendMock).not.toHaveBeenCalledOnce();
|
||||
// But then we do not ask to be closed automatically
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
||||
@@ -360,7 +326,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
||||
);
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const { rtcSession } = createGroupCallView(null);
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge);
|
||||
await user.click(screen.getByRole("button", { name: "Panic!" }));
|
||||
screen.getByText("Something went wrong");
|
||||
expect(leaveRTCSession).toHaveBeenCalledWith(
|
||||
@@ -377,7 +343,7 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
|
||||
onTestFinished(() => {
|
||||
enterRTCSession.mockReset();
|
||||
});
|
||||
createGroupCallView(null, false);
|
||||
createGroupCallView(nullHostBridge, false);
|
||||
await user.click(screen.getByRole("button", { name: "Join call" }));
|
||||
screen.getByText("Call is not supported");
|
||||
});
|
||||
@@ -396,7 +362,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
||||
{ cause: stickyError },
|
||||
);
|
||||
|
||||
const { rtcSession } = createGroupCallView(null, true, {
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
|
||||
withErrorBoundary: true,
|
||||
});
|
||||
|
||||
@@ -408,7 +374,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
||||
});
|
||||
|
||||
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
|
||||
const { rtcSession } = createGroupCallView(null, true, {
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
|
||||
withErrorBoundary: true,
|
||||
});
|
||||
|
||||
@@ -424,7 +390,7 @@ test("falls back to ConnectionLostError for unrecognised membership manager erro
|
||||
|
||||
test("user can reconnect after a membership manager error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rtcSession } = createGroupCallView(null, true);
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, true);
|
||||
await act(() =>
|
||||
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
|
||||
);
|
||||
|
||||
+63
-70
@@ -30,12 +30,7 @@ import {
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
type JoinCallData,
|
||||
type WidgetHelpers,
|
||||
} from "../widget";
|
||||
import { type JoinCallData } from "../widget";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { CallEndedView } from "./CallEndedView";
|
||||
@@ -54,12 +49,7 @@ import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useRoomName } from "./useRoomName";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import {
|
||||
getUrlParams,
|
||||
HeaderStyle,
|
||||
type UrlParams,
|
||||
useUrlParams,
|
||||
} from "../UrlParams";
|
||||
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import {
|
||||
@@ -80,6 +70,8 @@ import { useTypedEventEmitter } from "../useEvents";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useAppBarTitle } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
import { useRootElement } from "../RootElementContext.ts";
|
||||
import { useHostBridge } from "../HostBridge.ts";
|
||||
|
||||
/**
|
||||
* If there already are this many participants in the call, we automatically mute
|
||||
@@ -103,7 +95,6 @@ interface Props {
|
||||
joined: boolean;
|
||||
setJoined: (value: boolean) => void;
|
||||
muteStates: MuteStates;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const GroupCallView: FC<Props> = ({
|
||||
@@ -116,13 +107,21 @@ export const GroupCallView: FC<Props> = ({
|
||||
joined,
|
||||
setJoined,
|
||||
muteStates,
|
||||
widget,
|
||||
}) => {
|
||||
// Used to thread through any errors that occur outside the error boundary
|
||||
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
||||
null,
|
||||
);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const rootElement = useRootElement();
|
||||
const hostBridge = useHostBridge();
|
||||
// A host that can close us is a host that decides when we stop existing, so
|
||||
// we neither show our own post-call screens nor assume we have time to
|
||||
// finish what we are doing.
|
||||
// TODO: this reads a capability as a proxy for who owns our lifetime. Worth
|
||||
// finding a more direct way to express it — see the guidance in UrlParams.ts
|
||||
// on naming behaviours rather than situations.
|
||||
const hostControlsLifetime = hostBridge.close !== undefined;
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
const leaveSoundContext = useLatest(
|
||||
@@ -150,11 +149,11 @@ export const GroupCallView: FC<Props> = ({
|
||||
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
|
||||
// tested on different devices...)
|
||||
useEffect(() => {
|
||||
document.body.classList.add("no-scroll-body");
|
||||
rootElement.classList.add("no-scroll-body");
|
||||
return (): void => {
|
||||
document.body.classList.remove("no-scroll-body");
|
||||
rootElement.classList.remove("no-scroll-body");
|
||||
};
|
||||
}, []);
|
||||
}, [rootElement]);
|
||||
|
||||
useEffect(() => {
|
||||
window.rtcSession = rtcSession;
|
||||
@@ -297,28 +296,26 @@ export const GroupCallView: FC<Props> = ({
|
||||
};
|
||||
|
||||
if (skipLobby) {
|
||||
if (widget && preload) {
|
||||
// `preload` is only ever set when we have a host to be preloaded by.
|
||||
if (preload) {
|
||||
// In preload mode without lobby we wait for a join action before entering
|
||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
const subscription = hostBridge.join$.subscribe(({ data, reply }) => {
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
await defaultDeviceSetup(data);
|
||||
setJoined(true);
|
||||
widget.api.transport.reply(ev.detail, {});
|
||||
reply();
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session on preload", e);
|
||||
});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
});
|
||||
return (): void => subscription.unsubscribe();
|
||||
} else {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
setJoined(true);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
widget,
|
||||
hostBridge,
|
||||
rtcSession,
|
||||
preload,
|
||||
skipLobby,
|
||||
@@ -344,7 +341,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
// When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer
|
||||
// already plays the "left" sound when the remote participant's media
|
||||
// disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise.
|
||||
// (used to destory the widget)
|
||||
// (which is what delays asking the host to close us)
|
||||
audioPromise = leaveSoundContext.current?.playSound("left", 0);
|
||||
break;
|
||||
case "timeout":
|
||||
@@ -359,12 +356,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
setLeft(true);
|
||||
|
||||
// We need to wait until the callEnded event is tracked on PostHog,
|
||||
// otherwise the iframe may get killed first.
|
||||
// otherwise we may be torn down first.
|
||||
const posthogRequest = new Promise((resolve) => {
|
||||
// To increase the likelihood of the PostHog event being sent out in
|
||||
// widget mode before the iframe is killed, we ask it to skip the
|
||||
// usual queuing/batching of requests.
|
||||
const sendInstantly = widget !== null;
|
||||
// To increase the likelihood of the PostHog event being sent out
|
||||
// before the host disposes of us, we ask it to skip the usual
|
||||
// queuing/batching of requests.
|
||||
const sendInstantly = hostControlsLifetime;
|
||||
PosthogAnalytics.instance.eventCallEnded.track(
|
||||
room.roomId,
|
||||
rtcSession.memberships.length,
|
||||
@@ -372,8 +369,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
rtcSession,
|
||||
);
|
||||
// Unfortunately the PostHog library provides no way to await the
|
||||
// tracking of an event, but we don't really want it to hold up the
|
||||
// closing of the widget that long anyway, so giving it 10 ms will do.
|
||||
// tracking of an event, but we don't really want it to hold up our
|
||||
// disposal that long anyway, so giving it 10 ms will do.
|
||||
window.setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
@@ -392,25 +389,19 @@ export const GroupCallView: FC<Props> = ({
|
||||
)
|
||||
void navigate("/");
|
||||
|
||||
if (widget) {
|
||||
// After this point the iframe could die at any moment!
|
||||
// After this point the host could dispose of us at any moment!
|
||||
try {
|
||||
await hostBridge.setAlwaysOnScreen(false);
|
||||
} catch (e) {
|
||||
logger.error("Failed to set `alwaysOnScreen` to false", e);
|
||||
}
|
||||
// On a normal user hangup we can shut down and ask to be closed. But
|
||||
// if an error occurs we should stay open until the user reads it.
|
||||
if (reason != "error" && !returnToLobby) {
|
||||
try {
|
||||
await widget.api.setAlwaysOnScreen(false);
|
||||
await hostBridge.close?.();
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
"Failed to set call widget `alwaysOnScreen` to false",
|
||||
e,
|
||||
);
|
||||
}
|
||||
// On a normal user hangup we can shut down and close the widget. But if an
|
||||
// error occurs we should keep the widget open until the user reads it.
|
||||
if (reason != "error" && !getUrlParams().returnToLobby) {
|
||||
try {
|
||||
await widget.api.transport.send(ElementWidgetActions.Close, {});
|
||||
} catch (e) {
|
||||
logger.error("Failed to send close action", e);
|
||||
}
|
||||
widget.api.transport.stop();
|
||||
logger.error("Failed to ask the host to close Element Call", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -418,22 +409,24 @@ export const GroupCallView: FC<Props> = ({
|
||||
[
|
||||
setJoined,
|
||||
leaveSoundContext,
|
||||
widget,
|
||||
hostBridge,
|
||||
hostControlsLifetime,
|
||||
room.roomId,
|
||||
rtcSession,
|
||||
isPasswordlessUser,
|
||||
confineToRoom,
|
||||
returnToLobby,
|
||||
navigate,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget && joined)
|
||||
// set widget to sticky once joined.
|
||||
widget.api.setAlwaysOnScreen(true).catch((e) => {
|
||||
if (joined)
|
||||
// ask to be kept on screen once joined.
|
||||
hostBridge.setAlwaysOnScreen(true).catch((e) => {
|
||||
logger.error("Error calling setAlwaysOnScreen(true)", e);
|
||||
});
|
||||
}, [widget, joined, rtcSession]);
|
||||
}, [hostBridge, joined, rtcSession]);
|
||||
|
||||
const joinRule = useJoinRule(room);
|
||||
|
||||
@@ -503,19 +496,16 @@ export const GroupCallView: FC<Props> = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (left && widget === null) {
|
||||
// Left in SPA mode:
|
||||
} else if (left && !hostControlsLifetime) {
|
||||
// Left, and it is up to us what to show next:
|
||||
|
||||
// The call ended view is shown for two reasons: prompting guests to create
|
||||
// an account, and prompting users that have opted into analytics to provide
|
||||
// feedback. We don't show a feedback prompt to widget users however (at
|
||||
// least for now), because we don't yet have designs that would allow widget
|
||||
// users to dismiss the feedback prompt and close the call window without
|
||||
// submitting anything.
|
||||
if (
|
||||
isPasswordlessUser ||
|
||||
(PosthogAnalytics.instance.isEnabled() && widget === null)
|
||||
) {
|
||||
// feedback. We don't show a feedback prompt when a host owns our lifetime
|
||||
// however (at least for now), because we don't yet have designs that would
|
||||
// allow those users to dismiss the feedback prompt and close the call
|
||||
// window without submitting anything.
|
||||
if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) {
|
||||
body = (
|
||||
<CallEndedView
|
||||
endedCallId={rtcSession.room.roomId}
|
||||
@@ -531,8 +521,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
// LobbyView again which would open capture devices again.
|
||||
body = null;
|
||||
}
|
||||
} else if (left && widget !== null) {
|
||||
// Left in widget mode:
|
||||
} else if (left && hostControlsLifetime) {
|
||||
// Left, and the host decides what happens next:
|
||||
body = returnToLobby ? lobbyView : null;
|
||||
} else if (preload || skipLobby) {
|
||||
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
|
||||
@@ -543,7 +533,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<GroupCallErrorBoundary
|
||||
widget={widget}
|
||||
recoveryActionHandler={async (action) => {
|
||||
setExternalError(null);
|
||||
if (action == "reconnect") {
|
||||
@@ -555,6 +544,10 @@ export const GroupCallView: FC<Props> = ({
|
||||
}}
|
||||
onError={(_error) => {
|
||||
if (rtcSession.isJoined()) onLeft("error");
|
||||
// If there is an error we need to be dismissible again. This is done in
|
||||
// `onLeft` as well; we need it here explicitly in case
|
||||
// rtcSession.isJoined is false.
|
||||
void hostBridge.setAlwaysOnScreen(false);
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { InCallView } from "./InCallView";
|
||||
import { ActiveCall, InCallView } from "./InCallView";
|
||||
import {
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
@@ -33,7 +33,10 @@ import {
|
||||
type MockRTCSession,
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import {
|
||||
getBasicCallViewModelEnvironment,
|
||||
getBasicRTCSession,
|
||||
} from "../utils/test-viewmodel";
|
||||
import {
|
||||
type CallViewModel,
|
||||
type CallViewModelOptions,
|
||||
@@ -45,6 +48,8 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
||||
import { AppBar } from "../AppBar";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { initializeWidget } from "../widget";
|
||||
|
||||
initializeWidget();
|
||||
@@ -78,6 +83,17 @@ const remoteParticipant = mockRemoteParticipant({
|
||||
identity: "@alice:example.org:AAAAAA",
|
||||
});
|
||||
|
||||
const matrixInfo = {
|
||||
userId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
e2eeSystem: { kind: E2eeType.NONE },
|
||||
} satisfies MatrixInfo;
|
||||
|
||||
let useRoomEncryptionSystemMock: MockedFunction<typeof useRoomEncryptionSystem>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -122,12 +138,13 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
remoteParticipants$: of([remoteParticipant]),
|
||||
},
|
||||
);
|
||||
const { vm, footerVm, rtcSession } = getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
undefined,
|
||||
mediaDevices,
|
||||
args.callViewModelOptions,
|
||||
);
|
||||
const { vm, footerVm, developerSettingsVm, rtcSession } =
|
||||
getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
undefined,
|
||||
mediaDevices,
|
||||
args.callViewModelOptions,
|
||||
);
|
||||
|
||||
rtcSession.joined = true;
|
||||
const room = rtcSession.room;
|
||||
@@ -140,18 +157,8 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
muteStates={muteState}
|
||||
vm={vm}
|
||||
footerVm={footerVm}
|
||||
matrixInfo={{
|
||||
userId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
e2eeSystem: {
|
||||
kind: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
developerSettingsVm={developerSettingsVm}
|
||||
matrixInfo={matrixInfo}
|
||||
matrixRoom={room}
|
||||
onShareClick={null}
|
||||
/>
|
||||
@@ -226,3 +233,34 @@ describe("InCallView", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActiveCall", () => {
|
||||
it("creates the view models and renders the call", async () => {
|
||||
const mediaDevices = mockMediaDevices({});
|
||||
const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]);
|
||||
const { findByTestId } = render(
|
||||
<BrowserRouter>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<TooltipProvider>
|
||||
<RoomContext value={mockLivekitRoom({ localParticipant })}>
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
matrixRoom={matrixRoom}
|
||||
muteStates={mockMuteStates()}
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
onLeft={(): void => {}}
|
||||
/>
|
||||
</RoomContext>
|
||||
</TooltipProvider>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
// Rendering at all proves ActiveCall created all of its view models
|
||||
expect(await findByTestId("incall_leave")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+37
-11
@@ -28,7 +28,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { widget } from "../widget";
|
||||
import { useHostBridge } from "../HostBridge.ts";
|
||||
import styles from "./InCallView.module.css";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
@@ -41,6 +41,7 @@ import { type MatrixInfo } from "./VideoPreview";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import {
|
||||
type CallViewModel,
|
||||
callViewModelOptionsFromParams,
|
||||
createCallViewModel$,
|
||||
} from "../state/CallViewModel/CallViewModel.ts";
|
||||
import { Grid, type TileProps } from "../grid/Grid";
|
||||
@@ -49,8 +50,8 @@ import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { makeGridLayout } from "../grid/GridLayout";
|
||||
import { type CallLayoutOutputs } from "../grid/CallLayout";
|
||||
import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout";
|
||||
import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout";
|
||||
import { makeOneOnOneDesktopLayout } from "../grid/OneOnOneDesktopLayout";
|
||||
import { makeOneOnOneMobileLayout } from "../grid/OneOnOneMobileLayout";
|
||||
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
||||
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
||||
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
||||
@@ -83,6 +84,8 @@ import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||
import { SettingsIconButton } from "../button/Button.tsx";
|
||||
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
|
||||
import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel.ts";
|
||||
import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab.tsx";
|
||||
import { type ViewModel } from "../state/ViewModel.ts";
|
||||
import { RingingStatus } from "../tile/RingingStatus.tsx";
|
||||
import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
|
||||
@@ -96,7 +99,7 @@ declare module "react" {
|
||||
|
||||
export interface ActiveCallProps extends Omit<
|
||||
InCallViewProps,
|
||||
"vm" | "livekitRoom" | "connState" | "footerVm"
|
||||
"vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm"
|
||||
> {
|
||||
e2eeSystem: EncryptionSystem;
|
||||
// TODO refactor those reasons into an enum
|
||||
@@ -110,7 +113,11 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
|
||||
null,
|
||||
);
|
||||
const [developerSettingsVm, setDeveloperSettingsVm] =
|
||||
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
|
||||
|
||||
const urlParams = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
const mediaDevices = useMediaDevices();
|
||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||
useEffect(() => {
|
||||
@@ -127,7 +134,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
mediaDevices,
|
||||
props.muteStates,
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
hostBridge,
|
||||
autoLeaveWhenOthersLeft,
|
||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||
matrixRTCMode$: matrixRTCModeSetting.value$,
|
||||
@@ -152,6 +161,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.e2eeSystem,
|
||||
props.onLeft,
|
||||
urlParams,
|
||||
hostBridge,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
props.client,
|
||||
@@ -167,8 +177,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
||||
{ showControls: urlParams.showControls, header: urlParams.header },
|
||||
);
|
||||
setFooterVm(footerVm);
|
||||
setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm));
|
||||
|
||||
return (): void => {
|
||||
scope.end();
|
||||
@@ -188,10 +200,16 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
|
||||
if (vm === null) return null;
|
||||
if (footerVm === null) return null;
|
||||
if (developerSettingsVm === null) return null;
|
||||
|
||||
return (
|
||||
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||
<InCallView {...props} vm={vm} footerVm={footerVm} />
|
||||
<InCallView
|
||||
{...props}
|
||||
vm={vm}
|
||||
footerVm={footerVm}
|
||||
developerSettingsVm={developerSettingsVm}
|
||||
/>
|
||||
</ReactionsSenderProvider>
|
||||
);
|
||||
};
|
||||
@@ -200,6 +218,7 @@ export interface InCallViewProps {
|
||||
client: MatrixClient;
|
||||
vm: CallViewModel;
|
||||
footerVm: ViewModel<FooterSnapshot>;
|
||||
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
|
||||
matrixInfo: MatrixInfo;
|
||||
rtcSession: MatrixRTCSession;
|
||||
matrixRoom: MatrixRoom;
|
||||
@@ -211,6 +230,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
client,
|
||||
vm,
|
||||
footerVm,
|
||||
developerSettingsVm,
|
||||
matrixInfo,
|
||||
matrixRoom,
|
||||
muteStates,
|
||||
@@ -218,6 +238,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
}) => {
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
const { t } = useTranslation();
|
||||
const hostBridge = useHostBridge();
|
||||
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
||||
|
||||
useWakeLock();
|
||||
@@ -261,6 +282,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const overflowing = useBehavior(vm.overflowing$);
|
||||
const showNameTags = useBehavior(vm.showNameTags$);
|
||||
const showHeader = useBehavior(vm.showHeader$);
|
||||
const showModals = useBehavior(vm.showModals$);
|
||||
const settingsOpen = useBehavior(vm.settingsOpen$);
|
||||
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
|
||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||
@@ -300,14 +322,17 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
const openProfile = useMemo(
|
||||
() =>
|
||||
// Profile settings are unavailable in widget mode
|
||||
widget === null
|
||||
// A host that can dismiss us is a host that owns the user's account, so
|
||||
// their profile is not ours to edit.
|
||||
// TODO: another use of the close capability as a proxy — see the note in
|
||||
// GroupCallView.
|
||||
hostBridge.close === undefined
|
||||
? (): void => {
|
||||
setSettingsTab("profile");
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
: null,
|
||||
[setSettingsTab, setSettingsOpen],
|
||||
[setSettingsTab, setSettingsOpen, hostBridge],
|
||||
);
|
||||
|
||||
const [headerRef, headerBounds] = useMeasure();
|
||||
@@ -483,8 +508,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
|
||||
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
|
||||
"one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs),
|
||||
"one-on-one-portrait": makeOneOnOnePortraitLayout(inputs),
|
||||
"one-on-one-desktop": makeOneOnOneDesktopLayout(inputs),
|
||||
"one-on-one-mobile": makeOneOnOneMobileLayout(inputs),
|
||||
};
|
||||
}, [gridBoundsObservable$]);
|
||||
|
||||
@@ -622,7 +647,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{earpieceOverlay}
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
{showModals && (
|
||||
<>
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
<SettingsModal
|
||||
@@ -632,6 +657,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
onDismiss={(): void => setSettingsOpen(false)}
|
||||
tab={settingsTab}
|
||||
onTabChange={setSettingsTab}
|
||||
developerSettingsVm={developerSettingsVm}
|
||||
livekitRooms={allConnections
|
||||
.getConnections()
|
||||
.map((connectionItem) => ({
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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 { useId, type FC } from "react";
|
||||
import {
|
||||
SpotlightViewIcon,
|
||||
GridIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch } from "@vector-im/compound-web";
|
||||
|
||||
interface Props {
|
||||
vm: LayoutSwitchViewModel;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LayoutSwitch: FC<Props> = ({ vm, className }) => {
|
||||
const { t } = useTranslation();
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const name = useId();
|
||||
|
||||
return (
|
||||
<Switch<"spotlight", "grid">
|
||||
name={name}
|
||||
aria-label={t("layout_switch_label")}
|
||||
leftLabel={t("layout_spotlight_label")}
|
||||
leftValue="spotlight"
|
||||
leftIcon={SpotlightViewIcon}
|
||||
rightLabel={t("layout_grid_label")}
|
||||
rightValue="grid"
|
||||
rightIcon={GridIcon}
|
||||
className={className}
|
||||
value={layout}
|
||||
onChange={vm.setLayout}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user