Merge remote-tracking branch 'origin/livekit' into hs/compound-switch

This commit is contained in:
Half-Shot
2026-04-27 10:03:41 +01:00
58 changed files with 1059 additions and 363 deletions

View File

@@ -1,12 +0,0 @@
#!/usr/bin/sh
FILE=.links.cjs
FILE_DIS=.links.temp-disabled.cjs
if test -f "$FILE_DIS"; then
# Only do the post-commit hook if the file was temp-disabled by the pre-commit hook.
# Otherwise linking was actively (`yarn links:disable`) disabled and this hook should noop.
mv $FILE_DIS $FILE
yarnLog=$(yarn)
echo "[yarn-linker] The post-commit hook has re-enabled $FILE"
exit 1
fi

View File

@@ -1,12 +1,9 @@
#!/usr/bin/sh
#!/usr/bin/env bash
FILE=.links.cjs
FILE_DIS=.links.temp-disabled.cjs
if test -f "$FILE"; then
mv $FILE .links.temp-disabled.cjs
# echo "running yarn"
x=$(pnpm install)
y=$(git add pnpm-lock.yaml)
echo "[yarn-linker] The pre-commit hook has disabled $FILE and MODIFIED the pnpm-lock.yaml file. Review the staged changes (the hook added pnpm-lock.yaml, was this desired?) and run \`git commit \` again if they look okay. The post-commit hook will re-enable your links."
# Checks if there currently is linking configured. Informs the user to disable linking before committing.
PNPMFILE=.pnpmfile.cjs
if test -f "$PNPMFILE"; then
echo "[pnpm-linker] The pre-commit hook detected $PNPMFILE which implies you have linked packages in your pnpm-lock.yaml. Run pnpm links:off and commit again. See also linking.md."
exit 1
fi

View File

@@ -43,7 +43,8 @@ jobs:
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: "pnpm install --frozen-lockfile"
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Build Element Call
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
env:

View File

@@ -18,7 +18,8 @@ jobs:
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: "pnpm install --frozen-lockfile"
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Prettier
run: "pnpm run prettier:check"
- name: i18n

View File

@@ -42,7 +42,7 @@ jobs:
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ vars.NETLIFY_SITE_ID }}
site_id: ${{ secrets.NETLIFY_SITE_ID }}
desc: Playwright Report
deployment_env: EndToEndTests
prefix: "e2e-"

View File

@@ -20,7 +20,8 @@ jobs:
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: "pnpm install --frozen-lockfile"
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Vitest
run: "pnpm run test:coverage"
- name: Upload to codecov
@@ -45,7 +46,8 @@ jobs:
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: pnpm install --frozen-lockfile
# 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: Run backend components

View File

@@ -26,7 +26,8 @@ jobs:
node-version-file: ".node-version"
- name: Install Deps
run: "pnpm install --frozen-lockfile"
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Prune i18n
run: "rm -R locales"

View File

@@ -108,17 +108,17 @@ recommended method for embedding Element Call.
</p>
For more details on the packages, see the
[Embedded vs. Standalone Guide](./docs/embedded-standalone.md).
[Embedded vs. Standalone Guide](./docs/embedded_standalone.md).
## 🛠️ Self-Hosting
For operating and deploying Element Call on your own server, refer to the
[**Self-Hosting Guide**](./docs/self-hosting.md).
[**Self-Hosting Guide**](./docs/self_hosting.md).
## 🧭 MatrixRTC Backend Discovery and Selection
For proper Element Call operation each site deployment needs a MatrixRTC backend
setup as outlined in the [Self-Hosting](#self-hosting). A typical federated site
setup as outlined in the [Self-Hosting](#self_hosting). A typical federated site
deployment for three different sites A, B and C is depicted below.
<p align="center">
@@ -186,7 +186,7 @@ To get started clone and set up this project:
git clone https://github.com/element-hq/element-call.git
cd element-call
corepack enable
yarn
pnpm install
```
To use it, create a local config by, e.g.,
@@ -197,7 +197,7 @@ environment as outlined in the next section out of box.
You're now ready to launch the development server:
```sh
yarn dev
pnpm dev
```
See also:
@@ -230,7 +230,7 @@ only for local development and **_never be exposed to the public Internet._**
Run backend components:
```sh
yarn backend
pnpm backend
# or for podman-compose
# podman-compose -f dev-backend-docker-compose.yml up
```
@@ -260,13 +260,13 @@ on https://localhost:3000 (this is configured in `playwright.config.ts`) - this
is what will be tested.
The local backend environment should be running for the test to work:
`yarn backend`
`pnpm backend`
There are a few different ways to run the tests yourself. The simplest is to
run:
```shell
yarn run test:playwright
pnpm run test:playwright
```
This will run the Playwright tests once, non-interactively.
@@ -274,7 +274,7 @@ This will run the Playwright tests once, non-interactively.
There is a more user-friendly way to run the tests in interactive mode:
```shell
yarn run test:playwright:open
pnpm run test:playwright:open
```
The easiest way to develop new test is to use the codegen feature of Playwright:
@@ -316,7 +316,7 @@ To add a new translation key you can do these steps:
1. Add the new key entry to the code where the new key is used:
`t("some_new_key")`
1. Run `yarn i18n` to extract the new key and update the translation files. This
1. Run `pnpm i18n` to extract the new key and update the translation files. This
will add a skeleton entry to the `locales/en/app.json` file:
```jsonc

View File

@@ -1,6 +1,6 @@
# Testing Element-Call in widget mode
When running `yarn backend` the latest element-web develop will be deployed and served on `http://localhost:8081`.
When running `pnpm backend` the latest element-web develop will be deployed and served on `http://localhost:8081`.
In a development environment, you might prefer to just use the `element-web` repo directly, but this setup is useful for CI/CD testing.
## Setup
@@ -18,7 +18,7 @@ that uses
It is part of the existing backend setup. To start the backend, run:
```sh
yarn backend
pnpm backend
```
Then open `http://localhost:8081` in your browser.

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true
enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true
enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true

View File

@@ -18,3 +18,7 @@ keys:
devkey: secret
room:
auto_create: false
webhook:
api_key: devkey
urls:
- https://matrix-rtc.othersite.m.localhost/livekit/jwt/sfu_webhook

View File

@@ -18,3 +18,7 @@ keys:
devkey: secret
room:
auto_create: false
webhook:
api_key: devkey
urls:
- https://matrix-rtc.m.localhost/livekit/jwt/sfu_webhook

View File

@@ -28,11 +28,18 @@ server {
# Reason: the lk-jwt-service uses the federation API for the openid token
# verification, which requires TLS
location ~ ^(/_matrix|/_synapse/client) {
proxy_pass "http://homeserver:8008";
proxy_pass "http://homeserver:8008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Host $host;
}
location ~ ^(/_matrix|/_synapse/admin) {
proxy_pass "http://homeserver:8008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
@@ -73,7 +80,15 @@ server {
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Host $host;
}
location ~ ^(/_matrix|/_synapse/admin) {
proxy_pass "http://homeserver-1:18008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
@@ -108,7 +123,7 @@ server {
# JWT Service running at port 6080
proxy_pass http://jwt-auth-services/;
}
location ^~ /livekit/sfu/ {
@@ -128,7 +143,7 @@ server {
# LiveKit SFU websocket connection running at port 7880
proxy_pass http://livekit-sfu:7880/;
}
error_page 500 502 503 504 /50x.html;
}
@@ -156,7 +171,7 @@ server {
# JWT Service running at port 16080
proxy_pass http://auth-service-1:16080/;
}
location ^~ /livekit/sfu/ {
@@ -176,14 +191,14 @@ server {
# LiveKit SFU websocket connection running at port 17880
proxy_pass http://livekit-sfu-1:17880/;
}
error_page 500 502 503 504 /50x.html;
}
# Convenience reverse proxy for the call.m.localhost domain to element call
# running on the host either via
# - yarn dev --host or
# - pnpm dev --host or
# - falling back to http (the element call docker container)
server {
listen 80;
@@ -260,7 +275,7 @@ server {
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}
@@ -293,7 +308,7 @@ server {
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true
enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true
enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true

View File

@@ -62,7 +62,10 @@ services:
- 7882:7882/tcp
- 50100-50200:50100-50200/udp
volumes:
- ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
- ./backend/dev_livekit.yaml:/etc/livekit.yaml:Z
environment:
- SSL_CERT_FILE=/local_cert.pem
networks:
- ecbackend
@@ -82,7 +85,10 @@ services:
- 17882:17882/tcp
- 50300-50400:50300-50400/udp
volumes:
- ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
- ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
environment:
- SSL_CERT_FILE=/local_cert.pem
networks:
- ecbackend
@@ -164,6 +170,8 @@ services:
- "8448:8448"
extra_hosts:
- "host.docker.internal:host-gateway"
- "auth-server:127.0.0.1"
- "auth-server-1:127.0.0.1"
depends_on:
- synapse
networks:

View File

@@ -2,8 +2,8 @@
This folder contains documentation for setup, usage, and development of Element Call.
- [Embedded vs standalone mode](./embedded-standalone.md)
- [Url format and parameters](./url-params.md)
- [Embedded vs standalone mode](./embedded_standalone.md)
- [Url format and parameters](./url_params.md)
- [Global JS controls](./controls.md)
- [Self-Hosting](./self-hosting.md)
- [Self-Hosting](./self_hosting.md)
- [Developing with linked packages](./linking.md)

View File

@@ -14,7 +14,7 @@ The table below provides a comparison of the two packages:
| **Release artifacts** | Docker Image, Tarball | Tarball, NPM for Web, Android AAR, SwiftPM for iOS |
| **Recommended for** | Standalone/guest access usage | Embedding within messenger apps |
| **Responsibility for regulatory compliance** | The administrator that is deploying the app is responsible for compliance with any applicable regulations (e.g. privacy) | The developer of the messenger app is responsible for compliance |
| **Analytics consent** | Element Call will show a consent UI. | Element Call will not show a consent UI. The messenger app should only provide the embedded Element Call with the [analytics URL parameters](./url-params.md#embedded-only-parameters) if consent has been granted. |
| **Analytics consent** | Element Call will show a consent UI. | Element Call will not show a consent UI. The messenger app should only provide the embedded Element Call with the [analytics URL parameters](./url_params.md#embedded-only-parameters) if consent has been granted. |
| **Analytics data** | Element Call will send data to the Posthog, Sentry and Open Telemetry targets specified by the administrator in the `config.json` | Element Call will send data to the Posthog and Sentry targets specified in the URL parameters by the messenger app |
### Using the embedded package within a messenger app
@@ -26,7 +26,7 @@ The basics are:
1. Add the appropriate platform dependency as given for a [release](https://github.com/element-hq/element-call/releases), or use the embedded tarball. e.g. `npm install @element-hq/element-call-embedded@0.9.0`
2. Include the assets from the platform dependency in the build process. e.g. copy the assets during a [Webpack](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/webpack.config.js#L677-L682) build.
3. Use the `index.html` entrypointof the imported assets when you are constructing the WebView or iframe. e.g. using a [relative path in a webapp](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/src/models/Call.ts#L680), or on the the Android [WebViewAssetLoader](https://github.com/element-hq/element-x-android/blob/fe5aab6588ecdcf9354a3bfbd9e97c1b31175a8f/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt#L20)
4. Set any of the [embedded-only URL parameters](./url-params.md#embedded-only-parameters) that you need.
4. Set any of the [embedded-only URL parameters](./url_params.md#embedded-only-parameters) that you need.
## Widget vs standalone mode
@@ -36,4 +36,4 @@ As a widget, the app only uses the core calling (MatrixRTC) parts. The rest (aut
Element Call and the hosting client are connected via the widget API.
Element Call detects that it is run as a widget if a widgetId is defined in the url parameters. If `widgetId` is present then Element Call will try to connect to the client via the widget postMessage API using the parameters provided in [Url Format and parameters
](./url-params.md).
](./url_params.md).

View File

@@ -1,8 +1,28 @@
## Quickstart guide
run
```bash
./scripts/setup-linking.sh
```
Read the script output:
```
Setup complete.
Update: .links.cjs to your liking
Run: 'pnpm links:on' to test your .links.cjs
Run: 'git commit' with links enabled to test the git pre-commit hook.
Run: 'pnpm links:off' to be able to commit again
Run: 'git config --local core.hooksPath ""' to allow committing with linking (not recommended)
Run: 'rm links.cjs' & 'git config --local core.hooksPath ""' to fully revert what this script did
```
# Developing with linked packages
If you want to make changes to a package that Element Call depends on and see those changes applied in real time, you can create a link to a local copy of the package. Pnpm has a command for this (`pnpm link`), but it's not recommended to use it as it ends up modifying package.json with details specific to your development environment.
If you want to make changes to a package that Element Call depends on and see those changes applied in real time, you can create a link to a local copy of the package. `pnpm` has a command for this (`pnpm link`), but it's not recommended to use it as it ends up modifying package.json with details specific to your development environment.
Instead, you can use our little 'linker' plugin. Create a file named `.links.cjs` in the Element Call project directory, listing the names and paths of any dependencies you want to link. For example:
Instead, create a file named `.links.cjs` in the Element Call project directory (or run `./scripts/setup-linking.sh` to create a template), listing the names and paths of any dependencies you want to link. For example:
```cjs
// Packages to link to local checkouts
@@ -12,32 +32,34 @@ module.exports = {
};
```
Then run `pnpm install`.
Then run `pnpm links:on`. (this will activate the pnpm file + run `pnpm install` to setup the linking)
## Hooks
Changes in `.links.yaml` will also update `pnpm-lock.yaml` when `pnpm` is executed. The lockfile will then contain the local
Changes in `.links.cjs` will also update `pnpm-lock.yaml` when `pnpm install` is executed. The lockfile will then contain the local
version of the package which would not work on others dev setups or the github CI.
One always needs to run:
One always needs to remove the pnpm `readPackage` script (the `.pnpmfile.cjs`) and run:
```bash
mv .links.cjs .links.disabled.cjs
yarn
pnpm install
```
before committing a change.
To make it more convenient to work with this linking system we added git hooks.
A `pre-commit` hook will run `mv .links.yaml .links.disabled.yaml`, `yarn` and `git add yarn.lock` if it detects
a `.links.yaml` file and abort the commit.
You will than need to check if the resulting changes are appropriate and commit again.
To make this less of a foot gun we added a git hook.
A `pre-commit` hook will check if linking is currently used. If it detects
a `.pnpmfile.cjs` file it will abort the commit with an explanatory message.
You will than need to run `pnpm links:off` and commit again.
A `post-commit` hook will setup the linking as it was
before if a `.links.disabled.yaml` is present. It runs `mv .links.disabled.yaml .links.yaml` and `yarn`.
To activate the hooks automatically configure git with
To activate the hooks configure git with (when using the setup script (`./scripts/setup-linking.sh`) this is already done):
```bash
git config --local core.hooksPath .githooks
```
This will add the hook path for this repository only to .gihooks. which is a tracked (by git) folder containing the pre-commit hook.
## Background
Information, why this approach is used can be found in the [linking concept reasoning](./linking_concept_reasoning.md) document.

View File

@@ -0,0 +1,30 @@
### Why do we not enable .pnpmfile.cjs by default
Background: The presence of the `.pnpmfile.cjs` adds a field to the `pnpm-lock.yaml` called: `pnpmfileChecksum`. This field is a checksum of the content of the `.pnpmfile.cjs` file.
`pnpm install --frozen-lockfile` **fails** if there is a `.pnpmfile.cjs` but no `pnpmfileChecksum` or vice versa (or on mismatch).
_TLDR: running with `--ignore-pnpmfile` will fail if `pnpmfileChecksum` is present._
#### `pnpmfileChecksum` + renovate bot
When the renovate bot creates a PR it runs `pnpm install --ignore-pnpmfile`. This means that the `pnpmfileChecksum` in the lockfile will be **empty**.
This breaks builds that **don't** ignore the `.pnpmfile.cjs`-file. (CI that runs on the renovate PR)
From here we have two possible paths:
- ignore `.pnpmfile.cjs` in all CI builds CI will also fail if we accidently add it locally.
- fixup the `pnpm-lock.yaml` in the renovate PR to contain the correct `pnpmfileChecksum`.
Ignoring in all CI builds means that CI will always fail if we enable the linking system.
This is annoying but can be worked around with the git hook we provide that at least lets us know that we are
commiting with enabled linking.
Only if we remember setting it back/disbale linking (or let ourselves remember by the git hook) the CI will work.
#### Summary
- We will always run into conflicts with the `pnpmfileChecksum` because in renovate prs it will be empty (`--ignore-pnpmfile`)
- To keep it simple we set `--ignore-pnpmfile` in all of ours CI to see issues immediately.
- The only solution is to never have a `.pnpmfile.cjs` in the repository when pushing.
- This way there will never be a commit with `pnpmfileChecksum` in the lockfile.
- renovate (which uses `--ignore-pnpmfile` which we cannot disable) and other CI will work
- We are able to use the linking system locally if we `cp` this file from the scripts folder into `./` on demand.
- `pnpm links:on` and `pnpm links:off` + `./scripts/setup-linking.sh` will help us with this.

View File

@@ -237,8 +237,8 @@ source. First, clone and install the package:
git clone https://github.com/element-hq/element-call.git
cd element-call
corepack enable
yarn
yarn build
pnpm install
pnpm build
```
If all went well, you can now find the build output under `dist` as a series of

View File

@@ -4,7 +4,7 @@ There are two formats for Element Call URLs.
## Link for sharing
Requires Element Call to be deployed in [standalone](./embedded-standalone.md) mode.
Requires Element Call to be deployed in [standalone](./embedded_standalone.md) mode.
```text
https://element_call.domain/room/#
@@ -36,15 +36,15 @@ possible to support encryption.
| Package | Deployment | URL |
| ------------------------------------ | ----------------------------- | ----------------------------------------------------------------------------- |
| [Full](./embedded-standalone.md) | All | `https://element_call.domain/room` |
| [Embedded](./embedded-standalone.md) | Remote URL | `https://element_call.domain/` n.b. no `/room` part |
| [Embedded](./embedded-standalone.md) | Embedded within messenger app | Platform dependent, but you load the `index.html` file without a `/room` part |
| [Full](./embedded_standalone.md) | All | `https://element_call.domain/room` |
| [Embedded](./embedded_standalone.md) | Remote URL | `https://element_call.domain/` n.b. no `/room` part |
| [Embedded](./embedded_standalone.md) | Embedded within messenger app | Platform dependent, but you load the `index.html` file without a `/room` part |
## Parameters
### Common Parameters
These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes:
These parameters are relevant to both [widget](./embedded_standalone.md) and [standalone](./embedded_standalone.md) modes:
| Name | Values | Required for widget | Required for SPA | Description |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -74,7 +74,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
### Widget-only parameters
These parameters are only supported in [widget](./embedded-standalone.md) mode.
These parameters are only supported in [widget](./embedded_standalone.md) mode.
| Name | Values | Required | Description |
| --------------- | ----------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -89,7 +89,7 @@ These parameters are only supported in [widget](./embedded-standalone.md) mode.
### Embedded-only parameters
These parameters are only supported in the [embedded](./embedded-standalone.md) package of Element Call and will be ignored in the [full](./embedded-standalone.md) package.
These parameters are only supported in the [embedded](./embedded_standalone.md) package of Element Call and will be ignored in the [full](./embedded_standalone.md) package.
| Name | Values | Required | Description |
| -------------------- | -------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -11,7 +11,7 @@ pushd $CURRENT_DIR > /dev/null
function build_assets() {
echo "Generating Element Call assets..."
pushd ../.. > /dev/null
yarn build
pnpm build
popd > /dev/null
}
@@ -26,7 +26,7 @@ function copy_assets() {
}
getopts :sh opt
case $opt in
case $opt in
s)
SKIP=1
;;
@@ -41,7 +41,7 @@ if [ ! $SKIP ]; then
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
build_assets
else
else
echo "Using existing assets from ../../dist"
fi
copy_assets
@@ -56,4 +56,4 @@ echo "Publishing the Android project"
./gradlew publishAndReleaseToMavenCentral --no-daemon
popd > /dev/null
popd > /dev/null

View File

@@ -18,6 +18,7 @@ export default {
// https://docs.docker.com/compose/migrate/
"docker-compose",
],
ignoreFiles: ["scripts/.pnpmfile.cjs"],
ignoreDependencies: [
// Used in CSS
"normalize.css",
@@ -30,16 +31,10 @@ export default {
"@types/content-type",
"@types/sdp-transform",
"@types/uuid",
// We obviously use this, but if the package has been linked with yarn link,
// We obviously use this, but if the package has been linked with pnpm link,
// then Knip will flag it as a false positive
// https://github.com/webpro-nl/knip/issues/766
"@vector-im/compound-web",
// Yarn plugins are allowed to depend on packages provided by the Yarn
// runtime. These shouldn't be listed in package.json, because plugins
// should work before Yarn even installs dependencies for the first time.
// https://yarnpkg.com/advanced/plugin-tutorial#what-does-a-plugin-look-like
"@yarnpkg/core",
"@yarnpkg/parsers",
"matrix-widget-api",
],
ignoreExportsUsedInFile: true,

View File

@@ -32,8 +32,8 @@
"backend-playwright": "docker-compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up",
"test:playwright": "playwright test",
"test:playwright:open": "pnpm test:playwright --ui",
"links:enable": "mv .links.disabled.cjs .links.cjs & touch .links.cjs",
"links:disable": "mv .links.cjs .links.disabled.cjs",
"links:on": "cp scripts/.pnpmfile.cjs .pnpmfile.cjs & pnpm install",
"links:off": "rm .pnpmfile.cjs & pnpm install",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
@@ -132,7 +132,7 @@
"typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"vaul": "^1.0.0",
"vite": "^8.0.0",
"vite-plugin-generate-file": "^0.3.0",
@@ -156,5 +156,5 @@
"esbuild": "^0.27.7"
}
},
"packageManager": "pnpm@9.0.0"
"packageManager": "pnpm@10.33.0"
}

View File

@@ -7,11 +7,22 @@ Please see LICENSE in the repository root for full details.
*/
import { defineConfig, devices } from "@playwright/test";
import { join } from "path";
import path from "node:path";
import { fileURLToPath } from "node:url";
const baseURL = process.env.USE_DOCKER
? "http://localhost:8080"
: "https://localhost:3000";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Needed by the synapse admin API called in fixtures
process.env.NODE_EXTRA_CA_CERTS = join(
__dirname,
"backend/dev_tls_local-ca.crt",
);
/**
* See https://playwright.dev/docs/test-configuration.
*/

View File

@@ -75,9 +75,7 @@ test("Should automatically retry non fatal JWT errors", async ({
await expect(page.getByTestId("video").first()).toBeVisible();
});
// We skip this test for now as it appears the livekit does not let us
// detect and handle NotAllowed errors anymore. https://github.com/livekit/client-sdk-js/issues/1883
test.skip("Should show error screen if call creation is restricted", async ({
test("Should show error screen if call creation is restricted", async ({
page,
browserName,
}) => {

View File

@@ -91,7 +91,9 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1
.getByRole("button", { name: "Invite to this room", exact: true })
.click();
.click({
timeout: 10000,
});
await expect(
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
).toBeVisible();
@@ -104,6 +106,7 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId);
await ewPage1.getByRole("dialog").getByRole("textbox").click();
await ewPage1.getByRole("button", { name: "Invite" }).click();
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
// Accept the invite
await expect(
@@ -126,6 +129,7 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1.getByRole("textbox", { name: "Search" }).click();
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
await ewPage1.getByRole("button", { name: "Go" }).click();
await TestHelpers.dismissInviteUnknownUserModalDM(ewPage1);
// Wait and send the first message to create the DM
await expect(

View File

@@ -53,7 +53,6 @@ test("@mobile Start a new call then leave and show the feedback screen", async (
mobileTest(
"Test earpiece overlay in controlledAudioDevices mode",
async ({ asMobile, browser }) => {
test.slow(); // Triples the timeout
const { creatorPage, inviteLink } = asMobile;
// ========

View File

@@ -9,7 +9,9 @@ import { expect, test } from "@playwright/test";
test("When creator left, avoid reconnect to the same SFU", async ({
browser,
browserName,
}) => {
test.skip(browserName === "firefox", "Browser independent");
// Use reduce motion to disable animations that are making the tests a bit flaky
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
const creatorPage = await creatorContext.newPage();
@@ -91,8 +93,10 @@ test("When creator left, avoid reconnect to the same SFU", async ({
// the creator leaves the call
await creatorPage.getByTestId("incall_leave").click();
await guestCPage.waitForTimeout(2000);
// https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1);
// Wait a bit to be sure that if there was a reconnect, it would have happened by now
await guestCPage.waitForTimeout(6000);
expect(wsConnectionCount).toBe(1);
});

View File

@@ -28,7 +28,7 @@ async function setupTwoUserSpaCall(
await page.goto("/");
let androlHasSentStickyEvent = false;
const androlResolver = Promise.withResolvers<void>();
await interceptEventSend(
page,
// This room is not encrypted, so the event is sent in clear
@@ -36,6 +36,7 @@ async function setupTwoUserSpaCall(
(req) => {
androlHasSentStickyEvent =
androlHasSentStickyEvent || isStickySend(req.url());
androlResolver.resolve();
},
);
@@ -53,6 +54,7 @@ async function setupTwoUserSpaCall(
let pevaraHasSentStickyEvent = false;
const pevaraResolver = Promise.withResolvers<void>();
await interceptEventSend(
guestPage,
// This room is not encrypted, so the event is sent in clear
@@ -60,6 +62,7 @@ async function setupTwoUserSpaCall(
(req) => {
pevaraHasSentStickyEvent =
pevaraHasSentStickyEvent || isStickySend(req.url());
pevaraResolver.resolve();
},
);
@@ -70,7 +73,9 @@ async function setupTwoUserSpaCall(
"2_0",
);
// Assert both sides have sent sticky membership events
await androlResolver.promise;
expect(androlHasSentStickyEvent).toEqual(true);
await pevaraResolver.promise;
expect(pevaraHasSentStickyEvent).toEqual(true);
return { guestPage };
@@ -110,8 +115,12 @@ test("One to One rejoin after improper leave does not crash EC", async ({
await guestPage.getByTestId("lobby_joinCall").click();
// We cannot use the `expectVideoTilesCount` helper here since one of them is expected to show waiting for media
await expect(page.getByTestId("videoTile")).toHaveCount(3);
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2);
await expect(page.getByTestId("videoTile")).toHaveCount(3, {
timeout: 10000,
});
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
});
function isStickySend(url: string): boolean {

View File

@@ -119,25 +119,27 @@ async function setRtcModeFromSettings(
async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
await expect(page.getByTestId("videoTile")).toHaveCount(2);
// There are no other options than to wait for all media to be ready?
// Or it is too flaky :/
await page.waitForTimeout(3000);
// No one should be waiting for media
await expect(page.getByText("Waiting for media...")).not.toBeVisible();
await expect(page.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000,
});
// There should be 5 video elements, visible and autoplaying
const videoElements = await page.locator("video").all();
expect(videoElements.length).toBe(count);
// There should be `count` video elements, visible and autoplaying
await expect(page.locator("video")).toHaveCount(count);
const blockDisplayCount = await page
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(count);
await expect(async () => {
const videoBlockCount = await page
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(videoBlockCount).toBe(count);
}).toPass({
timeout: 10000,
});
}
export const SpaHelpers = {

View File

@@ -0,0 +1,142 @@
/*
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 { createHmac } from "crypto";
/**
* Response from Synapse registration API
*/
export interface SynapseRegistrationResponse {
access_token: string;
user_id: string;
home_server: string;
device_id: string;
}
/**
* Utility class for interacting with Synapse Admin API
* This provides fast user registration without going through the UI
*
* @see https://matrix-org.github.io/synapse/latest/admin_api/register_api.html
*/
export class SynapseAdmin {
public constructor(
private baseUrl: string = "https://synapse.m.localhost",
private sharedSecret: string = "test_shared_secret_for_local_dev_only",
) {}
/**
* Register a user using the Synapse Admin API
* This is much faster than going through the UI registration flow
*
* @param username - The username (localpart) for the new user
* @param password - The password for the new user
* @param displayName - Optional display name (defaults to username)
* @param admin - Whether the user should be an admin (defaults to false)
* @returns Registration response containing access token and user ID
*/
public async registerUser(
username: string,
password: string,
displayName?: string,
admin: boolean = false,
): Promise<SynapseRegistrationResponse> {
// Get a nonce first
const nonce = await this.getNonce();
// Generate the HMAC
const mac = this.generateMac(username, password, admin, nonce);
// Make the registration request
const response = await fetch(`${this.baseUrl}/_synapse/admin/v1/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nonce,
username,
password,
displayname: displayName || username,
admin,
mac,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(
`Failed to register user ${username}: ${response.status} ${error}`,
);
}
return response.json();
}
/**
* Get a nonce for registration
* The nonce is required for the HMAC calculation
*
* @returns A nonce string
*/
private async getNonce(): Promise<string> {
const response = await fetch(`${this.baseUrl}/_synapse/admin/v1/register`, {
method: "GET",
});
if (!response.ok) {
throw new Error(
`Failed to get nonce: ${response.status} ${await response.text()}`,
);
}
const data = await response.json();
return data.nonce;
}
/**
* Generate HMAC for shared secret registration
* This is the authentication mechanism for the admin API
*
* @param username - The username
* @param password - The password
* @param admin - Whether the user is an admin
* @param nonce - The nonce from the server
* @returns The HMAC hex string
*/
private generateMac(
username: string,
password: string,
admin: boolean,
nonce: string,
): string {
const mac = createHmac("sha1", this.sharedSecret);
mac.update(nonce);
mac.update("\x00");
mac.update(username);
mac.update("\x00");
mac.update(password);
mac.update("\x00");
mac.update(admin ? "admin" : "notadmin");
return mac.digest("hex");
}
/**
* Create a new SynapseAdmin instance for a different homeserver
*
* @param baseUrl - The base URL of the homeserver
* @param sharedSecret - The shared secret (defaults to test secret)
* @returns A new SynapseAdmin instance
*/
public static forHomeserver(
baseUrl: string,
sharedSecret: string = "test_shared_secret_for_local_dev_only",
): SynapseAdmin {
return new SynapseAdmin(baseUrl, sharedSecret);
}
}

View File

@@ -26,8 +26,12 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
"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);
test.slow();
const [florian, timo] = await Promise.all([
addUser("florian", HOST1),
addUser("timo", HOST2),
]);
const roomName = "Call Room";
@@ -57,27 +61,20 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
// There are no other options than to wait for all media to be ready?
// Or it is too flaky :/
await user.page.waitForTimeout(3000);
// No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000,
});
// There should be 2 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(2);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
// await florian.page.pause();

View File

@@ -75,18 +75,11 @@ widgetTest(
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
// There should be 2 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(2);
await expect(frame.locator("video")).toHaveCount(2, {
timeout: 10000,
});
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
},
);

View File

@@ -27,6 +27,7 @@ import { HOST1, HOST2, TestHelpers } from "./test-helpers";
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",
@@ -65,40 +66,26 @@ widgetTest(
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
// There are no other options than to wait for all media to be ready?
// Or it is too flaky :/
await user.page.waitForTimeout(3000);
// No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
// 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
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(2);
await expect(frame.locator("video")).toHaveCount(2, {
timeout: 10000,
});
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
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);
const blockDisplayCount = await timo.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
await TestHelpers.expectVisibleVideoCount(
timo.page.locator('iframe[title="Element Call"]').contentFrame(),
2,
);
},
);

View File

@@ -11,18 +11,21 @@ import { widgetTest } from "../fixtures/widget-user.ts";
import { HOST1, TestHelpers } from "./test-helpers.ts";
widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
// increase the timeouts, it is a long test and it is annoying to retry from the beginning for a single timeout.
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",
);
test.slow(); // We are registering multiple users here, give it more time
const valere = await addUser("Valere", HOST1);
const timo = await addUser("Timo", HOST1);
const robin = await addUser("Robin", HOST1);
const halfshot = await addUser("Halfshot", HOST1);
const florian = await addUser("florian", HOST1);
const [valere, timo, robin, halfshot, florian] = await Promise.all([
addUser("Valere", HOST1),
addUser("Timo", HOST1),
addUser("Robin", HOST1),
addUser("Halfshot", HOST1),
addUser("florian", HOST1),
]);
const roomName = "Group Call Room";
await TestHelpers.createRoom(roomName, valere.page, [
@@ -47,52 +50,55 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
await TestHelpers.joinCallFromLobby(valere.page);
for (const user of [timo, robin, halfshot, florian]) {
await TestHelpers.joinCallInCurrentRoom(user.page);
}
await Promise.all(
[timo, robin, halfshot, florian].map(async (user) => {
await TestHelpers.joinCallInCurrentRoom(user.page);
}),
);
for (const user of [timo, robin, halfshot, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// No lobby, should start with video on
await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible();
}
await Promise.all(
[timo, robin, halfshot, florian].map(async (user) => {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible({
timeout: 10000,
});
}),
);
// We should see 5 video tiles everywhere now
for (const user of [valere, timo, robin, halfshot, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(5);
for (const participant of [valere, timo, robin, halfshot, florian]) {
// Check the names are correct
await expect(frame.getByText(participant.displayName)).toBeVisible();
}
await Promise.all(
[valere, timo, robin, halfshot, florian].map(async (user) => {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(5, {
timeout: 15000,
});
// There is no other options than to wait for all media to be ready?
// Or it is too flaky :/
await user.page.waitForTimeout(5000);
// No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
// There should be 5 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(5);
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
await Promise.all(
[valere, timo, robin, halfshot, florian].map(async (user) => {
// Check the names are correct
await expect(frame.getByText(user.displayName)).toBeVisible();
}),
);
expect(blockDisplayCount).toBe(5);
}
// No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
// Use a bigger timeout here
timeout: 10000,
});
// There should be 5 video elements, visible and autoplaying
await expect(frame.locator("video")).toHaveCount(5);
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
await TestHelpers.expectVisibleVideoCount(frame, 5);
}),
);
// Quickly test muting one participant to see it reflects and that our asserts works
const florianFrame = florian.page
@@ -108,28 +114,16 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
await expect(florianVideoButton).toHaveAccessibleName("Start video");
await expect(florianVideoButton).not.toBeChecked();
// wait a bit for the state to propagate
await valere.page.waitForTimeout(3000);
{
const frame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(5);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
await expect(frame.locator("video")).toHaveCount(5, {
timeout: 10000,
});
// out of 5 ONLY 4 are visible (display:block) !!
// XXX we need to be better at our HTML markup and accessibility, it would make
// this kind of stuff way easier to test if we could look out for aria attributes.
expect(blockDisplayCount).toBe(4);
await TestHelpers.expectVisibleVideoCount(frame, 4);
}
});

View File

@@ -16,8 +16,6 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow();
const valere = await addUser("Valere", HOST1);
const callRoom = "CallRoom";

View File

@@ -47,7 +47,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
// check that the video is on
await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible();
).toBeVisible({
// Increase timeout, as this expect was flaky
timeout: 15000,
});
// Switch to the other room, the call should go to PIP
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
@@ -63,8 +66,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
const frame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.locator("video")).toHaveCount(1, { timeout: 10000 });
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(1);
const pipVideo = videoElements[0];
await expect(pipVideo).toHaveCSS("object-fit", "cover");

View File

@@ -18,9 +18,11 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
test.slow(); // We are registering multiple users here, give it more time
const alice = await addUser("Alice", HOST1);
const bob = await addUser("Bob", HOST1);
const carol = await addUser("Carol", HOST1);
const [alice, bob, carol] = await Promise.all([
addUser("Alice", HOST1),
addUser("Bob", HOST1),
addUser("Carol", HOST1),
]);
const roomName = "Meeting Room";
await TestHelpers.createRoom(roomName, alice.page, [bob.mxId, carol.mxId]);
@@ -50,7 +52,7 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
// Expect 3 video tiles
await expect(frame.locator("video")).toHaveCount(3, {
timeout: 5000,
timeout: 10000,
});
}

View File

@@ -17,7 +17,7 @@ widgetTest.skip(
);
widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
test.slow(); // Triples the timeout
test.slow();
const { brooks, whistler } = asWidget;
@@ -83,8 +83,12 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("incall_leave")
.click();
.click({ timeout: 15000 });
await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible();
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible({
timeout: 10000,
});
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible({
timeout: 10000,
});
});

View File

@@ -10,9 +10,12 @@ import {
expect,
type JSHandle,
type Page,
type FrameLocator,
} from "@playwright/test";
import { type MatrixClient } from "matrix-js-sdk";
import { SynapseAdmin } from "../utils/synapse-admin.ts";
const PASSWORD = "foobarbaz1!";
export const HOST1 = "https://app.m.localhost/#/welcome";
@@ -26,14 +29,14 @@ export class TestHelpers {
voice: boolean = false,
): Promise<void> {
const buttonName = voice ? "Voice call" : "Video call";
await expect(page.getByRole("button", { name: buttonName })).toBeVisible();
await page.getByRole("button", { name: buttonName }).click();
await expect(
page.getByRole("menuitem", { name: "Element Call" }),
).toBeVisible();
await page.getByRole("button", { name: buttonName }).click({
timeout: 5000,
});
await page.getByRole("menuitem", { name: "Element Call" }).click();
await page.getByRole("menuitem", { name: "Element Call" }).click({
timeout: 10000,
});
}
public static async joinCallFromLobby(page: Page): Promise<void> {
@@ -57,9 +60,12 @@ export class TestHelpers {
): Promise<void> {
// This is the header button that notifies about an ongoing call
const label = audioOnly ? "Voice call started" : "Video call started";
await expect(page.getByText(label)).toBeVisible();
await expect(page.getByRole("button", { name: "Join" })).toBeVisible();
await page.getByRole("button", { name: "Join" }).click();
await expect(page.getByText(label)).toBeVisible({
timeout: 10000,
});
await page.getByRole("button", { name: "Join" }).click({
timeout: 5000,
});
}
/**
@@ -74,28 +80,44 @@ export class TestHelpers {
clientHandle: JSHandle<MatrixClient>;
mxId: string;
}> {
// Determine which homeserver to use based on the host
const synapseBaseUrl =
host === HOST2
? "https://synapse.othersite.m.localhost"
: "https://synapse.m.localhost";
// Register user via Synapse Admin API to speed things up
const synapseAdmin = SynapseAdmin.forHomeserver(synapseBaseUrl);
const credentials = await synapseAdmin.registerUser(
username,
PASSWORD,
username,
);
// STEP 2: Open browser and login
const userContext = await browser.newContext({
reducedMotion: "reduce",
});
const page = await userContext.newPage();
await page.goto(host);
await page.getByRole("link", { name: "Create Account" }).click();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Password", exact: true }).click();
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await page.getByRole("textbox", { name: "Confirm password" }).click();
await page
.getByRole("textbox", { name: "Confirm password" })
.fill(PASSWORD);
await page.getByRole("button", { name: "Register" }).click();
await page.getByRole("link", { name: "Sign in" }).click({
timeout: 10000,
});
await page.getByRole("textbox", { name: "Username" }).fill(username, {
timeout: 10000,
});
await page.getByRole("textbox", { name: "Password" }).fill(PASSWORD, {
timeout: 10000,
});
await page.getByRole("button", { name: "Sign in" }).click();
await expect(
page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible({
// Increase timeout as registration can be slow :/
timeout: 15_000,
// Increase timeout here :/ flaky
timeout: 15000,
});
await this.maybeDismissBrowserNotSupportedToast(page);
@@ -106,11 +128,7 @@ export class TestHelpers {
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const mxId = (await clientHandle.evaluate(
(cli: MatrixClient) => cli.getUserId(),
clientHandle,
))!;
const mxId = credentials.user_id;
return { page, clientHandle, mxId };
}
@@ -178,10 +196,14 @@ export class TestHelpers {
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New Room" }).click();
await page.getByRole("menuitem", { name: "New Room" }).click({
timeout: 5000,
});
await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("button", { name: "Create room" }).click();
await expect(page.getByText("You created this room.")).toBeVisible();
await expect(page.getByText("You created this room.")).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Encryption enabled")).toBeVisible();
await TestHelpers.maybeDismissKeyBackupToast(page);
@@ -199,6 +221,7 @@ export class TestHelpers {
}
await page.getByRole("button", { name: "Invite" }).click();
await TestHelpers.dismissInviteUnknownUserModal(page);
}
}
@@ -211,9 +234,12 @@ export class TestHelpers {
roomName: string,
page: Page,
): Promise<void> {
await expect(page.getByRole("option", { name: roomName })).toBeVisible();
await page.getByRole("option", { name: roomName }).click();
await page.getByRole("button", { name: "Accept" }).click();
await page.getByRole("option", { name: roomName }).click({
timeout: 10000,
});
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
await expect(
page.getByRole("main").getByRole("heading", { name: roomName }),
@@ -233,8 +259,12 @@ export class TestHelpers {
page: Page,
mode: RtcMode,
): Promise<void> {
await page.getByRole("button", { name: "Video call" }).click();
await page.getByRole("menuitem", { name: "Element Call" }).click();
await page.getByRole("button", { name: "Video call" }).click({
timeout: 5000,
});
await page.getByRole("menuitem", { name: "Element Call" }).click({
timeout: 10000,
});
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
await page.getByRole("button", { name: "Close lobby" }).click();
@@ -308,4 +338,52 @@ export class TestHelpers {
): Promise<void> {
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
}
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
await expect(
page.getByRole("heading", { name: "Invite new contacts to this" }),
).toBeVisible();
await page.getByRole("button", { name: "Invite" }).click({
timeout: 5000,
});
}
public static async dismissInviteUnknownUserModalDM(
page: Page,
): Promise<void> {
await expect(
page.getByRole("heading", {
name: "Start a chat with this new contact?",
}),
).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click({
timeout: 5000,
});
}
public static async expectVisibleVideoCount(
frame: FrameLocator,
count: number,
): Promise<void> {
// XXX we need to be better at our HTML markup and accessibility, it would make
// this kind of stuff way easier to test if we could look out for aria attributes.
await expect
.poll(
async () => {
return await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) =>
window.getComputedStyle(v).display === "block",
).length,
);
},
{
timeout: 10000,
},
)
.toBe(count);
}
}

View File

@@ -20,8 +20,6 @@ widgetTest(
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, true);
@@ -113,8 +111,6 @@ widgetTest(
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
@@ -200,8 +196,6 @@ widgetTest(
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);

202
pnpm-lock.yaml generated
View File

@@ -14,8 +14,6 @@ overrides:
js-yaml: ^4.1.1
esbuild: ^0.27.7
pnpmfileChecksum: rxqlpiscahzxqq6bf4el6c6jvu
importers:
.:
@@ -49,16 +47,16 @@ importers:
version: 11.7.12
'@livekit/components-core':
specifier: ^0.12.0
version: 0.12.13(livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
version: 0.12.13(livekit-client@2.18.6(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
'@livekit/components-react':
specifier: ^2.0.0
version: 2.9.20(livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tslib@2.8.1)
version: 2.9.20(livekit-client@2.18.6(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tslib@2.8.1)
'@livekit/protocol':
specifier: ^1.42.2
version: 1.45.3
'@livekit/track-processors':
specifier: ^0.7.1
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22))
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.18.6(@types/dom-mediacapture-record@1.0.22))
'@mediapipe/tasks-vision':
specifier: ^0.10.18
version: 0.10.34
@@ -190,7 +188,7 @@ importers:
version: 6.10.2(eslint@8.57.1)
eslint-plugin-matrix-org:
specifier: 2.1.0
version: 2.1.0(@babel/core@7.29.0)(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(@babel/eslint-plugin@7.27.1(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(eslint@8.57.1))(@stylistic/eslint-plugin@3.1.0(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/parser@8.58.2(eslint@8.57.1)(typescript@5.9.3))(eslint-config-google@0.14.0(eslint@8.57.1))(eslint-config-prettier@10.1.8(eslint@8.57.1))(eslint-plugin-deprecate@0.9.0(eslint@8.57.1))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint-plugin-jest@29.15.2(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@5.2.0(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint-plugin-unicorn@56.0.1(eslint@8.57.1))(eslint@8.57.1)(prettier@3.8.3)(typescript@5.9.3)
version: 2.1.0(09e41ec1b738154136ea8b36e5821317)
eslint-plugin-react:
specifier: ^7.29.4
version: 7.37.5(eslint@8.57.1)
@@ -229,7 +227,7 @@ importers:
version: 5.88.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.2)(typescript@5.9.3)
livekit-client:
specifier: ^2.18.1
version: 2.18.3(@types/dom-mediacapture-record@1.0.22)
version: 2.18.6(@types/dom-mediacapture-record@1.0.22)
lodash-es:
specifier: ^4.17.21
version: 4.18.1
@@ -238,7 +236,7 @@ importers:
version: 1.9.2
matrix-js-sdk:
specifier: matrix-org/matrix-js-sdk#develop
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/668183d7226ccb4819788018fb48e9a58f85a45b
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/bec985b783c666de24d6db92a4307a10416742d7
matrix-widget-api:
specifier: ^1.16.1
version: 1.17.0
@@ -303,8 +301,8 @@ importers:
specifier: ^4.6.0
version: 4.7.1
uuid:
specifier: ^13.0.0
version: 13.0.0
specifier: ^14.0.0
version: 14.0.0
vaul:
specifier: ^1.0.0
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -334,7 +332,7 @@ importers:
version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
vitest-axe:
specifier: ^1.0.0-pre.3
version: 1.0.0-pre.5(vitest@4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)))
version: 1.0.0-pre.5(vitest@4.1.4)
packages:
@@ -1578,8 +1576,8 @@ packages:
'@types/dom-mediacapture-transform': ^0.1.9
livekit-client: ^1.12.0 || ^2.1.0
'@matrix-org/matrix-sdk-crypto-wasm@18.1.0':
resolution: {integrity: sha512-GxXK2U39+2qWNvR3fXJY7nxdikvpiT17RaS0/Dktk6R8FMKDk3vm79Hq65yrCWLBmT7pJZoerfILNZqhrcUHrg==}
'@matrix-org/matrix-sdk-crypto-wasm@18.2.0':
resolution: {integrity: sha512-puyZefvq6sHfqlmkri8umhA44724H2JL0YtX8wlvhGuNl8awX/Q1tZyW2Iekm9ZJP7BtuOqlNdg9oQd6iaGbNw==}
engines: {node: '>= 18'}
'@mdx-js/react@3.1.1':
@@ -1702,41 +1700,49 @@ packages:
resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==}
@@ -1792,36 +1798,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -2329,36 +2341,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.15':
resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15':
resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15':
resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.15':
resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.15':
resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.15':
resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==}
@@ -2445,66 +2463,79 @@ packages:
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.1':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.1':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.1':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.1':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.1':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.1':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.1':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.1':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.1':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.1':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
@@ -2950,6 +2981,12 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.59.0':
resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/scope-manager@5.62.0':
resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -2958,12 +2995,22 @@ packages:
resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/scope-manager@8.59.0':
resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.58.2':
resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/tsconfig-utils@8.59.0':
resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.58.2':
resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2979,6 +3026,10 @@ packages:
resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.59.0':
resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@5.62.0':
resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -2994,6 +3045,12 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/typescript-estree@8.59.0':
resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@5.62.0':
resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -3007,6 +3064,13 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.59.0':
resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/visitor-keys@5.62.0':
resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -3015,6 +3079,10 @@ packages:
resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/visitor-keys@8.59.0':
resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -4859,24 +4927,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -4901,8 +4973,8 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
livekit-client@2.18.3:
resolution: {integrity: sha512-A8QDaVPo+Ye35bJFyKe6PjMOtY33dmdRXGKP/3+BG48ynEES3YwFzHbsPHJiScgI4OZouNef3Ew/BPazXKwo8Q==}
livekit-client@2.18.6:
resolution: {integrity: sha512-JTOSWkRrFC9KayPvasbnXpAmt+J/ILk5c8f3xUmjqazZk7j9QTyj0qhDHIgdyy/5KFqjqaRmtPu/InMB+WlkPA==}
peerDependencies:
'@types/dom-mediacapture-record': ^1
@@ -4976,8 +5048,8 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/668183d7226ccb4819788018fb48e9a58f85a45b:
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/668183d7226ccb4819788018fb48e9a58f85a45b}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/bec985b783c666de24d6db92a4307a10416742d7:
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/bec985b783c666de24d6db92a4307a10416742d7}
version: 41.3.0
engines: {node: '>=22.0.0'}
@@ -6386,8 +6458,8 @@ packages:
util@0.12.5:
resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==}
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
validate-npm-package-license@3.0.4:
@@ -6576,8 +6648,8 @@ packages:
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
webrtc-adapter@9.0.4:
resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==}
webrtc-adapter@9.0.5:
resolution: {integrity: sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==}
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
whatwg-encoding@3.1.1:
@@ -8080,21 +8152,21 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@livekit/components-core@0.12.13(livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
'@livekit/components-core@0.12.13(livekit-client@2.18.6(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
dependencies:
'@floating-ui/dom': 1.7.4
livekit-client: 2.18.3(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.18.6(@types/dom-mediacapture-record@1.0.22)
loglevel: 1.9.1
rxjs: 7.8.2
tslib: 2.8.1
'@livekit/components-react@2.9.20(livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tslib@2.8.1)':
'@livekit/components-react@2.9.20(livekit-client@2.18.6(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tslib@2.8.1)':
dependencies:
'@livekit/components-core': 0.12.13(livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
'@livekit/components-core': 0.12.13(livekit-client@2.18.6(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
clsx: 2.1.1
events: 3.3.0
jose: 6.2.2
livekit-client: 2.18.3(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.18.6(@types/dom-mediacapture-record@1.0.22)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
tslib: 2.8.1
@@ -8106,13 +8178,13 @@ snapshots:
dependencies:
'@bufbuild/protobuf': 1.10.1
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22))':
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.18.6(@types/dom-mediacapture-record@1.0.22))':
dependencies:
'@mediapipe/tasks-vision': 0.10.34
'@types/dom-mediacapture-transform': 0.1.11
livekit-client: 2.18.3(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.18.6(@types/dom-mediacapture-record@1.0.22)
'@matrix-org/matrix-sdk-crypto-wasm@18.1.0': {}
'@matrix-org/matrix-sdk-crypto-wasm@18.2.0': {}
'@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5)':
dependencies:
@@ -9364,6 +9436,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.59.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
'@typescript-eslint/types': 8.59.0
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@5.62.0':
dependencies:
'@typescript-eslint/types': 5.62.0
@@ -9374,10 +9455,19 @@ snapshots:
'@typescript-eslint/types': 8.58.2
'@typescript-eslint/visitor-keys': 8.58.2
'@typescript-eslint/scope-manager@8.59.0':
dependencies:
'@typescript-eslint/types': 8.59.0
'@typescript-eslint/visitor-keys': 8.59.0
'@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/tsconfig-utils@8.59.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.58.2(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.58.2
@@ -9394,6 +9484,8 @@ snapshots:
'@typescript-eslint/types@8.58.2': {}
'@typescript-eslint/types@8.59.0': {}
'@typescript-eslint/typescript-estree@5.62.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 5.62.0
@@ -9423,6 +9515,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.59.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
'@typescript-eslint/types': 8.59.0
'@typescript-eslint/visitor-keys': 8.59.0
debug: 4.4.3
minimatch: 10.2.5
semver: 7.7.4
tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1)
@@ -9449,6 +9556,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.59.0(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1)
'@typescript-eslint/scope-manager': 8.59.0
'@typescript-eslint/types': 8.59.0
'@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
eslint: 8.57.1
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@5.62.0':
dependencies:
'@typescript-eslint/types': 5.62.0
@@ -9459,6 +9577,11 @@ snapshots:
'@typescript-eslint/types': 8.58.2
eslint-visitor-keys: 5.0.1
'@typescript-eslint/visitor-keys@8.59.0':
dependencies:
'@typescript-eslint/types': 8.59.0
eslint-visitor-keys: 5.0.1
'@ungap/structured-clone@1.3.0': {}
'@use-gesture/core@10.3.1': {}
@@ -10617,7 +10740,7 @@ snapshots:
eslint-plugin-jest@29.15.2(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3):
dependencies:
'@typescript-eslint/utils': 8.58.2(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/utils': 8.59.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
@@ -10664,8 +10787,8 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
? eslint-plugin-matrix-org@2.1.0(@babel/core@7.29.0)(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(@babel/eslint-plugin@7.27.1(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(eslint@8.57.1))(@stylistic/eslint-plugin@3.1.0(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/parser@8.58.2(eslint@8.57.1)(typescript@5.9.3))(eslint-config-google@0.14.0(eslint@8.57.1))(eslint-config-prettier@10.1.8(eslint@8.57.1))(eslint-plugin-deprecate@0.9.0(eslint@8.57.1))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint-plugin-jest@29.15.2(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1))(eslint-plugin-react-hooks@5.2.0(eslint@8.57.1))(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint-plugin-unicorn@56.0.1(eslint@8.57.1))(eslint@8.57.1)(prettier@3.8.3)(typescript@5.9.3)
: dependencies:
eslint-plugin-matrix-org@2.1.0(09e41ec1b738154136ea8b36e5821317):
dependencies:
'@babel/core': 7.29.0
'@babel/eslint-parser': 7.28.6(@babel/core@7.29.0)(eslint@8.57.1)
'@babel/eslint-plugin': 7.27.1(@babel/eslint-parser@7.28.6(@babel/core@7.29.0)(eslint@8.57.1))(eslint@8.57.1)
@@ -11675,7 +11798,7 @@ snapshots:
lines-and-columns@1.2.4: {}
livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22):
livekit-client@2.18.6(@types/dom-mediacapture-record@1.0.22):
dependencies:
'@livekit/mutex': 1.1.1
'@livekit/protocol': 1.45.3
@@ -11686,7 +11809,7 @@ snapshots:
sdp-transform: 2.15.0
tslib: 2.8.1
typed-emitter: 2.1.0
webrtc-adapter: 9.0.4
webrtc-adapter: 9.0.5
locate-path@5.0.0:
dependencies:
@@ -11751,10 +11874,10 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/668183d7226ccb4819788018fb48e9a58f85a45b:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/bec985b783c666de24d6db92a4307a10416742d7:
dependencies:
'@babel/runtime': 7.29.2
'@matrix-org/matrix-sdk-crypto-wasm': 18.1.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.2.0
another-json: 0.2.0
bs58: 6.0.0
content-type: 1.0.5
@@ -11766,7 +11889,6 @@ snapshots:
p-retry: 8.0.0
sdp-transform: 3.0.0
unhomoglyph: 1.0.6
uuid: 13.0.0
matrix-widget-api@1.17.0:
dependencies:
@@ -13404,7 +13526,7 @@ snapshots:
is-typed-array: 1.1.15
which-typed-array: 1.1.20
uuid@13.0.0: {}
uuid@14.0.0: {}
validate-npm-package-license@3.0.4:
dependencies:
@@ -13542,7 +13664,7 @@ snapshots:
terser: 5.46.1
yaml: 2.8.3
vitest-axe@1.0.0-pre.5(vitest@4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))):
vitest-axe@1.0.0-pre.5(vitest@4.1.4):
dependencies:
'@vitest/pretty-format': 3.2.4
axe-core: 4.11.3
@@ -13608,7 +13730,7 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
webrtc-adapter@9.0.4:
webrtc-adapter@9.0.5:
dependencies:
sdp: 3.2.2

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
# dependencies where we use branches and hashes in the package.json. But that also use a pre/post install script.
onlyBuiltDependencies:
- "matrix-js-sdk"

View File

@@ -54,8 +54,8 @@
"matchFileNames": ["embedded/**/*"]
},
{
"groupName": "Yarn",
"matchDepNames": ["yarn"]
"groupName": "Pnpm",
"matchDepNames": ["pnpm"]
}
],
"semanticCommits": "disabled",

View File

@@ -4,7 +4,11 @@ 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.
*/
// DONT RUN THIS FILE MANUALLY
// This file is intended to be used with `pnpm links:on` and `pnpm links:off` which will copy this file to the project root.
// See docs/linking.md for details.
//
//
// Created based on https://github.com/element-hq/element-call/blob/60fae70a60e3697eb41210ccf1e400cab37df7c8/.yarn/plugins/linker.cjs
// and the following prompt history:
// - Can you convert this yarn plugin into a pnpm plugin.

26
scripts/setup-linking.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Checks if there currently is linking configured. Informs the user to disable linking before committing.
LINKSFILE=.links.cjs
echo "Checking for existing linking configuration in $LINKSFILE..."
if test -f "$LINKSFILE"; then
echo "Linking configuration found in $LINKSFILE."
else
echo "No $LINKSFILE -> Creating $LINKSFILE with default values. Please edit this file to point to your local checkouts of the dependencies you want to link."
echo '''// Packages to link to local checkouts
module.exports = {
"matrix-js-sdk": "../your/path/matrix-js-sdk",
"matrix-widget-api": "../your/path/matrix-widget-api",
};''' > $LINKSFILE
fi
echo "updating local git hookPath to .githooks"
git config --local core.hooksPath .githooks
echo ""
echo "Setup complete."
echo "Update: .links.cjs to your liking"
echo "Run: 'pnpm links:on' to test your .links.cjs"
echo "Run: 'git commit' with links enabled to test the git pre-commit hook."
echo "Run: 'pnpm links:off' to be able to commit again"
echo "Run: 'git config --local core.hooksPath \"\"' to allow committing with linking on (not recommended)"
echo "Run: 'rm links.cjs' & 'git config --local core.hooksPath \"\"' to fully revert what this script did"

View File

@@ -13,8 +13,8 @@ This folder contains an example index.html file that showcases the sdk in use (h
To get started run
```
yarn
yarn build:sdk
pnpm install
pnpm build:sdk
```
in the repository root.

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
/**
* EXPERIMENTAL
*
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
* This file is the entrypoint for the sdk build of element call: `pnpm build:sdk`
* use in widgets.
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
* can be used to join a rtc session and exchange realtime data.

View File

@@ -0,0 +1,162 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
expect,
describe,
it,
vi,
beforeEach,
beforeAll,
afterAll,
} from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { PosthogAnalytics } from "./PosthogAnalytics";
import { CallEndedTracker } from "./PosthogEvents";
import { mockConfig } from "../utils/test";
const defaultCounters = {
roomEventEncryptionKeysSent: 10,
roomEventEncryptionKeysReceived: 5,
};
const defaultTotals = {
roomEventEncryptionKeysReceivedTotalAge: 500,
};
function createMockRtcSession(overrides?: {
counters?: Partial<typeof defaultCounters>;
totals?: Partial<typeof defaultTotals>;
}): MatrixRTCSession {
return {
statistics: {
counters: { ...defaultCounters, ...overrides?.counters },
totals: { ...defaultTotals, ...overrides?.totals },
},
} as unknown as MatrixRTCSession;
}
describe("CallEnded", () => {
beforeAll(() => {
mockConfig();
});
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation(
() => {},
);
});
afterAll(() => {
PosthogAnalytics.resetInstance();
});
it("warns if startTime is missing when track is called", () => {
const warnSpy = vi.spyOn(logger, "warn");
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.track("test-call-id", 2, false, mockSession);
expect(warnSpy).toHaveBeenCalledWith(
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
);
expect(PosthogAnalytics.instance.trackEvent).not.toHaveBeenCalled();
});
it("tracks event with correct properties when startTime is set", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date(Date.now() - 60000));
tracker.cacheParticipantCountChanged(5);
tracker.track("test-call-id", 3, true, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
{
eventName: "CallEnded",
callId: "test-call-id",
callParticipantsMax: 5,
callParticipantsOnLeave: 3,
callDuration: expect.closeTo(60, 1),
roomEventEncryptionKeysSent: 10,
roomEventEncryptionKeysReceived: 5,
roomEventEncryptionKeysReceivedAverageAge: 100,
},
{ send_instantly: true },
);
});
it("tracks maxParticipantsCount correctly across multiple changes", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.cacheParticipantCountChanged(3);
tracker.cacheParticipantCountChanged(7);
tracker.cacheParticipantCountChanged(2);
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
callParticipantsMax: 7,
}),
expect.anything(),
);
});
it("computes roomEventEncryptionKeysReceivedAverageAge as 0 when no keys received", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession({
counters: { roomEventEncryptionKeysReceived: 0 },
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
roomEventEncryptionKeysReceivedAverageAge: 0,
}),
expect.anything(),
);
});
it("computes roomEventEncryptionKeysReceivedAverageAge correctly when keys are received", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession({
counters: { roomEventEncryptionKeysReceived: 4 },
totals: { roomEventEncryptionKeysReceivedTotalAge: 200 },
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
roomEventEncryptionKeysReceivedAverageAge: 50,
}),
expect.anything(),
);
});
it("passes send_instantly option correctly", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.anything(),
{ send_instantly: false },
);
});
});

View File

@@ -27,8 +27,8 @@ interface CallEnded extends IPosthogEvent {
}
export class CallEndedTracker {
private cache: { startTime: Date; maxParticipantsCount: number } = {
startTime: new Date(0),
private cache: { startTime?: Date; maxParticipantsCount: number } = {
startTime: undefined,
maxParticipantsCount: 0,
};
@@ -49,26 +49,32 @@ export class CallEndedTracker {
sendInstantly: boolean,
rtcSession: MatrixRTCSession,
): void {
PosthogAnalytics.instance.trackEvent<CallEnded>(
{
eventName: "CallEnded",
callId: callId,
callParticipantsMax: this.cache.maxParticipantsCount,
callParticipantsOnLeave: callParticipantsNow,
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
roomEventEncryptionKeysSent:
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
roomEventEncryptionKeysReceived:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
roomEventEncryptionKeysReceivedAverageAge:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
? rtcSession.statistics.totals
.roomEventEncryptionKeysReceivedTotalAge /
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
: 0,
},
{ send_instantly: sendInstantly },
);
if (this.cache.startTime) {
PosthogAnalytics.instance.trackEvent<CallEnded>(
{
eventName: "CallEnded",
callId: callId,
callParticipantsMax: this.cache.maxParticipantsCount,
callParticipantsOnLeave: callParticipantsNow,
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
roomEventEncryptionKeysSent:
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
roomEventEncryptionKeysReceived:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
roomEventEncryptionKeysReceivedAverageAge:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
? rtcSession.statistics.totals
.roomEventEncryptionKeysReceivedTotalAge /
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
: 0,
},
{ send_instantly: sendInstantly },
);
} else {
logger.warn(
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
);
}
}
}

View File

@@ -47,7 +47,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
<CpdButton
iconOnly
Icon={Icon}
kind={enabled ? "primary" : "secondary"}
kind={enabled ? "secondary" : "primary"}
role="switch"
aria-checked={enabled}
{...props}
@@ -73,7 +73,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
<CpdButton
iconOnly
Icon={Icon}
kind={enabled ? "primary" : "secondary"}
kind={enabled ? "secondary" : "primary"}
role="switch"
aria-checked={enabled}
{...props}

View File

@@ -29,7 +29,7 @@ interface WasmFileset {
// MediaPipe and depend on node_modules having this specific structure. It's
// easy to see this breaking if our dependencies changed and MediaPipe were
// no longer hoisted, or if we switched to another dependency loader such as
// Yarn PnP.
// yarn PnP.
// https://github.com/google-ai-edge/mediapipe/issues/5961
const wasmFileset: WasmFileset = {
wasmLoaderPath: new URL(

View File

@@ -330,7 +330,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-disabled="true"
aria-labelledby="_r_i_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
@@ -354,7 +354,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-disabled="true"
aria-labelledby="_r_n_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-kind="primary"
data-size="lg"
data-testid="incall_videomute"
role="switch"

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { render } from "@testing-library/react";
import { type FC, useRef } from "react";
import { type FC, useRef, useState } from "react";
import { expect, test, vi } from "vitest";
import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event";
@@ -17,6 +17,7 @@ import {
ReactionSet,
ReactionsRowSize,
} from "./reactions";
import { type Controls } from "./controls";
// Test Explanation:
// - The main objective is to test `useCallViewKeyboardShortcuts`.
@@ -27,6 +28,7 @@ interface TestComponentProps {
onButtonClick?: () => void;
sendReaction?: () => void;
toggleHandRaised?: () => void;
initialModalOpen?: boolean;
}
const TestComponent: FC<TestComponentProps> = ({
@@ -34,7 +36,9 @@ const TestComponent: FC<TestComponentProps> = ({
onButtonClick = (): void => {},
sendReaction = (reaction: ReactionOption): void => {},
toggleHandRaised = (): void => {},
initialModalOpen = false,
}) => {
const [modalOpen, setModalOpen] = useState(initialModalOpen);
const ref = useRef<HTMLDivElement | null>(null);
useCallViewKeyboardShortcuts(
ref,
@@ -47,6 +51,19 @@ const TestComponent: FC<TestComponentProps> = ({
return (
<div ref={ref}>
<Button onClick={onButtonClick}>TEST</Button>
{modalOpen && (
<dialog
open
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
setModalOpen(false);
}
}}
>
<button>InModalButton</button>
</dialog>
)}
</div>
);
};
@@ -118,6 +135,27 @@ test("raised hand can be sent via keyboard presses", async () => {
expect(toggleHandRaised).toHaveBeenCalledOnce();
});
test("raised hand cannot be sent via keyboard presses if modal open and focussed", async () => {
const user = userEvent.setup();
const toggleHandRaised = vi.fn();
const { getByRole } = render(
<TestComponent
toggleHandRaised={toggleHandRaised}
initialModalOpen={true}
/>,
);
getByRole("button", { name: "InModalButton" }).focus();
await user.keyboard("h");
expect(toggleHandRaised).not.toHaveBeenCalledOnce();
// once we press esc...
await user.keyboard("[Escape]");
// we can toggle the hand raise...
await user.keyboard("h");
expect(toggleHandRaised).toHaveBeenCalledOnce();
});
test("unmuting happens in place of the default action", async () => {
const user = userEvent.setup();
const defaultPrevented = vi.fn();
@@ -138,3 +176,35 @@ test("unmuting happens in place of the default action", async () => {
await user.keyboard("[Space]");
expect(defaultPrevented).toBeCalledWith(true);
});
test("escape button triggers the controls back action", async () => {
const user = userEvent.setup();
window.controls = { onBackButtonPressed: vi.fn() } as unknown as Controls;
// In the real application, we mostly just want the spacebar shortcut to avoid
// scrolling the page. But to test that here in JSDOM, we need some kind of
// container element that can be interactive and receive focus / keydown
// events. <video> is kind of a weird choice, but it'll do the job.
render(<TestComponent setAudioEnabled={() => {}} />);
await user.keyboard("[Escape]");
expect(window.controls.onBackButtonPressed).toHaveBeenCalled();
});
test("escape button does not trigger back if sth else is focused", async () => {
const user = userEvent.setup();
window.controls = { onBackButtonPressed: vi.fn() } as unknown as Controls;
const { getByRole } = render(<TestComponent initialModalOpen={true} />);
getByRole("button", { name: "InModalButton" }).focus();
// First Escape: the dialog's onKeyDown intercepts it and closes the modal.
await user.keyboard("[Escape]");
expect(window.controls.onBackButtonPressed).not.toHaveBeenCalled();
// Second Escape: modal is gone, focus has fallen back to document.body,
// which *does* contain the ref div, so the hook fires and back IS triggered.
await user.keyboard("[Escape]");
expect(window.controls.onBackButtonPressed).toHaveBeenCalled();
});

View File

@@ -68,6 +68,8 @@ export function useCallViewKeyboardShortcuts(
} else if (KeyToReactionMap[event.key]) {
event.preventDefault();
sendReaction(KeyToReactionMap[event.key]);
} else if (event.key === "Escape") {
window.controls.onBackButtonPressed?.();
}
},
[