Compare commits

..

2 Commits

Author SHA1 Message Date
Valere
34ca911d6d Merge branch 'livekit' into valere/devx/refactor_connection_manager 2026-04-15 15:08:04 +02:00
Valere
a95d77b90a refactor: grug down the connection manager 2026-04-14 10:18:55 +02:00
186 changed files with 18965 additions and 22753 deletions

11
.githooks/post-commit Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/sh
FILE=.links.temp-disabled.yaml
if test -f "$FILE"; 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 .links.temp-disabled.yaml .links.yaml
yarnLog=$(yarn)
echo "[yarn-linker] The post-commit hook has re-enabled .links.yaml."
exit 1
fi

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env bash
#!/usr/bin/sh
# 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."
FILE=".links.yaml"
if test -f "$FILE"; then
mv .links.yaml .links.temp-disabled.yaml
# echo "running yarn"
x=$(yarn)
y=$(git add yarn.lock)
echo "[yarn-linker] The pre-commit hook has disabled .links.yaml and MODIFIED the yarn.lock file. Review the staged changes (the hook added yarn.lock, was this desired?) and run \`git commit \` again if they look okay. The post-commit hook will re-enable your links."
exit 1
fi

View File

@@ -37,16 +37,15 @@ jobs:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: pnpm cache
- name: Yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "pnpm"
cache: "yarn"
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
run: "yarn install --immutable"
- name: Build Element Call
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
run: yarn run build:"$PACKAGE":"$BUILD_MODE"
env:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}

View File

@@ -85,31 +85,3 @@ jobs:
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build_storybook:
name: Build Storybook
if: contains(github.event.pull_request.labels.*.name, 'storybook build')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: pnpm cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Build Storybook
run: pnpm run build-storybook
- name: Upload Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: build-output-storybook
path: storybook-static
# We'll only use this in a triggered job, then we're done with it
retention-days: 1

View File

@@ -17,7 +17,7 @@ on:
package:
required: true
type: string
description: Which package to deploy - 'full', 'embedded', 'sdk', or 'storybook'
description: Which package to deploy - 'full', 'embedded', or 'sdk'
artifact_run_id:
required: false
type: string
@@ -43,7 +43,7 @@ jobs:
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: ${{ inputs.package}}
env: Netlify
ref: ${{ inputs.deployment_ref }}
desc: |
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
@@ -59,13 +59,9 @@ jobs:
- name: Add redirects file
# We fetch from github directly as we don't bother checking out the repo
# Not needed for storybook deployments
if: inputs.package != 'storybook'
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
- name: Add config file
# Not needed for storybook deployments
if: inputs.package != 'storybook'
run: |
if [ "${INPUTS_PACKAGE}" = "full" ]; then
curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview.json" > webapp/config.json
@@ -82,7 +78,7 @@ jobs:
with:
publish-dir: webapp
deploy-message: "Deploy from GitHub Actions"
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || inputs.package == 'storybook' && format('pr{0}-storybook', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

View File

@@ -12,21 +12,20 @@ jobs:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: pnpm cache
- name: Yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "pnpm"
cache: "yarn"
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
run: "yarn install --immutable"
- name: Prettier
run: "pnpm run prettier:check"
run: "yarn run prettier:check"
- name: i18n
run: "pnpm run i18n:check"
run: "yarn run i18n:check"
- name: ESLint
run: "pnpm run lint:eslint"
run: "yarn run lint:eslint"
- name: Type check
run: "pnpm run lint:types"
run: "yarn run lint:types"
- name: Dead code analysis
run: "pnpm run lint:knip"
run: "yarn run lint:knip"

View File

@@ -14,7 +14,7 @@ jobs:
# 2. Event must be a pull_request
# 3. Head repository must be the SAME as the base repository (No Forks!)
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
@@ -63,24 +63,6 @@ jobs:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
netlify-storybook:
needs: prdetails
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'storybook build') }}
permissions:
deployments: write
uses: ./.github/workflows/deploy-to-netlify.yaml
with:
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
pr_number: ${{ needs.prdetails.outputs.pr_number }}
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
package: storybook
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
docker:
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'docker build') }}
needs: prdetails

View File

@@ -97,7 +97,7 @@ jobs:
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
- name: Upload
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -297,7 +297,7 @@ jobs:
NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}
- name: Add release notes
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
append_body: true
body: |

View File

@@ -42,7 +42,7 @@ jobs:
- name: Create Checksum
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
- name: Upload
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Add release note
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
append_body: true
body: |

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: ${{ secrets.NETLIFY_SITE_ID }}
site_id: ${{ vars.NETLIFY_SITE_ID }}
desc: Playwright Report
deployment_env: EndToEndTests
prefix: "e2e-"

View File

@@ -2,15 +2,11 @@ name: Test
on:
pull_request: {}
push:
branches: [livekit]
branches: [livekit, full-mesh]
jobs:
vitest:
name: Run unit tests
runs-on: ubuntu-latest
container:
# Make sure to grab the latest version of the Playwright image
# https://playwright.dev/docs/docker#pull-the-image
image: mcr.microsoft.com/playwright:v1.60.0-noble
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -18,16 +14,15 @@ jobs:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: pnpm cache
- name: Yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "pnpm"
cache: "yarn"
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
run: "yarn install --immutable"
- name: Vitest
run: "pnpm run test:coverage"
run: "yarn run test:coverage"
- name: Upload to codecov
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
env:
@@ -47,13 +42,12 @@ jobs:
run: corepack enable
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "pnpm"
cache: "yarn"
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: pnpm install --frozen-lockfile --ignore-pnpmfile
run: yarn install --immutable
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
run: yarn playwright install --with-deps
- name: Run backend components
run: |
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
@@ -62,7 +56,7 @@ jobs:
- name: Run Playwright tests
env:
USE_DOCKER: 1
run: pnpm exec playwright test
run: yarn playwright test
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:

View File

@@ -22,12 +22,11 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "pnpm"
cache: "yarn"
node-version-file: ".node-version"
- name: Install Deps
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
run: "yarn install --immutable"
- name: Prune i18n
run: "rm -R locales"
@@ -41,7 +40,7 @@ jobs:
run: "sudo chown runner:docker -R locales"
- name: Prettier
run: pnpm prettier:format
run: yarn prettier:format
- name: Create Pull Request
id: cpr

View File

@@ -20,4 +20,4 @@ jobs:
persist-credentials: false
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2

5
.gitignore vendored
View File

@@ -21,14 +21,9 @@ yarn-error.log
!/.yarn/releases
!/.yarn/sdks
!/.yarn/versions
# old yarn based linking
/.links.yaml
/.links.disabled.yaml
/.links.temp-disabled.yaml
# pnpm based linking
/.links.cjs
/.links.disabled.cjs
/.links.temp-disabled.cjs
# Playwright
/test-results/

View File

@@ -1,3 +1,2 @@
pnpm-lock.yaml
node_modules
dist

View File

@@ -1,36 +1,8 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: ["@storybook/addon-docs", "@storybook/addon-vitest"],
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: ["@storybook/addon-docs"],
framework: "@storybook/react-vite",
// THIS IS IMPORTANT
// vitest runs without Vite's normal dependency optimization, so we need to manually include the polyfills for the stories to work.
// otherwise we will get: new dependencies optimized: ...
// and
// ```
// [vitest] Vite unexpectedly reloaded a test. This may cause tests to fail, lead to flaky behaviour or duplicated test runs.
// For a stable experience, please add mentioned dependencies to your config's `optimizeDeps.include` field manually.
// ```
// which breaks the storybook ci on the first and only run.
viteFinal(config) {
config.optimizeDeps = {
...config.optimizeDeps,
include: [
...(config.optimizeDeps?.include ?? []),
"vite-plugin-node-polyfills/shims/buffer",
"vite-plugin-node-polyfills/shims/global",
"vite-plugin-node-polyfills/shims/process",
],
};
return config;
},
};
export default config;

View File

@@ -1,10 +1,3 @@
/*
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 { create } from "storybook/theming";
import { addons } from "storybook/manager-api";

View File

@@ -1,10 +1,3 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import type { Preview } from "@storybook/react-vite";
import { TooltipProvider } from "@vector-im/compound-web";
import i18n from "i18next";

91
.yarn/plugins/linker.cjs vendored Normal file
View File

@@ -0,0 +1,91 @@
/*
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.
*/
module.exports = {
name: "linker",
factory: (require) => ({
hooks: {
// Yarn's plugin system is very light on documentation. The best we have
// for this hook is simply the type definition in
// https://github.com/yarnpkg/berry/blob/master/packages/yarnpkg-core/sources/Plugin.ts
registerPackageExtensions: async (config, registerPackageExtension) => {
const { structUtils } = require("@yarnpkg/core");
const { parseSyml } = require("@yarnpkg/parsers");
const path = require("path");
const fs = require("fs");
const process = require("process");
// Create a descriptor that we can use to target our direct dependencies
const projectPath = config.projectCwd
.replace(/\\/g, "/")
.replace("/C:/", "C:/");
const manifestPath = path.join(projectPath, "package.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const selfDescriptor = structUtils.parseDescriptor(
`${manifest.name}@*`,
true,
);
// Load the list of linked packages
const linksPath = path.join(projectPath, ".links.yaml");
let linksFile;
try {
linksFile = fs.readFileSync(linksPath, "utf8");
} catch (e) {
return; // File doesn't exist, there's nothing to link
}
let links;
try {
links = parseSyml(linksFile);
} catch (e) {
console.error(".links.yaml has invalid syntax", e);
process.exit(1);
}
// Resolve paths and turn them into a Yarn package extension
const overrides = Object.fromEntries(
Object.entries(links).map(([name, link]) => [
name,
`portal:${path.resolve(config.projectCwd, link)}`,
]),
);
const overrideIdentHashes = new Set();
for (const name of Object.keys(overrides))
overrideIdentHashes.add(
structUtils.parseDescriptor(`${name}@*`, true).identHash,
);
// Extend our own package's dependencies with these local overrides
registerPackageExtension(selfDescriptor, { dependencies: overrides });
// Filter out the original dependencies from the package spec so Yarn
// actually respects the overrides
const filterDependencies = (original) => {
const pkg = structUtils.copyPackage(original);
pkg.dependencies = new Map(
Array.from(pkg.dependencies.entries()).filter(
([, value]) => !overrideIdentHashes.has(value.identHash),
),
);
return pkg;
};
// Patch Yarn's own normalizePackage method to use the above filter
const originalNormalizePackage = config.normalizePackage;
config.normalizePackage = function (pkg, extensions) {
return originalNormalizePackage.call(
this,
pkg.identHash === selfDescriptor.identHash
? filterDependencies(pkg)
: pkg,
extensions,
);
};
},
},
}),
};

3
.yarnrc.yml Normal file
View File

@@ -0,0 +1,3 @@
nodeLinker: node-modules
plugins:
- .yarn/plugins/linker.cjs

View File

@@ -66,7 +66,7 @@ requiring a separate Matrix client.
### 📲 In-App Calling (Widget Mode in Messenger Apps)
When used as a widget 🧩, Element Call is solely responsible for the core calling
When used as a widget 🧩, Element Call is solely responsible on the core calling
functionality (MatrixRTC). Authentication, event handling, and room state
updates (via the Client-Server API) are handled by the hosting client.
Communication between Element Call and the client is managed through the widget
@@ -108,18 +108,18 @@ 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 Guide](./docs/self_hosting.md). A typical
federated site deployment for three different sites A, B and C is depicted below.
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">
<img src="./docs/Federated_Setup.drawio.png" alt="Element Call federated setup">
@@ -127,7 +127,7 @@ federated site deployment for three different sites A, B and C is depicted below
### Backend Discovery
The MatrixRTC backend (according to
MatrixRTC backend (according to
[MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)) is
announced by the Matrix site's `.well-known/matrix/client` file and discovered
via the `org.matrix.msc4143.rtc_foci` key, e.g.:
@@ -151,10 +151,11 @@ via `livekit_service_url`.
- Each call participant proposes their discovered MatrixRTC backend from
`org.matrix.msc4143.rtc_foci` in their `org.matrix.msc3401.call.member` state event.
- For the **LiveKit** MatrixRTC backend
- For **LiveKit** MatrixRTC backend
([MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)),
the **first participant who joined the call** defines which backend will be used for this call via
the `foci_preferred` key in their `org.matrix.msc3401.call.member` state event.
the **first participant who joined the call** defines via the `foci_preferred`
key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend
will be used for this call.
- During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)**
provides the client with the **LiveKit SFU WebSocket URL** and an
**access JWT token** in order to exchange media via WebRTC.
@@ -177,13 +178,6 @@ discuss and coordinate translation efforts.
## 🛠️ Development
### Dependencies
- Node.js (e.g. via [nvm](https://github.com/nvm-sh/nvm))
- [Corepack](https://github.com/nodejs/corepack) (not bundled with Node.js anymore starting from 25.0.0)
- Docker client and runtime + Docker Compose (for the backend)
- On macOS you can install everything with `brew install colima docker docker-compose`
### Frontend
To get started clone and set up this project:
@@ -192,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
pnpm install
yarn
```
To use it, create a local config by, e.g.,
@@ -203,12 +197,12 @@ environment as outlined in the next section out of box.
You're now ready to launch the development server:
```sh
pnpm dev
yarn dev
```
See also:
- [Developing with linked packages](./docs/linking.md)
- [Developing with linked packages](./linking.md)
### Backend
@@ -216,29 +210,28 @@ A docker compose file `dev-backend-docker-compose.yml` is provided to start the
whole stack of components which is required for a local development environment
including federation:
- Minimum Synapse Setup (servernames: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
- MatrixRTC Authorization Service (Note: requires Federation API and hence a TLS reverse proxy)
- Minimum Synapse Setup (servernameis: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- Minimum LiveKit SFU setup using dev defaults for config
- Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)
- Hostnames: `m.localhost`, `*.m.localhost`, `*.othersite.m.localhost`
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browser's trusted
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browsers trusted
certificates
- Minimum TLS reverse proxy for
- Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost`
- MatrixRTC backend: `matrix-rtc.m.localhost` and `matrix-rtc.othersite.m.localhost`
- Local Element Call development `call.m.localhost` via `pnpm dev --host `
- Local Element Call development `call.m.localhost` via `yarn dev --host `
- Element Web `app.m.localhost` and `app.othersite.m.localhost`
- Note certificates will expire on Thr, 20 September 2035 14:27:35 CEST
These use a test 'secret' published in this repository, so this must be used
only for local development and **_never be exposed to the public Internet._**
Make sure your Docker runtime is running (e.g. via `colima start`) and then start
the backend components:
Run backend components:
```sh
pnpm backend
# or for podman-compose:
yarn backend
# or for podman-compose
# podman-compose -f dev-backend-docker-compose.yml up
```
@@ -249,7 +242,7 @@ pnpm backend
> `https://synapse.m.localhost/.well-known/matrix/client`. This can be either
> done by adding the minimum localhost CA
> ([./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt)) to your web
> browser's trusted certificates or by simply copying and pasting each URL into
> browsers trusted certificates or by simply copying and pasting each URL into
> your browsers address bar and follow the prompts to add the exception.
### Playwright tests
@@ -267,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:
`pnpm backend`
`yarn backend`
There are a few different ways to run the tests yourself. The simplest is to
run:
```shell
pnpm run test:playwright
yarn run test:playwright
```
This will run the Playwright tests once, non-interactively.
@@ -281,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
pnpm run test:playwright:open
yarn run test:playwright:open
```
The easiest way to develop new test is to use the codegen feature of Playwright:
@@ -323,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 `pnpm i18n` to extract the new key and update the translation files. This
1. Run `yarn 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 `pnpm backend` the latest element-web develop will be deployed and served on `http://localhost:8081`.
When running `yarn 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
pnpm backend
yarn backend
```
Then open `http://localhost:8081` in your browser.

View File

@@ -50,9 +50,6 @@ 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,9 +50,6 @@ 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,7 +18,3 @@ 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,7 +18,3 @@ keys:
devkey: secret
room:
auto_create: false
webhook:
api_key: devkey
urls:
- https://matrix-rtc.m.localhost/livekit/jwt/sfu_webhook

View File

@@ -28,20 +28,15 @@ 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;
}
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;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
}
# Synapse reverse proxy including .well-known/matrix/client
@@ -78,16 +73,10 @@ 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;
}
@@ -119,7 +108,7 @@ server {
# JWT Service running at port 6080
proxy_pass http://jwt-auth-services/;
}
location ^~ /livekit/sfu/ {
@@ -139,6 +128,8 @@ server {
# LiveKit SFU websocket connection running at port 7880
proxy_pass http://livekit-sfu:7880/;
}
error_page 500 502 503 504 /50x.html;
}
@@ -165,7 +156,7 @@ server {
# JWT Service running at port 16080
proxy_pass http://auth-service-1:16080/;
}
location ^~ /livekit/sfu/ {
@@ -185,12 +176,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
# - pnpm dev --host or
# - yarn dev --host or
# - falling back to http (the element call docker container)
server {
listen 80;
@@ -235,6 +228,7 @@ server {
proxy_pass http://host.docker.internal:8080;
}
error_page 500 502 503 504 /50x.html;
}
@@ -266,6 +260,8 @@ server {
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}
@@ -297,5 +293,7 @@ server {
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}

View File

@@ -1,19 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDGjCCAgKgAwIBAgIUOlA2wgQUGZkKqNDvvifFWEsJfvYwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNjA1MTgwOTM0
MzFaFw0yODA3MjYwOTM0MzFaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcImv3pStfIUo7PbOO
XRVUXuDlApBOrg2dCnvZQ1Jfaf4MftGHj/pURkF4eoBuyH4k4+NLzWD0VcU1cM74
RnxowJt4AceCGe5RK/rqal5fapXc2vYMM8P6xaQR86gkxohpufsLgTnSweh74yqN
B5WHUnCX00/X0bh1ho2BMUvGM9+dI4MdgKdaQDgWK4zg9hwp2Z6Yq7SkJ/D8+sGW
WGpn3osDakL8HTBqop+YVJgF40db50yFurfcfQ0gjVtT4JW8ejO9j8PS/S2oQ/s5
mA1B470XhLtT5qTjGm2bp3WpYkTi5widps8PDzBp5eNr0HrvJqcw7BGpbvBlLa+3
7dhLAgMBAAGjUDBOMB0GA1UdDgQWBBRDfyRM4yKUqW6vu/2KUSXGb8vswDAfBgNV
HSMEGDAWgBRDfyRM4yKUqW6vu/2KUSXGb8vswDAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQBoAhD4W4Yi/VJ2pTKrzhstn1UF1rgQnRddnn97v5BaEV/X
uuBXbSO+/ewjQUupRjePZFp9FFe9co1OiduKcDExlvPU1eIqkWAwDWjMDpI+Lw5q
KI5yHzplmMrT/7jn9Tepl9atrIcfDeFkP1dGtdRPyU6ARJEEWJSKeH9ftmImAsbM
ykXAqSyRl8+bPx1ISG4cNihOxFd38VPDHIW53umaRBgRcN4GcvloKBGrVtRFNM//
H+md8HmNQMP+e7FamETxs28DxjsdpygxjiFNY/T2eD67dH50ZxC3qCxEG6TJsoAg
TYJafnqEcGDfiWQyNZRBypuaRsRmmTR27hCPVgi8
MIIDGjCCAgKgAwIBAgIUbSbx+1UGptOTGefqEn7Zh3yoChIwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MzVaFw0zNTA5MjAxMjI3MzVaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHODfkrFsOkqCnXnTb
QWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+1azC
SlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+I9O6
NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBhwLyC
DW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+Liyt
X/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPERYS2H
Ru/RAgMBAAGjUDBOMB0GA1UdDgQWBBTXNfLAKVayGQda/JZLPszrpz6LVzAfBgNV
HSMEGDAWgBTXNfLAKVayGQda/JZLPszrpz6LVzAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQCvGfyopHHgZB+horGH6i/Xg41V+r4d0o092F1Lfr4vh86e
XMakRw92vsyk/iWOnLPNPcpVWzPcvINaCs/bahgnGSOAnrA4jjcXqymyGIy/6xc5
1EeZAxehiL9E5q4LQ841HDX0gps4ZzUO1BRYQcjG9Rdts83JO2ekkfHkZdNj2eQr
KOrr92Na1/w+EQdo/T9Rs2ES623xKEOxPqb8d/rx5Z4DdeuGx1u+3AfS76Lpo4ni
EJ0g1ImqdSUtiOLzeCQh6pqqb+vuFbxAyeyYSAJ49847EtFBvZCmWmPL2JICg9uq
7rKW/qDfEK9GUs0GWCs3+mJkNvOOxBwtMuQrL7ZF
-----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcImv3pStfIUo7
PbOOXRVUXuDlApBOrg2dCnvZQ1Jfaf4MftGHj/pURkF4eoBuyH4k4+NLzWD0VcU1
cM74RnxowJt4AceCGe5RK/rqal5fapXc2vYMM8P6xaQR86gkxohpufsLgTnSweh7
4yqNB5WHUnCX00/X0bh1ho2BMUvGM9+dI4MdgKdaQDgWK4zg9hwp2Z6Yq7SkJ/D8
+sGWWGpn3osDakL8HTBqop+YVJgF40db50yFurfcfQ0gjVtT4JW8ejO9j8PS/S2o
Q/s5mA1B470XhLtT5qTjGm2bp3WpYkTi5widps8PDzBp5eNr0HrvJqcw7BGpbvBl
La+37dhLAgMBAAECggEAEsS4gc5jBk50I+bo3KYn2DqHgj/qpOqbTFNkS9uh3UJa
fZoJCeiuyM6hNCBVq/uB3mFeg1Au5XAiAqiK2KFwdw8gIS7lkqgXU76brO4YZhPj
6+aOSS03079KV7YYckNDRqJKoTlpgAI7Nhk6ljVhLiEk07tdD65wJACGpg8M8sg9
dyAz+ANs9gs65iF5LYjH61O/AFlLqCRQh5/z0mjGX6G9uN27nxeUY4+n4QMAcd9D
Gcygxjt+4nlQayNAlKMwVfps9bWNtI3Ye9knY4WGkrv5cJbW3bgjV6qrvQsbukbq
xEYzcIlUiWGO9Tv7MN6rk5uQOyoKT/KUnfRmdVd3YQKBgQDMhWm6Q+WuI7Pyn57R
tmY4rs+fSqTmv6xAOcozKJxffGaEwSUuNA15NvR/7iedcNqmH3XT9j90ZNVHe090
ocm1HDUvzC9G5GRrdO6JTTksRaIMZEhosWxqH3DIuBJPLGbF/4obGE7//PJtmDEp
QVL9Aa0WrcwAWhRzUdvCE+taMwKBgQDDbyZIvtlEr1w2V0bjXO536rRksBapc2ZJ
XRKtrXivuVtiZYNDB0I7CCJ52cka61n3kyZz2mhQmLq4cAZXyKYWE2i643O+kc3S
lpZEFSfDZ+3YlhxMxG9oEcgUSwVdbPlAhd/UR8V6n8o2Fm+gug9h6E2zY4fgHLJF
8hOWoD4hiQKBgA7YXD1F8mT6eHRS+78zIyZYIf/o9iE9pm4fA7tE5lzT9ckLD/zT
kGrM/2BN1BhMecJ3JCFXjXGQZB7FJ5ZKrA52VrH6ezAFIfjeyvWyYkUBZOrLWKoo
vrrRP2mCWuneSjNzAf5HfGx+WsZztpXNBQ4SUhMEWHtqDnP0bCQhOAMbAoGAPfLv
qcOFT3ZevoLv34ZHuQ9W20vOAyynUb4E+7SvOtSAmTIgZ5DXd6recs2MJ9JOlGG6
oKKsyk9/cJNiD1V1AC5q1kLfH5tMKOK/AxnJnvFEvZDnq5Xg0pZAW95j9vdiEwfc
qYeOm44nJPn7rHEOCzT93E1CdtHh2LYha2+kAjECgYEAh4qODleBi+2fnf2eq494
/tAot3szu2+gjyCN00vGjtzoAuDKTYgo0cbU1ILk0Pgpp1NcIvdHz/wQnG9RLX7e
Dfy1Q+UkyBK67SJUPvcYqBEaZ6ddyijJDunqh+U3nIBGP+IntKIKMIKiLF6wzTKz
NRpK1HNmllp+O692ZtxoNDU=
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHODfkrFsOkqCn
XnTbQWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+
1azCSlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+
I9O6NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBh
wLyCDW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+
LiytX/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPER
YS2HRu/RAgMBAAECggEABhB9CxYAE5p9D3s9nWsJcSDUdELRQSYlOoPFLmeMkF9c
dcvq7LmduMh1Q8TnoivOBxRIwbj7pZHEYfYJM0TmH82wrQzXu5KLVltm4gTkVt9b
DR8vjBgYdb8HVpM17Cl2xhW62XpJIiseFRUsHc/9sf2Egc3MIpPuIleGR0budbSW
ybBkqEokTYTSiAztcu3G+VN0U9MsJgLMa8HApya7M48ojdrhzngVHZRUOXul9o7u
zYJWSxPHIIYp5C4pYQBAx8OttThwKK1A9lwbQ2EJx0KnTbBC6O5Gna/jENpGd1h2
rzK/9MONtsjln7IejP+4mDlNupS6SF3zzHPBHjqKAQKBgQDtXUIKPiVTFS45yWtK
XD62s3j8jfIi+22b/C30fCPtppn0cm/0zY+vovgWVUBnQXkExafRthZCuxnE8ry7
E29S40+4z9yivAC9dz7vHZUbyIFP6VG9WyhUYo+/WqOIePyh+iBISQ9TA1DneIYz
+VZ8iU5GvdybUPl2C5WN8seaoQKBgQDW3EwVN2EEkChLRJbQYN2qpjn+0vYESMJ8
K0sgMRtgh4+/T2Xb9b8O/dd87Fi/4oaUqWZ2E2sdsXq8P/IEo0cv6SRfHMy7GyxL
RM7ztwUfMC4LVWi0ZIXMrm4gRDGN2XjGvhkX6fU2lSf6azWL1K3wI3amNV2b7P7d
ItpvdkH3MQKBgQCXf29YJEQkXB9t6J3fDzND3xb4cwy5wSo7ZeBa7CTuWOhoeeX1
JIJyAp0/e9goT0SThChRlFtu6gZPivJkoMnr6IOInLrg7we15fc4HPR/kCDgxTVT
m2wJOAMxigNYZogwRfn2yRLL1BD+PBHD+H936xcX1bSJOUyPSGOC/xLhIQKBgQCb
kCDd85ygyycBaAWxlZCor3WqFF/fNjbp5Aaepi9mMoBXSUs8eK7+UbelURHozEAY
fpYaw3B4rTlp9vppdTZjb+/PlXB9v+zQCl+0gTyKGj4cIpiOk4F0co51eipOw7f4
XUaZ0+CgxlmNq/W26iONjH+pU1YVQQA+Z6+zp/GW4QKBgQCrzYgeugxxqgJzyIRu
0njJkIg+T5gHvsQrtpzq7LVob+HBiBiT7eDOeGDXTK8F//sk969QVrDMQsTMvGW9
sG1oTqxciALTMqkJTf8+hT9Uogir0/iTbJUzTt5vPYpQOEQwQHIXMUTjZ9C6NDKT
QlmeMCxeWyPYqoMfwKmdtDP/Iw==
-----END PRIVATE KEY-----

View File

@@ -1,21 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDijCCAnKgAwIBAgIUWkx2ad/F7QIj1JDaYfbLhiRV+EswDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNjA1MTgwOTM0
MzFaFw0yODA3MjYwOTM0MzFaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0hora/UCYMtrLJc6BOjonPUPi
bYpbNiZYnvnqI4doKbV0LBT2TfokT7tpgdPCtHKV0RknsVSL8vhlXpkRqIiWPml8
sZaa0+5NDGCQxexS2WVBlsoNCmAaqi/HNSFop6xaxGpQ3bu0iV3oIkUihveXAl6H
C0VYyGifQ8D5onzepW2ayhemu47YRNSo8wETY5vIi0i/iajTTaw6JvwS+8Kv5/QV
5prdvcFlG/oBs12p0+KoRyxskyzcdBdyIarvfY+9nDZwym5GfN32xO/iqtDuDQzw
Q09h2OsfHJCw70IpHcgXLlEQF2DsFbmbVpWSU6HcMm6B7Yw1YeE64W4PRJp3AgMB
AAGjgcUwgcIwHwYDVR0jBBgwFoAUQ38kTOMilKlur7v9ilElxm/L7MAwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwUwYDVR0RBEww
SoIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
cnNpdGUubS5sb2NhbGhvc3SCCCoubmlwLmlvMB0GA1UdDgQWBBSd0sKIKmZzTnxT
gNHHjsJNnFcYaTANBgkqhkiG9w0BAQsFAAOCAQEAeffRTrD9o9PVRIoul5r2chwP
WF7JtvPdC5xWy9rlCfmIKRNzNRnpVw/mDF/jdhlWcENt3psN8Vb1NM3SECKve9KL
8bDD2rJEoLBHIFQPS+XpEPqVGLHQcfBtGgs2XdILKvgXJyBHY/pgNZkQmXxYDVoc
bH9PjJJ4V3t6+tiVWZ792739EU/pHaSz7tab+ycTiggs7mo18E5jpYILhWsDqIVs
Kz3uczK2OR8537Ix64Z9kmKiklVAqE53odV7Qx2B+7DoOD/7KBN7SMy1KvR1ae6I
p1ivtDKpBZWbb1ccFxp2cQ30qRHLJrt2YRwz268gx/A6rGXuW6UQPYf4ISNR4Q==
MIIDgDCCAmigAwIBAgIUT9NYpZbrAKokSPSTE3zzsAMowvEwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MzVaFw0zNTA5MjAxMjI3MzVaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDK1DNwTQWmyK71Ar56NvmSMQ8s
qUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgfJlZMd4lEvt6V
EEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+7Z22i6cGMOnQ
0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6WxJmFF+eV9P/ax
fxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiVG1jCrVcaKNGT
PYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrncneIJR24/AgMB
AAGjgbswgbgwHwYDVR0jBBgwFoAU1zXywClWshkHWvyWSz7M66c+i1cwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwSQYDVR0RBEIw
QIIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
cnNpdGUubS5sb2NhbGhvc3QwHQYDVR0OBBYEFIkGX+cEJ1ISKIwuT1zzp7uHJ90e
MA0GCSqGSIb3DQEBCwUAA4IBAQBnnnfB7KmyYo16ZYUCmoqGhbM4p8npeYTh5ySb
K01YwGCnMU1qGfJnKHaRwQ2+KtVGZnpBdjmsHcOUetA3V2BirPaYowMCMtaI36LD
LnxvboSZLX0mgEYuN7HmxW4a7fSelDecTYa7xti1sNhE/w8xW7Lky046/DousyUy
d9x3wJ183GGj1W2p6bR1E4sqTr/VbmoULQxnqA3GUNOxW3lRL5e8lQ6jJVRmMF4k
92BtMPrI/m7jwHj0f/WBLI8mdJ/O/W/NxQOG475FZePDfrg+MkeXPChPggf42/ou
AMm56FNB7e1l0b1Fots730RfpCPuXpiAxL4pisS0X1dMVeeM
-----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0hora/UCYMtrL
Jc6BOjonPUPibYpbNiZYnvnqI4doKbV0LBT2TfokT7tpgdPCtHKV0RknsVSL8vhl
XpkRqIiWPml8sZaa0+5NDGCQxexS2WVBlsoNCmAaqi/HNSFop6xaxGpQ3bu0iV3o
IkUihveXAl6HC0VYyGifQ8D5onzepW2ayhemu47YRNSo8wETY5vIi0i/iajTTaw6
JvwS+8Kv5/QV5prdvcFlG/oBs12p0+KoRyxskyzcdBdyIarvfY+9nDZwym5GfN32
xO/iqtDuDQzwQ09h2OsfHJCw70IpHcgXLlEQF2DsFbmbVpWSU6HcMm6B7Yw1YeE6
4W4PRJp3AgMBAAECggEAIgdIbk4VmnrfjjKCsg5JPvNH9AsE7PuQj9zrq+xljkdq
aksS6ni5YZXb9F/iDE4aWU4waTB+iODUXLtPrCnyESwTk0sgYe/39/MQ0slUKivL
b+keDgY6JlyVI/5KXWFZ1kQ27CZXxwiruGGZWZBKZF8wdVE1Ea65Neg+HHA6DHee
Jck002gtgO/J1MMbB1MzdtGcsejYLrA+mO6YddQhA65xdQMljTEfyUwgTVv0pWde
biyKegGK77vlsOyoCkMpVYORG5NMV1Kxs+htA79yuIW71tWHqVbcRMyoM+BaHzPh
7uprs+8vYDFrO39LseczA8gURWwUsCgQ0yQ6Ix5W7QKBgQDnSK4AzjPpDEArdHuV
VGKyzrfPtzH0VV/yTH9hvByNG6i/x8sE/r2KPi5nRMi4PAjjqmxyO1G5qwDOfzvK
sBvwFrTRpmbnqGITVKPPivdoI9+RveN+FxhOXVA8NylAOv/dtSoakYwg3e507UsC
RuFW3Re0Oc+0XFq4C8rQyLkIOwKBgQDH0T9gww+XbwIGCtiNpnEziU9FXBKSVwXf
dCxYcTLPATq3BqHmP4OUA0v+sa3wPcnBkXF7q6eoB9+S6ZYQA/b2BXGU5/j9xYd4
29cF4DlPkhTwF9S8b+h1zhlGIn96Lw/vZuj7Bc3wuwxvB17d8dpyo8bZynIe7BvF
KSPJz+2O9QKBgFFyd8xS0VcFeGeVKpwozmUXhQWCBvZ7RkGGjOk3HHrYvbFjw2vr
5YWUZjT5tRGkGqFJ98y2dQ5EWRFfHwg+wmfnJyAZUG3OD1OtX86Lqpqi321siHtz
2JxoIgRCjKVQ4aAK11vp24YLgZjto5eWrG4xh9Jw9WMXjt73UCH8PaTXAoGBAIff
TY1qlmuO3H1nWqHXkBpPQEwVs7s22ZN817q8HqSMXXSfWe/LOJmpND/YakJ2gX7S
e6xwqOylje3EUHpLd98LDJUIuFM3wkr4klo4gkANQZeRXONV5WhV4PHD+5MF9XwB
KmOnKsaLKoVFKckZ8EUMAOePtdI5ExkaRG+yqAMRAoGAJyUFK+V9ST1N/6wYgqor
vywMSRE2cF2WvVIxdMvWffmpoj40bG6lAlaSWm29E2T8SVvAKsRid0wDgCQ4QTEn
ft7yUDjqVALCJVCrOFHDY0BPStkm6njMWagr/0lGr9zUWqbBOKJhNfDJlykv8gaF
8kWTgabrMCKmpTi7fBWbzZA=
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDK1DNwTQWmyK71
Ar56NvmSMQ8sqUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgf
JlZMd4lEvt6VEEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+
7Z22i6cGMOnQ0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6Wx
JmFF+eV9P/axfxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiV
G1jCrVcaKNGTPYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrnc
neIJR24/AgMBAAECggEANRp6vzfDN4gKWoaV5TyYegCTNv+4rRl74cd9pjmx6Jam
uWaUXCx1etpNqPPWcG1Z9OKLLRnk+kjgKGOqq4mObGvGreNeBot7bHOJZADtwMMI
YG+Gp7StlclS1YoEHoDmezA/AcqDgTXa9KF0rdMBb1sGFJCLAuBNSJCxtVV6CQIz
X26uT0m+Wx8MQyQWA7Sqy6DQNJo++IZkvr7a3cidqBOUPs+QvnIV5JsUb2gp5tGn
zk+ObeRjoFFWYAN/NK7bneRenkP40m3MSL8ZfaEuuonui7CrxM1SiQyq2N1u/Aoy
OE1JtNaVPbLBo6kG5al7Sj4Z0zhRt+iv93S2lZMkBQKBgQD2+FpLTqyLO1NDOFkE
kxU+LdLOx0OV9wASC0ApPOu1dHMG6ksByr7TWeiu6GJDgajusPB7NVPOt2cm4iWU
xPxXPO5l87uiSvu80h5uG4Qdj8KEijHkdap2wbVkU/mm8lBKC36jyBQIlJKySyXY
zSEMfLK9jQPKz5cKKT3dVj/fAwKBgQDSPq9oks6K96MAB66o6cm214otQlnTQkPM
xgjtjddX+Lp9tgihGvtSfPbyy89oUDHCfKvW/AHG52e5dec5YUi6mVdHEWbk33Kt
BoQuxeK3XseIDlD/JD9Dd7KfUyO5w2jtYLfNdqez41O4qj2N52m1KwJYTwMsc8Kq
izVgkC5hFQKBgBFAc/5CtqbbNAvECePZ6mf3h3xOSxhUsrqP8qFu0gBQ7CAVibvM
T9wvsaNWNFcG3age0A2rQfl0sk3zCjEEOaRWa0jP59GEb2VXQCzs2yO9gRcFGEsf
NRMqoOMrQos47gbeGrCSL2QSDNVLjo9AdQiMRWgcS6GFMsXQ77NgbQHFAoGBAI4a
YGTGFWRITJvQlXUFz5kNxg8hMaVgvILDt3UY0dxb+XDOgLajjgsK+77Pkrhmu7tA
mMUOQAU4kxr/XfGil43H5v3Z/Tnk7ZWVOfKDPeHC5gpH4ucQkNIBLXISt6rvMRSA
srrk4CTuGcBPEJvBNemF0Gfvv61j8MdkoAdMbIyhAoGAfGR6yZLBmRMsW5PKmcpT
nq2oSeUpmtGZra6pWz/3XU7AgrCLcx1DmqEjm4w7y5NQJmxyMZqqdTJILCjr3Srt
+2F0NqQL6Li+xQGibAvDj0Jxyol38RvFC0J/w2vQmuF0hTuH95yknSd7FPXK+DPG
qYgXLjun9dht6kx9vGJ69wI=
-----END PRIVATE KEY-----

View File

@@ -3,7 +3,7 @@
# Step 1: Create a Root CA key and cert
openssl genrsa -out dev_tls_local-ca.key 2048
openssl req -x509 -new -nodes \
-days 800 \
-days 3650 \
-subj "/CN=Element Call Dev CA" \
-key dev_tls_local-ca.key \
-out dev_tls_local-ca.crt \
@@ -21,7 +21,7 @@ openssl x509 \
-CA dev_tls_local-ca.crt -CAkey dev_tls_local-ca.key \
-CAcreateserial \
-out dev_tls_m.localhost.crt \
-days 800 \
-days 3650 \
-sha256 \
-extfile <( cat <<EOF
authorityKeyIdentifier=keyid,issuer
@@ -35,6 +35,5 @@ DNS.1 = localhost
DNS.2 = m.localhost
DNS.3 = *.m.localhost
DNS.4 = *.othersite.m.localhost
DNS.5 = *.nip.io
EOF
)

View File

@@ -50,9 +50,6 @@ 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,9 +50,6 @@ 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

@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
pull_policy: always
hostname: auth-server
environment:
@@ -25,7 +25,7 @@ services:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
pull_policy: always
hostname: auth-server-1
environment:
@@ -62,10 +62,7 @@ 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
@@ -85,10 +82,7 @@ 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
@@ -170,8 +164,6 @@ 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

@@ -1,112 +0,0 @@
# Developer help
## Testing on Mobile Devices
When developing Element Call locally, you may want to test on physical mobile devices (iOS/Android)
on the same WiFi network.
**Known Limitations:** For now this setup allows to use your local EC server but not yet the SFUs and Synapses.
### Prerequisites
1. **Start the dev server**
```bash
pnpm dev
```
Check the output for the `➜ Network` (this will contain the local IP address of your laptop)
```
➜ Local: https://m.localhost:3000/ 12:06:48
➜ Local: https://vite.m.localhost:3000/ 12:06:48
➜ Local: https://vite.othersite.m.localhost:3000/ 12:06:48
➜ Local: https://vite.nip.io:3000/ 12:06:48
➜ Network: https://192.168.0.122:3000/
```
2. **Transfer the CA certificate to your phone**
The file is located at `backend/dev_tls_local-ca.crt`. Transfer it via:
- Matrix room
- AirDrop for iphone
### IOS Setup
**Install the certificate profile on iPhone**
- Open the `dev_tls_local-ca.crt` file on your iPhone
- You'll see "Profile Downloaded"
- Go to **Settings → General → VPN & Device Management** (or **Settings → General → Profiles**)
- Tap the "Element Call Dev CA" profile
- Tap **Install** and enter your passcode
- Confirm by tapping **Install** again
**Enable full trust (Critical!)**
- Go to **Settings → General → About → Certificate Trust Settings**
- Under "Enable Full Trust for Root Certificates"
- Toggle **ON** for "Element Call Dev CA"
- Confirm the security warning
**Access Element Call**
Find your laptop's IP address (e.g., `192.168.0.122`) and use one of these URLs in Safari to validate:
```
https://192-168-0-122.nip.io:3000/
```
**For Element X iOS Developer Tools**
In Element X's developer settings, set the Element Call URL to the nip.io url (replace . with - in the IP address):
```
https://192-168-0-122.nip.io:3000/room
```
### Android Setup
**Transfer the CA certificate to your Android device**
The file is located at `backend/dev_tls_local-ca.crt`.
**Install the certificate**
This might vary by Android version and manufacturer, but generally:
- Open **Settings** search for "CA Certificate"/"Certificate"
- Tap **Install a certificate** or **Install from storage**
- Select **CA certificate**
- Confirm the security warning
- Navigate to and select the `dev_tls_local-ca.crt` file
- Give it a name like "Element Call Dev CA"
**Access Element Call**
Find your laptop's IP address (e.g., `192.168.0.122`) and use one of these URLs in Chrome to validate:
```
https://192-168-0-122.nip.io:3000/
```
**For Element X Android Developer Tools**
In Element X's developer settings, set the Element Call URL to the nip.io url (replace . with - in the IP address):
```
https://192-168-0-122.nip.io:3000/room
```
### Why nip.io?
[nip.io](https://nip.io) is a free wildcard DNS service that automatically resolves domain names containing IP addresses. For example, `192-168-0-122.nip.io` automatically resolves to `192.168.0.122`. This means:
- No need to regenerate certificates when your laptop's IP changes
- Works from any device without DNS configuration
- iOS/Android treat it as a proper domain name, not an IP address
- One-time certificate setup works for all future IP addresses
> [!IMPORTANT]
> Make sure your network router doesn't enforce DNS rebinding protection (which will
> break nip.io). If it does, try allow-listing nip.io in your router's administration interface.

View File

@@ -12,7 +12,7 @@ A few aspects of Element Call's interface can be controlled through a global API
On mobile platforms (iOS, Android), web views do not reliably support selecting audio output devices such as the main speaker, earpiece, or headset. To address this limitation, the following functions allow the hosting application (e.g., Element Web, Element X) to manage audio devices via exposed JavaScript interfaces. These functions must be enabled using the URL parameter `controlledAudioDevices` to take effect.
- `controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?: boolean }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only.
- `controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?, boolean; }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only.
It flags the device that should be used if the user selects earpiece mode. This should be the main stereo loudspeaker of the device.
- `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output.
- `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example.

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
@@ -25,8 +25,8 @@ 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` entrypoint of 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.
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.
## Widget vs standalone mode
@@ -35,5 +35,5 @@ Element Call is developed using the [js-sdk](https://github.com/matrix-org/matri
As a widget, the app only uses the core calling (MatrixRTC) parts. The rest (authentication, sending events, getting room state updates about calls) is done by the hosting client.
Element Call and the hosting client are connected via the widget API.
Element Call detects that it is run as a widget if `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).
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).

View File

@@ -1,65 +1,39 @@
## 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. Yarn has a command for this (`yarn link`), but it's not recommended to use it as it ends up modifying package.json with details specific to your development environment.
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:
Instead, you can use our little 'linker' plugin. Create a file named `.links.yaml` in the Element Call project directory, listing the names and paths of any dependencies you want to link. For example:
```cjs
// Packages to link to local checkouts
module.exports = {
"matrix-js-sdk": "../your/path/matrix-js-sdk",
"matrix-widget-api": "../your/path/matrix-widget-api",
};
```yaml
matrix-js-sdk: ../path/to/matrix-js-sdk
"@vector-im/compound-web": /home/alice/path/to/compound-web
```
Then run `pnpm links:on`. (this will activate the pnpm file + run `pnpm install` to setup the linking)
Then run `yarn install`.
## Hooks
Changes in `.links.cjs` will also update `pnpm-lock.yaml` when `pnpm install` is executed. The lockfile will then contain the local
Changes in `.links.yaml` will also update `yarn.lock` when `yarn` 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 remove the pnpm `readPackage` script (the `.pnpmfile.cjs`) and run:
One always needs to run:
```bash
pnpm install
mv .links.yaml .links.disabled.yaml
yarn
```
before committing a change.
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 then need to run `pnpm links:off` and commit again.
To make it more convenient to work with this linking system we added git hooks for your conviniece.
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 activate the hooks configure git with (when using the setup script (`./scripts/setup-linking.sh`) this is already done):
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
```bash
git config --local core.hooksPath .githooks
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

@@ -1,30 +0,0 @@
### 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 our CI builds 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

@@ -58,7 +58,7 @@ rc_message:
rc_delayed_event_mgmt:
# This needs to match at least the heart-beat frequency plus a bit of headroom
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2Hz
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 1
burst_count: 20
```
@@ -70,7 +70,7 @@ make sure that your Synapse server has either a `federation` or `openid`
### MatrixRTC Backend
In order to **guarantee smooth operation** of Element Call, a MatrixRTC backend is
In order to **guarantee smooth operation** of Element Call MatrixRTC backend is
required for each site deployment.
![MSC4195 compatible setup](MSC4195_setup.drawio.png)
@@ -190,8 +190,8 @@ backend mxrtc_auth_backend
> [!IMPORTANT]
> As defined in
> [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143),
> the MatrixRTC backend(s) must be announced to the client via your **Matrix site's
> [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)
> MatrixRTC backend must be announced to the client via your **Matrix site's
> `.well-known/matrix/client`** file (e.g.
> `example.com/.well-known/matrix/client` matching the site deployment example
> from above). The configuration is a list of Foci configs:
@@ -222,7 +222,7 @@ Access-Control-Allow-Headers: X-Requested-With, Content-Type, Authorization
> [!NOTE]
> Most `org.matrix.msc4143.rtc_foci` configurations will only have one entry in
> the array.
> the array
## Building Element Call
@@ -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
pnpm install
pnpm build
yarn
yarn build
```
If all went well, you can now find the build output under `dist` as a series of
@@ -291,7 +291,7 @@ be able to handle those yet and it may behave unreliably.
Therefore, to use a self-hosted homeserver, this is recommended to be a new
server where any user account created has not joined any normal rooms anywhere
in the Matrix federated network. The homeserver used can be set up to disable
in the Matrix federated network. The homeserver used can be setup to disable
federation, so as to prevent spam registrations (if you keep registrations open)
and to ensure Element Call continues to work in case any user decides to log in
to their Element Call account using the standard Element app and joins normal

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/#
@@ -12,7 +12,7 @@ https://element_call.domain/room/#
```
The URL is split into two sections. The `https://element_call.domain/room/#`
contains the app and the intent that the link brings you into a specific room
contains the app and the intend that the link brings you into a specific room
(`https://call.element.io/#` would be the homepage). The fragment is used for
query parameters to make sure they never get sent to the element_call.domain
server. Here we have the actual Matrix room ID and the password which are used
@@ -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,22 +74,22 @@ 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 |
| --------------- | ----------------------------------------------------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | | Yes | The base URL of the homeserver to use for media lookups. |
| `deviceId` | Matrix device ID | Yes | The Matrix device ID for the widget host. |
| `parentUrl` | | Yes | The url used to send widget action postMessages. This should be the domain of the client or the webview the widget is hosted in. (In case the widget is not in an Iframe but in a dedicated webview, we send the postMessages in the same WebView the widget lives in. Filtering is done in the widget so it ignores the messages it receives from itself.) |
| `posthogUserId` | Posthog user identifier | No | This replaces the `analyticsID` parameter |
| `preload` | `true` or `false` | No, defaults to `false` | Pauses app before joining a call until an `io.element.join` widget action is seen, allowing preloading. |
| `returnToLobby` | `true` or `false` | No, defaults to `false` | Displays the lobby in widget mode after leaving a call; shows a blank page if set to `false`. Useful for video rooms. |
| `userId` | [Matrix User Identifier](https://spec.matrix.org/v1.12/appendices/#user-identifiers) | Yes | The Matrix user ID. |
| `widgetId` | [MSC2774](https://github.com/matrix-org/matrix-spec-proposals/pull/2774) format widget ID | Yes | The id used by the widget. The presence of this parameter implies that element call will not connect to a homeserver directly and instead tries to establish postMessage communication via the `parentUrl`. |
| Name | Values | Required | Description |
| --------------- | ----------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | | Yes | The base URL of the homeserver to use for media lookups. |
| `deviceId` | Matrix device ID | Yes | The Matrix device ID for the widget host. |
| `parentUrl` | | Yes | The url used to send widget action postMessages. This should be the domain of the client or the webview the widget is hosted in. (in case the widget is not in an Iframe but in a dedicated webview we send the postMessages same WebView the widget lives in. Filtering is done in the widget so it ignores the messages it receives from itself) |
| `posthogUserId` | Posthog user identifier | No | This replaces the `analyticsID` parameter |
| `preload` | `true` or `false` | No, defaults to `false` | Pauses app before joining a call until an `io.element.join` widget action is seen, allowing preloading. |
| `returnToLobby` | `true` or `false` | No, defaults to `false` | Displays the lobby in widget mode after leaving a call; shows a blank page if set to `false`. Useful for video rooms. |
| `userId` | [Matrix User Identifier](https://spec.matrix.org/v1.12/appendices/#user-identifiers) | Yes | The Matrix user ID. |
| `widgetId` | [MSC2774](https://github.com/matrix-org/matrix-spec-proposals/pull/2774) format widget ID | Yes | The id used by the widget. The presence of this parameter implies that element call will not connect to a homeserver directly and instead tries to establish postMessage communication via the `parentUrl`. |
### 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

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -11,7 +11,7 @@ pushd $CURRENT_DIR > /dev/null
function build_assets() {
echo "Generating Element Call assets..."
pushd ../.. > /dev/null
pnpm build
yarn 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

@@ -10,7 +10,7 @@
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0"
/>
<title><%- brand %></title>
<script>

View File

@@ -18,7 +18,6 @@ export default {
// https://docs.docker.com/compose/migrate/
"docker-compose",
],
ignoreFiles: ["scripts/.pnpmfile.cjs"],
ignoreDependencies: [
// Used in CSS
"normalize.css",
@@ -31,10 +30,16 @@ export default {
"@types/content-type",
"@types/sdp-transform",
"@types/uuid",
// We obviously use this, but if the package has been linked with pnpm link,
// We obviously use this, but if the package has been linked with yarn 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

@@ -3,7 +3,6 @@
"user_menu": "User menu"
},
"action": {
"blur_background": "Blur background",
"close": "Close",
"copy_link": "Copy link",
"edit": "Edit",
@@ -146,7 +145,6 @@
},
"layout_grid_label": "Grid",
"layout_spotlight_label": "Spotlight",
"layout_switch_label": "Layout",
"lobby": {
"ask_to_join": "Request to join call",
"join_as_guest": "Join as guest",

View File

@@ -3,23 +3,23 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "pnpm dev:full",
"dev": "yarn dev:full",
"dev:full": "vite",
"dev:embedded": "vite --config vite-embedded.config.js",
"build": "pnpm build:full",
"build": "yarn build:full",
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
"build:full:production": "pnpm build:full",
"build:full:development": "pnpm build:full --mode development",
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
"build:embedded:production": "pnpm build:embedded",
"build:embedded:development": "pnpm build:embedded --mode development",
"build:sdk:development": "pnpm build:sdk --mode development",
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
"build:sdk:production": "pnpm build:sdk",
"build:full:production": "yarn build:full",
"build:full:development": "yarn build:full --mode development",
"build:embedded": "yarn build:full --config vite-embedded.config.js",
"build:embedded:production": "yarn build:embedded",
"build:embedded:development": "yarn build:embedded --mode development",
"build:sdk:development": "yarn build:sdk --mode development",
"build:sdk": "yarn build:full --config vite-sdk.config.js",
"build:sdk:production": "yarn build:sdk",
"serve": "vite preview",
"prettier:check": "prettier -c .",
"prettier:format": "prettier -w .",
"lint": "pnpm lint:types && pnpm lint:eslint && pnpm lint:knip",
"lint": "yarn lint:types && yarn lint:eslint && yarn lint:knip",
"lint:eslint": "eslint --max-warnings 0 src playwright",
"lint:eslint-fix": "eslint --max-warnings 0 src playwright --fix",
"lint:knip": "knip",
@@ -27,21 +27,19 @@
"i18n": "i18next",
"i18n:check": "i18next --fail-on-warnings --fail-on-update",
"test": "vitest",
"test:storybook": "vitest --project=storybook",
"test:unit": "vitest --project=unit",
"test:coverage": "vitest --coverage",
"backend": "docker-compose -f dev-backend-docker-compose.yml up",
"backend-playwright": "docker-compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up",
"test:playwright": "playwright test",
"test:playwright:open": "pnpm test:playwright --ui",
"links:on": "cp scripts/.pnpmfile.cjs .pnpmfile.cjs & pnpm install",
"links:off": "rm .pnpmfile.cjs & pnpm install",
"test:playwright:open": "yarn test:playwright --ui",
"links:enable": "mv .links.disabled.yaml .links.yaml & touch .links.yaml",
"links:disable": "mv .links.yaml .links.disabled.yaml",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@babel/core": "^7.16.5",
"@babel/preset-env": "^7.29.5",
"@babel/preset-env": "^7.22.20",
"@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.23.0",
"@codecov/vite-plugin": "^1.3.0",
@@ -54,16 +52,15 @@
"@livekit/protocol": "^1.42.2",
"@livekit/track-processors": "^0.7.1",
"@mediapipe/tasks-vision": "^0.10.18",
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.57.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@react-spring/web": "^10.0.0",
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^3.0.0",
"@storybook/addon-docs": "^10.3.6",
"@storybook/addon-vitest": "^10.3.6",
"@storybook/react-vite": "^10.3.6",
"@storybook/addon-docs": "^10.3.3",
"@storybook/react-vite": "^10.3.3",
"@stylistic/eslint-plugin": "^3.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -84,11 +81,9 @@
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^9.3.0",
"@vector-im/compound-web": "^9.0.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.7",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
@@ -103,7 +98,7 @@
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-storybook": "^10.3.6",
"eslint-plugin-storybook": "^10.3.3",
"eslint-plugin-unicorn": "^56.0.0",
"fetch-mock": "11.1.5",
"global-jsdom": "^26.0.0",
@@ -115,7 +110,7 @@
"livekit-client": "^2.18.1",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf",
"matrix-widget-api": "^1.16.1",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
@@ -123,7 +118,7 @@
"pako": "^2.0.4",
"postcss": "^8.4.41",
"postcss-preset-env": "^10.0.0",
"posthog-js": "1.374.0",
"posthog-js": "1.160.3",
"prettier": "^3.0.0",
"qrcode": "^1.5.4",
"react": "19",
@@ -133,33 +128,28 @@
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sass": "^1.42.1",
"storybook": "^10.3.6",
"storybook": "^10.3.3",
"typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"uuid": "^14.0.0",
"uuid": "^13.0.0",
"vaul": "^1.0.0",
"vite": "^8.0.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-node-polyfills": "^0.28.0",
"vite-plugin-node-stdlib-browser": "^0.2.1",
"vite-plugin-svgr": "^4.0.0",
"vite-plugin-wasm": "^3.6.0",
"vitest": "^4.1.5",
"vitest": "^4.0.18",
"vitest-axe": "^1.0.0-pre.3"
},
"pnpm": {
"overrides": {
"@livekit/components-core>rxjs": "^7.8.1",
"@livekit/track-processors>@mediapipe/tasks-vision": "^0.10.18",
"minimatch": "^10.2.3",
"tar": "^7.5.11",
"glob": "^10.5.0",
"qs": "^6.14.1",
"js-yaml": "^4.1.1",
"esbuild": "^0.28.0"
}
"resolutions": {
"@livekit/components-core/rxjs": "^7.8.1",
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
"minimatch": "^10.2.3",
"tar": "^7.5.11",
"glob": "^10.5.0",
"qs": "^6.14.1",
"js-yaml": "^4.1.1"
},
"packageManager": "pnpm@10.33.0"
"packageManager": "yarn@4.7.0"
}

View File

@@ -7,22 +7,11 @@ 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.
*/
@@ -79,11 +68,6 @@ export default defineConfig({
firefoxUserPrefs: {
"permissions.default.microphone": 1,
"permissions.default.camera": 1,
// Equivalent to Chromium's --use-fake-device-for-media-stream:
// feeds a synthetic media stream so getUserMedia and
// enumerateDevices work on CI runners without real hardware.
"media.navigator.streams.fake": true,
"media.navigator.permission.disabled": true,
},
},
},

View File

@@ -58,41 +58,3 @@ test("Start a new call then leave and show the feedback screen", async ({
page.getByRole("link", { name: "Not now, return to home screen" }),
).toBeVisible();
});
test("BugFix: When unmuting in lobby, you had to click twice to unmute in call", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("DoubleUnMute");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("me");
await page.getByTestId("home_go").click();
const microphoneButton = page.getByTestId("incall_mute");
const cameraButton = page.getByTestId("incall_videomute");
// Wait for devices to enumerate before the button enables.
await expect(microphoneButton).toBeEnabled({ timeout: 10_000 });
await microphoneButton.click();
await cameraButton.click();
// Should be muted now
await expect(microphoneButton).toHaveAccessibleName("Unmute microphone");
await expect(cameraButton).toHaveAccessibleName("Start video");
// Create the call and join
await page.getByTestId("lobby_joinCall").click();
// Give sometime for the all to be connected
// Check the number of participants
await expect(page.locator("div").filter({ hasText: /^1$/ })).toBeVisible();
// Click again on the mute button. it should unmute
await microphoneButton.click();
await expect(microphoneButton).toHaveAccessibleName("Mute microphone");
await cameraButton.click();
await expect(cameraButton).toHaveAccessibleName("Stop video");
});

View File

@@ -75,7 +75,9 @@ test("Should automatically retry non fatal JWT errors", async ({
await expect(page.getByTestId("video").first()).toBeVisible();
});
test("Should show error screen if call creation is restricted", async ({
// 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 ({
page,
browserName,
}) => {

View File

@@ -91,9 +91,7 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1
.getByRole("button", { name: "Invite to this room", exact: true })
.click({
timeout: 10000,
});
.click();
await expect(
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
).toBeVisible();
@@ -106,7 +104,6 @@ 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(
@@ -129,7 +126,6 @@ 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,6 +53,7 @@ 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;
// ========
@@ -77,13 +78,13 @@ mobileTest(
await expect(
guestPage.getByTestId("roomHeader_participants_count"),
).toContainText("2");
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2);
expect(await guestPage.getByTestId("videoTile").count()).toBe(2);
// Same in creator page
await expect(
creatorPage.getByTestId("roomHeader_participants_count"),
).toContainText("2");
await expect(creatorPage.getByTestId("videoTile")).toHaveCount(2);
expect(await creatorPage.getByTestId("videoTile").count()).toBe(2);
// TEST: control audio devices from the invitee page
@@ -97,12 +98,10 @@ mobileTest(
// Open settings to select earpiece
await guestPage.getByRole("button", { name: "Settings" }).click();
await guestPage
.getByRole("radio", { name: "Handset", exact: true })
.click();
await guestPage.getByText("Handset", { exact: true }).click();
// dismiss settings
await guestPage.locator("#root").press("Escape");
await guestPage.locator("#root").getByLabel("Settings").press("Escape");
await guestPage.pause();
await expect(

View File

@@ -54,8 +54,6 @@ test("can only interact with header and footer while reconnecting", async ({
page.getByRole("switch", { name: "Mute microphone" }),
).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Microphone" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused();
// Most critically, we should be able to press the hangup button
await page.getByRole("button", { name: "End call" }).click();

View File

@@ -9,9 +9,7 @@ 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();
@@ -93,10 +91,8 @@ 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,7 +36,6 @@ async function setupTwoUserSpaCall(
(req) => {
androlHasSentStickyEvent =
androlHasSentStickyEvent || isStickySend(req.url());
androlResolver.resolve();
},
);
@@ -54,7 +53,6 @@ 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
@@ -62,7 +60,6 @@ async function setupTwoUserSpaCall(
(req) => {
pevaraHasSentStickyEvent =
pevaraHasSentStickyEvent || isStickySend(req.url());
pevaraResolver.resolve();
},
);
@@ -73,9 +70,7 @@ 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 };
@@ -115,12 +110,8 @@ 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, {
timeout: 10000,
});
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
await expect(page.getByTestId("videoTile")).toHaveCount(3);
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2);
});
function isStickySend(url: string): boolean {

View File

@@ -119,27 +119,25 @@ 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({
timeout: 10000,
});
await expect(page.getByText("Waiting for media...")).not.toBeVisible();
// There should be `count` video elements, visible and autoplaying
await expect(page.locator("video")).toHaveCount(count);
// There should be 5 video elements, visible and autoplaying
const videoElements = await page.locator("video").all();
expect(videoElements.length).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,
});
const blockDisplayCount = await page
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(count);
}
export const SpaHelpers = {

View File

@@ -1,142 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { 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,12 +26,8 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow();
const [florian, timo] = await Promise.all([
addUser("florian", HOST1),
addUser("timo", HOST2),
]);
const florian = await addUser("floriant", HOST1);
const timo = await addUser("timo", HOST2);
const roomName = "Call Room";
@@ -61,20 +57,27 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
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({
timeout: 10000,
});
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 TestHelpers.expectVisibleVideoCount(frame, 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 florian.page.pause();

View File

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

View File

@@ -27,7 +27,6 @@ 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",
@@ -66,26 +65,40 @@ widgetTest(
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
// Wait for "Waiting for media..." to disappear (with timeout)
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000, // Maximum time to wait
});
// There 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();
// There should be 2 video elements, visible and autoplaying
await expect(frame.locator("video")).toHaveCount(2, {
timeout: 10000,
});
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(2);
await TestHelpers.expectVisibleVideoCount(frame, 2);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
}
// now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1)
await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat");
await timo.page.waitForTimeout(3000);
await TestHelpers.expectVisibleVideoCount(
timo.page.locator('iframe[title="Element Call"]').contentFrame(),
2,
);
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);
},
);

View File

@@ -11,21 +11,18 @@ 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",
);
const [valere, timo, robin, halfshot, florian] = await Promise.all([
addUser("Valere", HOST1),
addUser("Timo", HOST1),
addUser("Robin", HOST1),
addUser("Halfshot", HOST1),
addUser("florian", HOST1),
]);
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 roomName = "Group Call Room";
await TestHelpers.createRoom(roomName, valere.page, [
@@ -50,55 +47,52 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
await TestHelpers.joinCallFromLobby(valere.page);
await Promise.all(
[timo, robin, halfshot, florian].map(async (user) => {
await TestHelpers.joinCallInCurrentRoom(user.page);
}),
);
for (const user of [timo, robin, halfshot, florian]) {
await TestHelpers.joinCallInCurrentRoom(user.page);
}
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,
});
}),
);
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();
}
// We should see 5 video tiles everywhere now
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,
});
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) => {
// Check the names are correct
await expect(frame.getByText(user.displayName)).toBeVisible();
}),
// 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,
);
// 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);
}),
);
expect(blockDisplayCount).toBe(5);
}
// Quickly test muting one participant to see it reflects and that our asserts works
const florianFrame = florian.page
@@ -114,16 +108,28 @@ 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();
await expect(frame.locator("video")).toHaveCount(5, {
timeout: 10000,
});
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,
);
// out of 5 ONLY 4 are visible (display:block) !!
await TestHelpers.expectVisibleVideoCount(frame, 4);
// 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);
}
});

View File

@@ -16,6 +16,8 @@ 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,10 +47,7 @@ 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({
// Increase timeout, as this expect was flaky
timeout: 15000,
});
).toBeVisible();
// Switch to the other room, the call should go to PIP
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
@@ -66,10 +63,8 @@ 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

@@ -1,152 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user.ts";
import { HOST1, TestHelpers } from "./test-helpers.ts";
widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
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 [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]);
for (const user of [bob, carol]) {
// Accept the invite
// This isn't super stable to get this as this super generic locator,
// but it works for now.
await TestHelpers.acceptRoomInvite(roomName, user.page);
}
await TestHelpers.startCallInCurrentRoom(alice.page, false);
await expect(
alice.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
await TestHelpers.joinCallFromLobby(alice.page);
for (const user of [bob, carol]) {
await TestHelpers.joinCallInCurrentRoom(user.page);
}
for (const user of [alice, bob, carol]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// Expect 3 video tiles
await expect(frame.locator("video")).toHaveCount(3, {
timeout: 10000,
});
}
// await alice.page.pause();
await alice.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByRole("switch", { name: "Share screen" })
.click();
// await alice.page.pause();
for (const user of [alice, bob, carol]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// Expect 4 (3 + screen share) video tiles
await expect(frame.locator("video")).toHaveCount(4, {
timeout: 5000,
});
await expect(
frame.locator('video[data-lk-source="screen_share"]'),
).toHaveCount(1);
}
// Alice should be in grid mode as she is local sharing
{
const frame = alice.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByRole("radio", { name: "Grid" })).toBeChecked();
}
// Others should have switched to spotlight
for (const user of [bob, carol]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByRole("radio", { name: "Spotlight" })).toBeChecked();
}
// await alice.page.pause();
// await bob.page.pause();
// Let's start another screen share from bob
await bob.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByRole("switch", { name: "Share screen" })
.click();
{
const frame = carol.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// Expect 5 (2 + screen share) video tiles
await expect(frame.locator("video")).toHaveCount(5, {
timeout: 5000,
});
await expect(
frame.locator('video[data-lk-source="screen_share"]'),
).toHaveCount(2);
// Expect 2 indicators at the bottom
await expect(frame.getByTestId("screenshare-indicator")).toHaveCount(2);
// Check the first indicator is visible
await expect(
frame.getByTestId("screenshare-indicator").first(),
).toHaveAttribute("data-visible", "true");
await carol.page.pause();
// now click on next
await expect(frame.getByRole("button", { name: "Next" })).toBeVisible();
await frame.getByRole("button", { name: "Next" }).click();
// Check the second indicator is visible
await expect(
frame.getByTestId("screenshare-indicator").nth(1),
).toHaveAttribute("data-visible", "true");
// the first one should be grayed out
await expect(
frame.getByTestId("screenshare-indicator").first(),
).toHaveAttribute("data-visible", "false");
// There should be a prev button now
await expect(frame.getByRole("button", { name: "Back" })).toBeVisible();
// await carol.page.pause();
}
});

View File

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

View File

@@ -10,12 +10,9 @@ 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";
@@ -29,14 +26,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 page.getByRole("button", { name: buttonName }).click({
timeout: 5000,
});
await expect(
page.getByRole("menuitem", { name: "Element Call" }),
).toBeVisible();
await page.getByRole("menuitem", { name: "Element Call" }).click({
timeout: 10000,
});
await page.getByRole("menuitem", { name: "Element Call" }).click();
}
public static async joinCallFromLobby(page: Page): Promise<void> {
@@ -54,40 +51,15 @@ export class TestHelpers {
.click();
}
public static async joinCallInCurrentDM(
page: Page,
audioOnly: boolean = false,
): Promise<void> {
await this.joinCallInRoom(page, audioOnly, true);
}
public static async joinCallInCurrentRoom(
page: Page,
audioOnly: boolean = false,
): Promise<void> {
await this.joinCallInRoom(page, audioOnly, false);
}
public static async joinCallInRoom(
page: Page,
audioOnly: boolean = false,
isDM: boolean = false,
): Promise<void> {
// XXX This using the notification toast to join the room.
// Not the button in the header
await page.waitForTimeout(3000);
const label = isDM
? audioOnly
? "Incoming voice call"
: "Incoming video call"
: "Group call started";
await expect(page.getByText(label)).toBeVisible({
timeout: 10000,
});
await page.getByRole("button", { name: "Join" }).click({
timeout: 5000,
});
// 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();
}
/**
@@ -102,90 +74,97 @@ 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: "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 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 expect(
page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible({
// Increase timeout here :/ flaky
timeout: 15000,
// Increase timeout as registration can be slow :/
timeout: 15_000,
});
await this.dismissStartupToasts(page);
await this.maybeDismissBrowserNotSupportedToast(page);
await this.maybeDismissServiceWorkerWarningToast(page);
await TestHelpers.setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const mxId = credentials.user_id;
const mxId = (await clientHandle.evaluate(
(cli: MatrixClient) => cli.getUserId(),
clientHandle,
))!;
return { page, clientHandle, mxId };
}
// Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
// dismiss (if part of expected toats)
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
private static async maybeDismissBrowserNotSupportedToast(
page: Page,
): Promise<void> {
const browserUnsupportedToast = page
.getByText("Element does not support this browser")
.locator("..")
.locator("..");
const toast = page.locator(".mx_Toast_toast");
// Dismiss incompatible browser toast
const dismissButton = browserUnsupportedToast.getByRole("button", {
name: "Dismiss",
});
try {
await expect(dismissButton).toBeVisible({ timeout: 700 });
await dismissButton.click();
} catch {
// dismissButton not visible, continue as normal
}
}
// eslint-disable-next-line no-constant-condition
while (true) {
try {
await toast.waitFor({ state: "visible", timeout: 700 });
const title = await toast.locator(".mx_Toast_title h2").textContent();
private static async maybeDismissServiceWorkerWarningToast(
page: Page,
): Promise<void> {
const toast = page
.locator(".mx_Toast_toast")
.getByText("Failed to load service worker");
// Find the matching toast config
const toastConfig = expectedToasts.find((t) =>
title?.includes(t.title),
);
try {
await expect(toast).toBeVisible({ timeout: 700 });
await page
.locator(".mx_Toast_toast")
.getByRole("button", { name: "OK" })
.click();
} catch {
// toast not visible, continue as normal
}
}
if (toastConfig) {
await toast.getByRole("button", { name: toastConfig.button }).click();
} else {
// Unknown toast. We don't want to act on unknown toasts
break;
}
} catch {
// No toast visible, exit loop
break;
}
public static async maybeDismissKeyBackupToast(page: Page): Promise<void> {
const toast = page
.locator(".mx_Toast_toast")
.getByText("Back up your chats");
try {
await expect(toast).toBeVisible({ timeout: 700 });
await page
.locator(".mx_Toast_toast")
.getByRole("button", { name: "Dismiss" })
.click();
} catch {
// toast not visible, continue as normal
}
}
@@ -199,16 +178,12 @@ export class TestHelpers {
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New Room" }).click({
timeout: 5000,
});
await page.getByRole("menuitem", { name: "New Room" }).click();
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({
timeout: 10000,
});
await expect(page.getByText("You created this room.")).toBeVisible();
await expect(page.getByText("Encryption enabled")).toBeVisible();
await TestHelpers.dismissStartupToasts(page);
await TestHelpers.maybeDismissKeyBackupToast(page);
// Invite users if any
if (andInvite.length > 0) {
@@ -224,7 +199,6 @@ export class TestHelpers {
}
await page.getByRole("button", { name: "Invite" }).click();
await TestHelpers.dismissInviteUnknownUserModal(page);
}
}
@@ -237,17 +211,14 @@ export class TestHelpers {
roomName: string,
page: Page,
): Promise<void> {
await page.getByRole("option", { name: roomName }).click({
timeout: 10000,
});
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
await expect(page.getByRole("option", { name: roomName })).toBeVisible();
await page.getByRole("option", { name: roomName }).click();
await page.getByRole("button", { name: "Accept" }).click();
await expect(
page.getByRole("main").getByRole("heading", { name: roomName }),
).toBeVisible();
await TestHelpers.dismissStartupToasts(page);
await TestHelpers.maybeDismissKeyBackupToast(page);
}
/**
@@ -262,12 +233,8 @@ export class TestHelpers {
page: Page,
mode: RtcMode,
): Promise<void> {
await page.getByRole("button", { name: "Video call" }).click({
timeout: 5000,
});
await page.getByRole("menuitem", { name: "Element Call" }).click({
timeout: 10000,
});
await page.getByRole("button", { name: "Video call" }).click();
await page.getByRole("menuitem", { name: "Element Call" }).click();
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
await page.getByRole("button", { name: "Close lobby" }).click();
@@ -341,52 +308,4 @@ 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,6 +20,8 @@ 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);
@@ -41,7 +43,7 @@ widgetTest(
).toBeVisible();
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Join" }).click();
await whistler.page.getByRole("button", { name: "Accept" }).click();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
@@ -111,6 +113,8 @@ 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);
@@ -132,7 +136,7 @@ widgetTest(
).toBeVisible();
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Join" }).click();
await whistler.page.getByRole("button", { name: "Accept" }).click();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
@@ -196,6 +200,8 @@ 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);

14986
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
# 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

@@ -49,22 +49,17 @@
"matchDepNames": ["vaul"],
"prHeader": "Please review modals on mobile for visual regressions."
},
{
"groupName": "PostHog",
"matchDepNames": ["posthog-js"],
"prHeader": "Please ensure that all analytics data is still appropriately sanitized."
},
{
"groupName": "embedded package dependencies",
"matchFileNames": ["embedded/**/*"]
},
{
"groupName": "Pnpm",
"matchDepNames": ["pnpm"]
"groupName": "Yarn",
"matchDepNames": ["yarn"]
}
],
"semanticCommits": "disabled",
"ignoreDeps": ["eslint-plugin-matrix-org"],
"ignoreDeps": ["posthog-js", "eslint-plugin-matrix-org"],
"vulnerabilityAlerts": {
"schedule": ["at any time"],
"prHourlyLimit": 0,

View File

@@ -1,62 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
// 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.
// - The goal is to not have modifications to the package.json and lock files so that we do not track links on gh.
// This seems to modify the package.json file.
// What can we do with pnpm to have the link inforamtion in a seperate file
// - why do you cache the loaded links. When does this file get executed?
// Do we need this optimization.
// How do we guarantee, that we aleays use the most recent content from the links file?
//
// Manual transition to cjs. Claude proposed manual yaml parsing.
const fs = require("fs");
const path = require("path");
function loadLinks() {
try {
return require(path.join(__dirname, ".links.cjs"));
} catch (e) {
return null;
}
}
function readPackage(pkg, context) {
const links = loadLinks();
if (!links) return pkg;
const manifest = JSON.parse(
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
);
if (pkg.name !== manifest.name) return pkg;
for (const [name, linkPath] of Object.entries(links)) {
const resolved = `link:${path.resolve(__dirname, linkPath)}`;
if (pkg.dependencies && pkg.dependencies[name]) {
context.log(`Linking ${name} -> ${resolved}`);
pkg.dependencies[name] = resolved;
} else if (pkg.devDependencies && pkg.devDependencies[name]) {
context.log(`Linking ${name} -> ${resolved}`);
pkg.devDependencies[name] = resolved;
}
}
return pkg;
}
module.exports = {
hooks: {
readPackage,
},
};

View File

@@ -5,5 +5,5 @@ set -ex
export VITE_APP_VERSION=$(git describe --tags --abbrev=0)
corepack enable
pnpm install
pnpm run build
yarn install
yarn run build

View File

@@ -1,10 +1,10 @@
#!/bin/sh
if [ -n "$USE_DOCKER" ]; then
set -ex
pnpm build
yarn build
docker build -t element-call:testing .
exec docker run --rm --name element-call-testing -p 8080:8080 -v ./config/config.devenv.json:/app/config.json:ro,Z element-call:testing
else
cp config/config.devenv.json public/config.json
exec pnpm dev --host
exec yarn dev
fi

View File

@@ -1,26 +0,0 @@
#!/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
```
pnpm install
pnpm build:sdk
yarn
yarn 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: `pnpm build:sdk`
* This file is the entrypoint for the sdk build of element call: `yarn 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

@@ -1,25 +1,10 @@
.bar {
block-size: 64px;
flex-shrink: 0;
position: relative;
}
/* Pseudo-element for the gradient background */
.bar::before {
content: "";
position: absolute;
inset-inline: 0;
/* Extend the gradient beyond the bottom of the header for readability */
inset-block: -24px;
z-index: var(--call-view-header-footer-layer);
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
}
.bar > header {
position: sticky;
position: absolute;
inset-inline: 0;
inset-block-start: 0;
block-size: 64px;

View File

@@ -19,7 +19,6 @@ import {
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import { Header, LeftNav, RightNav } from "./Header";
import { platform } from "./Platform";
@@ -50,9 +49,7 @@ export const AppBar: FC<Props> = ({ children }) => {
const [title, setTitle] = useState<string>("");
const [hidden, setHidden] = useState<boolean>(false);
const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>(
null,
);
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
const context = useMemo(
() => ({ setTitle, setSecondaryButton, setHidden }),
[setTitle, setHidden, setSecondaryButton],
@@ -71,8 +68,8 @@ export const AppBar: FC<Props> = ({ children }) => {
>
<LeftNav>
<Tooltip label={t("common.back")}>
<IconButton size="24px" onClick={onBackClick}>
<CollapseIcon aria-hidden />
<IconButton onClick={onBackClick}>
<CollapseIcon />
</IconButton>
</Tooltip>
</LeftNav>
@@ -117,10 +114,6 @@ export function useAppBarHidden(hidden: boolean): void {
if (setHidden !== undefined) {
setHidden(hidden);
return (): void => setHidden(false);
} else if (platform !== "desktop") {
logger.warn(
"[AppBar] useAppBarHidden called without AppBarContext provider, this will have no effect",
);
}
}, [setHidden, hidden]);
}
@@ -136,10 +129,6 @@ export function useAppBarSecondaryButton(button: ReactNode): void {
if (setSecondaryButton !== undefined) {
setSecondaryButton(button);
return (): void => setSecondaryButton("");
} else if (platform !== "desktop") {
logger.warn(
"[AppBar] useAppBarSecondaryButton called without AppBarContext provider, this will have no effect",
);
}
}, [button, setSecondaryButton]);
}

View File

@@ -12,9 +12,7 @@ Please see LICENSE in the repository root for full details.
align-items: center;
user-select: none;
flex-shrink: 0;
padding-left: var(--content-inset-left);
padding-right: var(--content-inset-right);
padding-top: env(safe-area-inset-top);
padding-inline: var(--inline-content-inset);
}
.nav {

View File

@@ -92,11 +92,6 @@ export const Modal: FC<Props> = ({
return (
<Drawer.Root
open={open}
// This autofocus is a custom vault property and not the
// standard HTML autofocus attribute.
// It makes the Drawer.Root behave like the `DialogRoot`
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onOpenChange={onOpenChange}
dismissible={onDismiss !== undefined}
>

View File

@@ -87,7 +87,7 @@ export const RTCConnectionStats: FC<Props> = ({
<div>
<Button
onClick={() => showFullModal("audio")}
size="md"
size="sm"
kind="tertiary"
Icon={MicOnSolidIcon}
>
@@ -103,7 +103,7 @@ export const RTCConnectionStats: FC<Props> = ({
<div>
<Button
onClick={() => showFullModal("video")}
size="md"
size="sm"
kind="tertiary"
Icon={VideoCallSolidIcon}
>

View File

@@ -17,7 +17,7 @@ exports[`AppBar > renders 1`] = `
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 24px;"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
@@ -25,7 +25,6 @@ exports[`AppBar > renders 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"

View File

@@ -14,13 +14,8 @@ import {
beforeAll,
afterAll,
} from "vitest";
import posthog, { type CaptureResult } from "posthog-js";
import {
Anonymity,
santizeSensitiveData,
PosthogAnalytics,
} from "./PosthogAnalytics";
import { PosthogAnalytics } from "./PosthogAnalytics";
import { mockConfig } from "../utils/test";
describe("PosthogAnalytics", () => {
@@ -93,154 +88,4 @@ describe("PosthogAnalytics", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
});
});
describe("applyPrivacyFilters", () => {
const makeEvent = (properties: Record<string, unknown>): CaptureResult =>
({ event: "anyEvent", properties }) as unknown as CaptureResult;
it("drops $initial_person_info regardless of anonymity", () => {
const out = santizeSensitiveData(
makeEvent({
$current_url: "https://call.example.com/some/private/path",
$initial_person_info: {
r: "https://example.com/referrer",
u: "https://call.example.com/some/private/path",
},
}),
Anonymity.Pseudonymous,
);
expect(out?.properties).not.toHaveProperty("$initial_person_info");
});
it("strips hash from $current_url", () => {
const out = santizeSensitiveData(
makeEvent({ $current_url: "https://call.example.com/#/x/y/z" }),
Anonymity.Pseudonymous,
);
expect(out?.properties["$current_url"]).not.toContain("/x/y/z");
});
it("nulls referrer and device fields when anonymous", () => {
const out = santizeSensitiveData(
makeEvent({
$current_url: "https://x/y",
$referrer: "https://leaky",
$initial_referrer: "https://leaky-too",
$device_id: "uuid",
}),
Anonymity.Anonymous,
);
expect(out?.properties["$referrer"]).toBeUndefined();
expect(out?.properties["$initial_referrer"]).toBeUndefined();
expect(out?.properties["$device_id"]).toBeUndefined();
});
it("passes null events through unchanged", () => {
expect(santizeSensitiveData(null, Anonymity.Pseudonymous)).toBeNull();
});
it("strips URL fields nested inside $set_once", () => {
const secretUrl =
"https://call.example.com/room/#/?password=hunter2&roomId=abc";
const out = santizeSensitiveData(
makeEvent({
$current_url: "https://call.example.com/x",
$set_once: {
$current_url: secretUrl,
$initial_current_url: secretUrl,
$session_entry_url: secretUrl,
$initial_person_info: { r: "x", u: secretUrl },
},
}),
Anonymity.Pseudonymous,
);
const setOnce = out?.properties["$set_once"] as Record<string, unknown>;
expect(setOnce["$current_url"]).not.toContain("password");
expect(setOnce["$initial_current_url"]).not.toContain("password");
expect(setOnce).not.toHaveProperty("$session_entry_url");
expect(setOnce).not.toHaveProperty("$initial_person_info");
});
it("strips URL fields nested inside $set", () => {
const secretUrl =
"https://call.example.com/room/#/?password=hunter2&roomId=abc";
const out = santizeSensitiveData(
makeEvent({
$current_url: "https://call.example.com/x",
$set: {
$current_url: secretUrl,
$session_entry_url: secretUrl,
},
}),
Anonymity.Pseudonymous,
);
const set = out?.properties["$set"] as Record<string, unknown>;
expect(set["$current_url"]).not.toContain("password");
expect(set).not.toHaveProperty("$session_entry_url");
});
it("nulls referrer fields inside $set_once when anonymous", () => {
const out = santizeSensitiveData(
makeEvent({
$current_url: "https://x/y",
$set_once: {
$initial_referrer: "https://leaky",
$initial_referring_domain: "leaky",
},
}),
Anonymity.Anonymous,
);
const setOnce = out?.properties["$set_once"] as Record<string, unknown>;
expect(setOnce["$initial_referrer"]).toBeUndefined();
expect(setOnce["$initial_referring_domain"]).toBeUndefined();
});
});
// Verifies that applyPrivacyFilters is actually wired into posthog.init via
// the before_send hook — guards against typos in the option name or future
// posthog-js bumps renaming/removing the hook. The filter logic itself is
// covered by the applyPrivacyFilters block above.
describe("posthog.init wiring", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
beforeEach(() => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("passes events through the privacy filter via before_send", () => {
const initSpy = vi.spyOn(posthog, "init");
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
const beforeSend = initSpy.mock.calls[0][1]?.before_send;
expect(beforeSend).toBeInstanceOf(Function);
const event = {
event: "anyEvent",
properties: {
$current_url: "https://call.example.com/x/y",
$initial_person_info: { r: "x" },
},
} as unknown as CaptureResult;
const out = (beforeSend as (e: CaptureResult) => CaptureResult | null)(
event,
);
expect(out?.properties).not.toHaveProperty("$initial_person_info");
});
});
});

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import posthog, {
type CaptureOptions,
type CaptureResult,
type PostHog,
type Properties,
} from "posthog-js";
@@ -27,7 +26,6 @@ import {
QualitySurveyEventTracker,
CallDisconnectedEventTracker,
CallConnectDurationTracker,
CallReconnectingTracker,
} from "./PosthogEvents";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
@@ -66,73 +64,6 @@ export enum RegistrationType {
Registered,
}
// Sanitize URL / referrer / device fields on a single posthog properties bag.
// Applied to event.properties and to the person-profile bags ($set / $set_once),
// since posthog mirrors the same URL fields into those.
function stripSensitiveFields(
obj: Properties | undefined,
anonymity: Anonymity,
): void {
if (!obj) return;
if (anonymity === Anonymity.Anonymous) {
// drop referrer information for anonymous users
delete obj["$referrer"];
delete obj["$referring_domain"];
delete obj["$initial_referrer"];
delete obj["$initial_referring_domain"];
// drop device ID, which is a UUID persisted in local storage
delete obj["$device_id"];
}
// the url leaks a lot of private data like the call name or the user
// (room password / room ID can land in the hash/query). Strip down to
// scheme + host so we still get host-level insights (develop / main / sfu).
for (const key of ["$current_url", "$initial_current_url"]) {
if (typeof obj[key] === "string") {
try {
const url = new URL(obj[key]);
obj[key] = url.protocol + "//" + url.hostname + url.pathname;
} catch {
obj[key] = null;
}
}
}
// $session_entry_url carries the full untrimmed URL; $initial_person_info
// bundles initial referrer + URL into a nested object that bypasses the
// per-key strips above. Drop both.
delete obj["$session_entry_url"];
delete obj["$initial_person_info"];
}
/**
* Strip PII from posthog's built-in properties (URL, referrer fields,
* device ID, $initial_person_info, $session_entry_url) before events leave
* the client. Also applied to the person-profile bags ($set / $set_once),
* which mirror the same URL fields.
* See src/utils/event-utils.ts in posthog-js (getEventProperties, getPersonInfo)
* for the list of properties posthog sets automatically.
*/
export function santizeSensitiveData(
event: CaptureResult | null,
anonymity: Anonymity,
): CaptureResult | null {
if (event === null) return null;
stripSensitiveFields(event.properties, anonymity);
// posthog can stash person-profile updates either at the top level
// of CaptureResult or nested inside properties depending on the pipeline
// stage; clean both spots so nothing slips through.
stripSensitiveFields(event.$set, anonymity);
stripSensitiveFields(event.$set_once, anonymity);
stripSensitiveFields(event.properties["$set"], anonymity);
stripSensitiveFields(event.properties["$set_once"], anonymity);
return event;
}
interface PlatformProperties {
appVersion: string;
matrixBackend: "embedded" | "jssdk";
@@ -197,16 +128,13 @@ export class PosthogAnalytics {
}
if (apiKey && apiHost) {
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
santizeSensitiveData(event, this.anonymity);
this.posthog.init(apiKey, {
api_host: apiHost,
autocapture: false,
mask_all_text: true,
mask_all_element_attributes: true,
mask_personal_data_properties: true,
capture_pageview: false,
before_send: beforeSend,
sanitize_properties: this.sanitizeProperties,
respect_dnt: true,
advanced_disable_decide: true,
});
@@ -219,6 +147,34 @@ export class PosthogAnalytics {
}
}
private sanitizeProperties = (
properties: Properties,
_eventName: string,
): Properties => {
// Callback from posthog to sanitize properties before sending them to the server.
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
// See utils.js _.info.properties in posthog-js.
if (this.anonymity == Anonymity.Anonymous) {
// drop referrer information for anonymous users
properties["$referrer"] = null;
properties["$referring_domain"] = null;
properties["$initial_referrer"] = null;
properties["$initial_referring_domain"] = null;
// drop device ID, which is a UUID persisted in local storage
properties["$device_id"] = null;
}
// the url leaks a lot of private data like the call name or the user.
// Its stripped down to the bare minimum to only give insights about the host (develop, main or sfu)
properties["$current_url"] = (properties["$current_url"] as string)
.split("/")
.slice(0, 3)
.join("");
return properties;
};
private registerSuperProperties(properties: Properties): void {
if (this.enabled) {
this.posthog.register(properties);
@@ -465,5 +421,4 @@ export class PosthogAnalytics {
public eventQualitySurvey = new QualitySurveyEventTracker();
public eventCallDisconnected = new CallDisconnectedEventTracker();
public eventCallConnectDuration = new CallConnectDurationTracker();
public eventCallReconnecting = new CallReconnectingTracker();
}

View File

@@ -1,237 +0,0 @@
/*
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,
CallReconnectingTracker,
type CallReconnectingReason,
} 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,
callReconnectingCount: 0,
callReconnectingCountSync: 0,
callReconnectingCountMembership: 0,
callReconnectingCountProbablyLeft: 0,
callReconnectingCountLivekit: 0,
},
{ 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 },
);
});
it("includes per-reason reconnecting counts in CallEnded", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.cacheReconnecting("sync");
tracker.cacheReconnecting("sync");
tracker.cacheReconnecting("livekit");
tracker.cacheReconnecting("membership");
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
callReconnectingCount: 4,
callReconnectingCountSync: 2,
callReconnectingCountMembership: 1,
callReconnectingCountProbablyLeft: 0,
callReconnectingCountLivekit: 1,
}),
expect.anything(),
);
});
});
describe("CallReconnecting", () => {
beforeAll(() => {
mockConfig();
});
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation(
() => {},
);
});
afterAll(() => {
PosthogAnalytics.resetInstance();
});
it("tracks event with correct shape", () => {
const tracker = new CallReconnectingTracker();
tracker.track("!room:example.org", "sync", 3.5);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
eventName: "CallReconnecting",
callId: "!room:example.org",
reason: "sync",
reconnectDuration: 3.5,
});
});
it.each([
"sync",
"membership",
"probablyLeft",
"livekit",
] as CallReconnectingReason[])("tracks reason %s correctly", (reason) => {
const tracker = new CallReconnectingTracker();
tracker.track("!room:example.org", reason, 1.0);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({ reason, reconnectDuration: 1.0 }),
);
});
});

View File

@@ -17,7 +17,6 @@ import {
interface CallEnded extends IPosthogEvent {
eventName: "CallEnded";
// the callId posthog key is essentially a Matrix roomId
callId: string;
callParticipantsOnLeave: number;
callParticipantsMax: number;
@@ -25,43 +24,16 @@ interface CallEnded extends IPosthogEvent {
roomEventEncryptionKeysSent: number;
roomEventEncryptionKeysReceived: number;
roomEventEncryptionKeysReceivedAverageAge: number;
callReconnectingCount: number;
callReconnectingCountSync: number;
callReconnectingCountMembership: number;
callReconnectingCountProbablyLeft: number;
callReconnectingCountLivekit: number;
}
export class CallEndedTracker {
private cache: {
startTime?: Date;
maxParticipantsCount: number;
reconnectingCount: number;
reconnectingCountByReason: Record<CallReconnectingReason, number>;
} = {
startTime: undefined,
private cache: { startTime: Date; maxParticipantsCount: number } = {
startTime: new Date(0),
maxParticipantsCount: 0,
reconnectingCount: 0,
reconnectingCountByReason: {
sync: 0,
membership: 0,
probablyLeft: 0,
livekit: 0,
},
};
public cacheStartCall(time: Date): void {
this.cache = {
startTime: time,
maxParticipantsCount: 0,
reconnectingCount: 0,
reconnectingCountByReason: {
sync: 0,
membership: 0,
probablyLeft: 0,
livekit: 0,
},
};
this.cache.startTime = time;
}
public cacheParticipantCountChanged(count: number): void {
@@ -71,57 +43,37 @@ export class CallEndedTracker {
);
}
public cacheReconnecting(reason: CallReconnectingReason): void {
this.cache.reconnectingCount++;
this.cache.reconnectingCountByReason[reason]++;
}
public track(
callId: string,
callParticipantsNow: number,
sendInstantly: boolean,
rtcSession: MatrixRTCSession,
): void {
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,
callReconnectingCount: this.cache.reconnectingCount,
callReconnectingCountSync: this.cache.reconnectingCountByReason.sync,
callReconnectingCountMembership:
this.cache.reconnectingCountByReason.membership,
callReconnectingCountProbablyLeft:
this.cache.reconnectingCountByReason.probablyLeft,
callReconnectingCountLivekit:
this.cache.reconnectingCountByReason.livekit,
},
{ send_instantly: sendInstantly },
);
} else {
logger.warn(
"[PosthogEvents] Failed to send posthog callEnded event due to missing 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 },
);
}
}
interface CallStarted extends IPosthogEvent {
eventName: "CallStarted";
// the callId posthog key is essentially a Matrix roomId
callId: string;
}
@@ -182,7 +134,6 @@ export class LoginTracker {
interface MuteMicrophone {
eventName: "MuteMicrophone";
targetMuteState: "mute" | "unmute";
// the callId posthog key is essentially a Matrix roomId
callId: string;
}
@@ -199,7 +150,6 @@ export class MuteMicrophoneTracker {
interface MuteCamera {
eventName: "MuteCamera";
targetMuteState: "mute" | "unmute";
// the callId posthog key is essentially a Matrix roomId
callId: string;
}
@@ -215,7 +165,6 @@ export class MuteCameraTracker {
interface UndecryptableToDeviceEvent {
eventName: "UndecryptableToDeviceEvent";
// the callId posthog key is essentially a Matrix roomId
callId: string;
}
@@ -230,7 +179,6 @@ export class UndecryptableToDeviceEventTracker {
interface QualitySurveyEvent {
eventName: "QualitySurvey";
// the callId posthog key is essentially a Matrix roomId
callId: string;
feedbackText: string;
stars: number;
@@ -295,32 +243,3 @@ export class CallConnectDurationTracker {
);
}
}
export type CallReconnectingReason =
| "sync"
| "membership"
| "probablyLeft"
| "livekit";
interface CallReconnecting extends IPosthogEvent {
eventName: "CallReconnecting";
// the callId posthog key is essentially a Matrix roomId
callId: string;
reason: CallReconnectingReason;
reconnectDuration: number;
}
export class CallReconnectingTracker {
public track(
callId: string,
reason: CallReconnectingReason,
reconnectDuration: number,
): void {
PosthogAnalytics.instance.trackEvent<CallReconnecting>({
eventName: "CallReconnecting",
callId,
reason,
reconnectDuration,
});
}
}

View File

@@ -8,11 +8,7 @@ Please see LICENSE in the repository root for full details.
import { type ComponentPropsWithoutRef, type FC } from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import {
Button as CpdButton,
IconButton,
Tooltip,
} from "@vector-im/compound-web";
import { Button as CpdButton, Tooltip } from "@vector-im/compound-web";
import {
MicOnSolidIcon,
MicOffSolidIcon,
@@ -20,19 +16,14 @@ import {
VideoCallOffSolidIcon,
EndCallIcon,
ShareScreenSolidIcon,
OverflowHorizontalIcon,
OverflowVerticalIcon,
VolumeOnSolidIcon,
VolumeOffSolidIcon,
SettingsSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Button.module.css";
import callFooterStyles from "../components/CallFooter.module.css";
import { platform } from "../Platform";
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
size?: "md" | "lg";
size?: "sm" | "lg";
}
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
@@ -47,7 +38,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
<CpdButton
iconOnly
Icon={Icon}
kind={enabled ? "secondary" : "primary"}
kind={enabled ? "primary" : "secondary"}
role="switch"
aria-checked={enabled}
{...props}
@@ -58,7 +49,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
size?: "md" | "lg";
size?: "sm" | "lg";
}
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
@@ -73,7 +64,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
<CpdButton
iconOnly
Icon={Icon}
kind={enabled ? "secondary" : "primary"}
kind={enabled ? "primary" : "secondary"}
role="switch"
aria-checked={enabled}
{...props}
@@ -84,7 +75,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
size: "md" | "lg";
size: "sm" | "lg";
}
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
@@ -111,7 +102,7 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
};
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "md" | "lg";
size?: "sm" | "lg";
}
export const EndCallButton: FC<EndCallButtonProps> = ({
@@ -133,87 +124,18 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
);
};
interface LoudspeakerButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "md" | "lg";
loudspeakerModeEnabled: boolean;
}
export const LoudspeakerButton: FC<LoudspeakerButtonProps> = ({
loudspeakerModeEnabled,
...props
}) => {
const { t } = useTranslation();
// if the target is the earpice, we are currently in loudspeaker mode.
const label = loudspeakerModeEnabled
? t("settings.devices.loudspeaker")
: t("settings.devices.handset");
return (
<Tooltip label={label}>
<CpdButton
iconOnly
Icon={loudspeakerModeEnabled ? VolumeOnSolidIcon : VolumeOffSolidIcon}
{...props}
kind={loudspeakerModeEnabled ? "primary" : "secondary"}
aria-checked={loudspeakerModeEnabled}
/>
</Tooltip>
);
};
function classNamesForScreenWidth(
className?: string,
forScreenWidth?: "wide" | "narrow",
): string {
return classNames(className, {
[callFooterStyles.settingsOnlyShowWide]: forScreenWidth === "wide",
[callFooterStyles.settingsOnlyShowNarrow]: forScreenWidth === "narrow",
});
}
interface SettingsIconButtonProps extends ComponentPropsWithoutRef<"button"> {
/** If this buttons should be setup to be used in the app bar */
showForScreenWidth?: "wide" | "narrow";
kind?: "secondary" | "primary";
}
export const SettingsIconButton: FC<SettingsIconButtonProps> = ({
showForScreenWidth,
className,
...props
}) => {
const { t } = useTranslation();
const Icon =
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
return (
<Tooltip label={t("common.settings")}>
<IconButton
className={classNamesForScreenWidth(className, showForScreenWidth)}
{...props}
>
<Icon aria-hidden />
</IconButton>
</Tooltip>
);
};
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "md" | "lg";
/** If this buttons should be setup to be used in the app bar */
showForScreenWidth?: "wide" | "narrow";
size?: "sm" | "lg";
}
export const SettingsButton: FC<SettingsButtonProps> = ({
showForScreenWidth,
className,
...props
}) => {
export const SettingsButton: FC<SettingsButtonProps> = (props) => {
const { t } = useTranslation();
return (
<Tooltip label={t("common.settings")}>
<CpdButton
className={classNamesForScreenWidth(className, showForScreenWidth)}
iconOnly
Icon={
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon
}
kind={"secondary"}
Icon={SettingsSolidIcon}
kind="secondary"
{...props}
/>
</Tooltip>

View File

@@ -15,7 +15,7 @@ export const InviteButton: FC<
> = (props) => {
const { t } = useTranslation();
return (
<Button kind="secondary" size="md" Icon={UserAddIcon} {...props}>
<Button kind="secondary" size="sm" Icon={UserAddIcon} {...props}>
{t("action.invite")}
</Button>
);

View File

@@ -37,13 +37,7 @@ function TestComponent({
vm={vm}
rtcSession={rtcSession.asMockedSession()}
>
<ReactionToggleButton
reactionData={{
reactions$: vm.reactions$,
handsRaised$: vm.handsRaised$,
}}
identifier={localIdent}
/>
<ReactionToggleButton vm={vm} identifier={localIdent} />
</ReactionsSenderProvider>
</TooltipProvider>
);

View File

@@ -28,14 +28,13 @@ import classNames from "classnames";
import { useReactionsSender } from "../reactions/useReactionsSender";
import styles from "./ReactionToggleButton.module.css";
import {
type RaisedHandInfo,
type ReactionOption,
ReactionSet,
ReactionsRowSize,
} from "../reactions";
import { Modal } from "../Modal";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
import { type Behavior } from "../state/Behavior";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
@@ -164,22 +163,15 @@ export function ReactionPopupMenu({
);
}
export interface ReactionData {
handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
reactions$: Behavior<Record<string, ReactionOption>>;
}
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
reactionData: ReactionData;
identifier: string;
size?: "md" | "lg";
/** List of participants raising their hand */
vm: CallViewModel;
size?: "sm" | "lg";
}
export function ReactionToggleButton({
identifier,
reactionData: { handsRaised$, reactions$ },
vm,
...props
}: ReactionToggleButtonProps): ReactNode {
const { t } = useTranslation();
@@ -188,8 +180,8 @@ export function ReactionToggleButton({
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>();
const isHandRaised = !!useBehavior(handsRaised$)[identifier];
const canReact = !useBehavior(reactions$)[identifier];
const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
const canReact = !useBehavior(vm.reactions$)[identifier];
useEffect(() => {
// Clear whenever the reactions menu state changes.

View File

@@ -10,7 +10,7 @@ exports[`Can close reaction dialog 1`] = `
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_bb_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -44,7 +44,7 @@ exports[`Can fully expand emoji picker 1`] = `
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_7m_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -75,7 +75,7 @@ exports[`Can lower hand 1`] = `
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_36_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="secondary"
data-size="lg"
role="button"
@@ -109,7 +109,7 @@ exports[`Can open menu 1`] = `
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_0_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -140,7 +140,7 @@ exports[`Can raise hand 1`] = `
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_1j_"
class="_button_1nw83_8 raisedButton _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_13vu4_8 raisedButton _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="primary"
data-size="lg"
role="button"

View File

@@ -1,37 +0,0 @@
{/**
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
**/}
{/**
This is a custom doc page overwriting the default autodocs tag.
This can be done by using the same filename as the component
With the help of Primary, Controls,Stories the overhead is minimal
**/}
import {
Meta,
Primary,
Controls,
Stories,
Title,
Subtitle,
} from "@storybook/addon-docs/blocks";
import * as CallFooterStories from "./CallFooter.stories";
<Meta of={CallFooterStories} />
<Title> Call Footer </Title>
The footer compoentn contains all main interactions needed for a call.
<Subtitle> Mobile layouts </Subtitle>
This component is reactive. To properly check the mobile layout, you will need to click on the stories in the left sidebar to see the
component on a mobile screen.
The story summary here does not render the mobile layouts correctly.
<Primary />
<Controls of={CallFooterStories.Primary} />
<Stories />

View File

@@ -1,166 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.footer {
position: sticky;
inset-block-end: 0;
z-index: var(--call-view-header-footer-layer);
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas: ". buttons layout";
align-items: center;
gap: var(--cpd-space-3x);
/* Ensure that footer lies within the safe area */
padding-left: calc(env(safe-area-inset-left) + var(--cpd-space-6x));
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
padding-block: var(--cpd-space-10x)
calc(env(safe-area-inset-bottom) + var(--cpd-space-10x));
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
}
.footer.hidden {
display: none;
}
.footer.overlay {
/* Note that the footer is still position: sticky in this case so that certain
tiles can move up out of the way of the footer when visible. */
opacity: 1;
transition: opacity 0.15s;
}
.footer.overlay.hidden {
display: grid;
opacity: 0;
pointer-events: none;
/* Switch to position: absolute so the footer takes up no space in the layout
when hidden. */
position: absolute;
inset-block-end: 0;
inset-inline: 0;
}
.footer.overlay:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
.settingsLogoContainer {
display: flex;
align-items: center;
gap: var(--cpd-space-4x);
flex-direction: row;
flex-wrap: nowrap;
}
.logo {
justify-self: start;
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
padding-inline-start: var(--cpd-space-1x);
}
.buttons {
grid-area: buttons;
justify-self: center;
display: flex;
gap: var(--cpd-space-3x);
}
.layout {
grid-area: layout;
justify-self: end;
}
/*First hide the logo*/
@media (max-width: 750px) {
.logo {
display: none;
}
}
.settingsOnlyShowNarrow {
display: none;
}
.settingsOnlyShowWide {
display: inherit;
}
/*
With the logo hidden >500px is enough space to show overflow, buttons, layout.
Once we exceed 500 we hide everything except the buttons.
*/
@media (max-width: 500px) {
.footer {
grid-template-areas: "buttons buttons buttons";
}
.settingsOnlyShowNarrow {
display: inherit;
}
.settingsOnlyShowWide {
display: none;
}
.settingsLogoContainer {
display: none;
}
.layout {
display: none !important;
}
}
@media (max-height: 800px) {
.footer {
padding-block: var(--cpd-space-8x)
calc(env(safe-area-inset-bottom) + var(--cpd-space-8x));
}
}
@media (max-height: 400px) {
.footer {
padding-block: var(--cpd-space-4x)
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
}
}
@media (max-width: 370px) {
.shareScreen {
display: none;
}
/* PIP custom css */
@media (max-height: 400px) {
.shareScreen {
display: flex;
}
.footer {
padding-block-start: var(--cpd-space-3x);
padding-block-end: calc(
env(safe-area-inset-bottom) + var(--cpd-space-2x)
);
}
}
}
@media (max-width: 320px) {
.raiseHand {
display: none;
}
}
@media (min-width: 800px) {
.buttons {
gap: var(--cpd-space-4x);
}
}

View File

@@ -1,383 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, fn, userEvent, within } from "storybook/test";
import { BehaviorSubject } from "rxjs";
import { type JSX, type ReactNode } from "react";
import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css";
import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
// consts for tests
const reactionIdentifier = "@user:example.com:DEVICE";
const reactionData = {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
};
const mediaDevices = new MediaDevices(globalScope);
/**
* A wrapper component that is used for:
* - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm)
* - Add additional react context
* The paraeters are all params from the FooterSnapshot,
* the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter.
* `children` is used for the "Back to Recents" button in the lobby stories, but can be used for anything really.
* @returns A component that renders the CallFooter based on primitive snapshot params (not a view model). Which is what we want for storybook.
*/
function CallFooterStoryWrapper({
children,
...vmSnapshot
}: FooterSnapshot & {
children?: false | JSX.Element | JSX.Element[] | undefined;
}): ReactNode {
const vm = useStaticViewModel(vmSnapshot);
return (
<MediaDevicesContext value={mediaDevices}>
<div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext
value={{
supportsReactions: false,
toggleRaisedHand: async () => Promise.resolve(),
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
}}
>
<CallFooter vm={vm} />
</ReactionsSenderContext>
</div>
</MediaDevicesContext>
);
}
const meta = {
component: CallFooterStoryWrapper,
} satisfies Meta<typeof CallFooterStoryWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
const fnArgType = {
control: { type: "select" as const },
options: ["MockedCallback", "undefined"],
mapping: { MockedCallback: fn(), undefined: undefined },
};
export const Default: Story = {
args: {
showLogo: false,
layoutMode: "grid",
audioEnabled: true,
videoEnabled: true,
setLayoutMode: fn(),
openSettings: fn(),
toggleAudio: fn(),
toggleVideo: fn(),
toggleScreenSharing: fn(),
toggleBlur: fn(),
videoBlurEnabled: true,
hangup: fn(),
buttonSize: "lg",
showFooter: true,
hideControls: false,
asOverlay: false,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
reactionData: undefined,
debugTileLayout: false,
tileStoreGeneration: undefined,
audioOptions: [],
videoOptions: [],
selectedAudio: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined,
},
parameters: {
layout: "fullscreen",
},
argTypes: {
layoutMode: {
control: "radio",
options: ["grid", "spotlight"] satisfies GridMode[],
},
audioOutputSwitcher: {
control: "select",
options: ["NoOutputCallback", "speaker", "earpiece"],
table: { defaultValue: { summary: "NoOutputCallback" } },
mapping: {
NoOutputCallback: undefined,
// This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one.
speaker: { targetOutput: "earpiece", switch: fn() },
earpiece: { targetOutput: "speaker", switch: fn() },
},
},
toggleScreenSharing: fnArgType,
setLayoutMode: fnArgType,
openSettings: fnArgType,
toggleAudio: fnArgType,
toggleVideo: fnArgType,
hangup: fnArgType,
},
};
export const WithAudioAndVideoOptions: Story = {
...Default,
args: {
...Default.args,
audioEnabled: false,
videoEnabled: true,
audioOptions: [
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
videoOptions: [
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedAudio: "2",
selectedVideo: "1",
},
};
export const WithLogo: Story = {
...Default,
args: {
...Default.args,
showLogo: true,
},
};
export const AudioVideoEnabled: Story = {
...Default,
args: {
...Default.args,
audioEnabled: true,
videoEnabled: true,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
await userEvent.click(spotlightRadio);
await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight");
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
/** used to test switching to grid mode */
export const SpotlightMode: Story = {
...Default,
args: {
...Default.args,
layoutMode: "spotlight",
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
await userEvent.click(spotlightRadio);
await expect(args.setLayoutMode).toHaveBeenCalledWith("grid");
},
};
export const WithAudioOutputSpeaker: Story = {
...Default,
args: {
...Default.args,
audioOutputSwitcher: { targetOutput: "earpiece", switch: fn() },
},
};
export const WithAudioOutputEarpiece: Story = {
...Default,
args: {
...Default.args,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
},
};
export const WithReactions: Story = {
...Default,
args: {
...Default.args,
reactionIdentifier,
reactionData,
},
};
export const Pip: Story = {
...Default,
args: {
...Default.args,
buttonSize: "md",
layoutMode: undefined,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole("radio", { name: "Spotlight" }),
).not.toBeInTheDocument();
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
export const NoControlsWithLogo: Story = {
...Default,
args: {
...Default.args,
hideControls: true,
showLogo: true,
},
};
export const DebugData: Story = {
...Default,
args: {
...Default.args,
debugTileLayout: true,
tileStoreGeneration: 74,
},
};
export const UnavailableMediaDevices: Story = {
...Default,
args: {
...Default.args,
audioEnabled: false,
videoEnabled: false,
toggleAudio: undefined,
toggleVideo: undefined,
audioOutputSwitcher: undefined,
},
};
export const MobileLayout: Story = {
...Default,
args: {
...Default.args,
showLogo: false,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
},
globals: {
viewport: { value: "mobile2", isRotated: false },
},
parameters: {
...Default.parameters,
},
};
export const Lobby: Story = {
...Default,
args: {
...Default.args,
showLogo: false,
openSettings: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
parameters: {
...Default.parameters,
},
};
export const LobbyMobile: Story = {
...Default,
args: {
...Default.args,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
globals: {
viewport: { value: "mobile2", isRotated: false },
},
parameters: {
...Default.parameters,
},
};
export const LobbyRecentButton: Story = {
...Default,
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
parameters: {
...Default.parameters,
},
};
export const LobbyRecentButtonMobile: Story = {
...Default,
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
globals: {
viewport: { value: "mobile2", isRotated: false },
},
parameters: {
...Default.parameters,
},
};

View File

@@ -1,323 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames";
import {
SpotlightIcon,
GridIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Switch } from "@vector-im/compound-web";
import { t } from "i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
import {
EndCallButton,
MicButton,
VideoButton,
ShareScreenButton,
SettingsButton,
ReactionToggleButton,
LoudspeakerButton,
SettingsIconButton,
type ReactionData,
} from "../button";
import styles from "./CallFooter.module.css";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
export interface AudioOutputSwitcher {
targetOutput: string;
switch: () => void;
}
/**
* The Snapshot combines all fields required to populate the view.
*
* It is a combination of Actions and State.
* All Actions and State will be wrappen in behaviors.
* This has the advantage, that actions can mutate.
* (example: a device gets disconnected, the swicht action is not possible anymore, the actions becomes undefined)
* With it being reactive we can use the existance of the action to update the rendering without
* requiring additional state.
*
* Comment: It might not make sense to seperate the two interfaces. Hence the seperation
* just happens on the syntax level with the `type = ... & ...` notation.
*/
export type FooterSnapshot = FooterActions & FooterState;
export interface FooterActions {
/** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined;
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined;
/** Also controls if the layout button is visible */
setLayoutMode: ((mode: GridMode) => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */
openSettings: (() => void) | undefined;
/** Also controls if the hangup button is visible */
hangup: (() => void) | undefined;
}
// we do not use any ? optional properties so that the vm type is including all fields.
export interface FooterState {
audioEnabled: boolean;
videoEnabled: boolean;
videoBlurEnabled: boolean;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
hideControls: boolean;
/** The footer should be used as an overlay.
* (Over the Call Grid) This saves spaces on small screens. */
asOverlay: boolean;
buttonSize: "md" | "lg";
showLogo: boolean;
layoutMode: GridMode | undefined;
sharingScreen: boolean;
/** Also controls if the audio output button is visible */
audioOutputSwitcher: AudioOutputSwitcher | undefined;
reactionIdentifier: string | undefined;
reactionData: ReactionData | undefined;
// debug stuff
debugTileLayout: boolean;
tileStoreGeneration: number | undefined;
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions: MenuOptions[];
selectedAudio: string | undefined;
selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined;
}
export interface FooterProps {
ref?: Ref<HTMLDivElement>;
children?: JSX.Element | JSX.Element[] | false;
vm: ViewModel<FooterSnapshot>;
}
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
const layoutMode = useBehavior(vm.layoutMode$);
const setLayoutMode = useBehavior(vm.setLayoutMode$);
const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const toggleAudio = useBehavior(vm.toggleAudio$);
const toggleVideo = useBehavior(vm.toggleVideo$);
const sharingScreen = useBehavior(vm.sharingScreen$);
const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
const reactionData = useBehavior(vm.reactionData$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const hangup = useBehavior(vm.hangup$);
const debugTileLayout = useBehavior(vm.debugTileLayout$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const videoOptions = useBehavior(vm.videoOptions$);
const selectedVideo = useBehavior(vm.selectedVideo$);
const audioOptions = useBehavior(vm.audioOptions$);
const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
const buttonSize = useBehavior(vm.buttonSize$);
const showLogo = useBehavior(vm.showLogo$);
const buttons: JSX.Element[] = [];
if (openSettings !== undefined) {
// Add the settings button to the center group so it's visible on small
// screens. On larger screens the SettingsIconButton with
// showForScreenWidth="wide" in the settingsLogoContainer is used instead.
buttons.push(
<SettingsButton
key="settings"
showForScreenWidth="narrow"
onClick={openSettings}
data-testid="settings-bottom-center"
/>,
);
}
if ((audioOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Mic Source"}
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioButtonOption}
/>,
);
} else {
buttons.push(
<MicButton
size={buttonSize}
key="audio"
enabled={audioEnabled ?? false}
onClick={toggleAudio}
disabled={toggleAudio === undefined}
data-testid="incall_mute"
/>,
);
}
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Camera Source"}
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
onMuteClick={toggleVideo}
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoButtonOption}
videoBlurToggleClick={toggleBlur}
videoBlurEnabled={videoBlurEnabled}
/>,
);
} else {
buttons.push(
<VideoButton
size={buttonSize}
key="video"
enabled={videoEnabled ?? false}
onClick={toggleVideo}
disabled={toggleVideo === undefined}
data-testid="incall_videomute"
/>,
);
}
if (toggleScreenSharing !== undefined) {
buttons.push(
<ShareScreenButton
size={buttonSize}
key="share_screen"
className={styles.shareScreen}
enabled={sharingScreen ?? false}
onClick={toggleScreenSharing}
data-testid="incall_screenshare"
/>,
);
}
if (reactionIdentifier && reactionData) {
buttons.push(
<ReactionToggleButton
size={buttonSize}
reactionData={reactionData}
key="raise_hand"
className={styles.raiseHand}
identifier={reactionIdentifier}
/>,
);
}
// In this PR we just move the button to the bottom bar. We do not yet update its appearance
const audioOutputButton = useMemo(() => {
if (audioOutputSwitcher === undefined) return null;
return (
<LoudspeakerButton
size={buttonSize}
onClick={() => audioOutputSwitcher.switch()}
loudspeakerModeEnabled={audioOutputSwitcher.targetOutput === "earpiece"}
/>
);
}, [audioOutputSwitcher, buttonSize]);
if (audioOutputButton) buttons.push(audioOutputButton);
if (hangup)
buttons.push(
<EndCallButton
size={buttonSize}
key="end_call"
onClick={hangup}
data-testid="incall_leave"
/>,
);
const logoDebugContainer = (
<div className={styles.logo}>
{showLogo && (
<>
<LogoMark width={24} height={24} aria-hidden />
<LogoType
width={80}
height={11}
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
/>
</>
)}
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
</div>
);
return (
<div
ref={ref}
data-testid="footer-container"
className={classNames(styles.footer, {
[styles.overlay]: asOverlay,
[styles.hidden]: !showFooter,
})}
>
<div className={styles.settingsLogoContainer}>
{openSettings !== undefined && (
<SettingsIconButton
key="settings"
kind="secondary"
showForScreenWidth="wide"
onClick={openSettings}
data-testid="settings-bottom-left"
/>
)}
{children}
{(showLogo || debugTileLayout) && logoDebugContainer}
</div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
{!hideControls && setLayoutMode && layoutMode && (
<Switch<"spotlight", "grid">
name="layoutMode"
aria-label={t("layout_switch_label")}
leftLabel={t("layout_spotlight_label")}
leftValue="spotlight"
leftIcon={SpotlightIcon}
rightLabel={t("layout_grid_label")}
rightValue="grid"
rightIcon={GridIcon}
className={styles.layout}
value={layoutMode}
onChange={setLayoutMode}
/>
)}
</div>
);
};

View File

@@ -1,157 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
get platform(): string {
return platformMock();
},
}));
// Prevent supportsBackgroundProcessors from throwing in jsdom it is not
// exercised by these tests (only used in `videoToggles`, not `videoOptions`).
vi.mock("@livekit/track-processors", () => ({
supportsBackgroundProcessors: (): boolean => false,
}));
/**
* Returns the minimum set of CallViewModel fields required by
* createCallFooterViewModel, with all other properties stubbed to
* simple constant values.
*/
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
return {
layout$: constant(layout),
edgeToEdge$: constant(false),
showHeader$: constant(false),
hangup: (): void => {},
gridMode$: constant("grid"),
setGridMode: (): void => {},
sharingScreen$: constant(false),
toggleScreenSharing: null,
audioOutputSwitcher$: constant(null),
handsRaised$: constant({}),
reactions$: constant({}),
tileStoreGeneration$: constant(0),
showFooter$: constant(true),
settingsOpen$: constant(false),
setSettingsOpen$: constant(() => {}),
} as unknown as CallViewModel;
}
/** A regular grid layout (not PiP). */
const gridLayout: Layout = {
type: "grid",
grid: [],
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: (_: number) => {},
};
/** A PiP layout only the `type` matters for the tests. */
const pipLayout: Layout = {
type: "pip",
spotlight: {} as SpotlightTileViewModel,
};
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
audioInput: {
available$: constant(
new Map<string, DeviceLabel>([
["mic1", { type: "number", number: 1 }],
["mic2", { type: "name", name: "Microphone 2" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
videoInput: {
available$: constant(
new Map<string, DeviceLabel>([
["cam1", { type: "name", name: "Camera 1" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
});
describe("createCallFooterViewModel", () => {
describe("audioOptions and videoOptions", () => {
function checkEmptyFor(platform: string, layout: Layout): void {
platformMock.mockReturnValue(platform);
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(layout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
);
expect(vm.audioOptions$.value).toEqual([]);
expect(vm.videoOptions$.value).toEqual([]);
}
it("are both empty when the platform is iOS", () => {
checkEmptyFor("ios", gridLayout);
});
it("are both empty when the layout is pip", () => {
checkEmptyFor("desktop", pipLayout);
});
it("are populated when the platform is desktop and the layout is not PiP", () => {
platformMock.mockReturnValue("desktop");
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
);
expect(vm.audioOptions$?.value).toEqual([
{
id: "mic1",
label: {
number: 1,
type: "number",
},
},
{
id: "mic2",
label: {
name: "Microphone 2",
type: "name",
},
},
]);
expect(vm.videoOptions$?.value).toEqual([
{
id: "cam1",
label: {
name: "Camera 1",
type: "name",
},
},
]);
});
});
});

View File

@@ -1,272 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { combineLatest, map, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import {
backgroundBlur as backgroundBlurSettings,
debugTileLayout as debugTileLayoutSetting,
} from "../settings/settings";
import { type Behavior, constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates";
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
import { getUrlParams, HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter";
/**
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
* needed by FooterSnapshot.
*/
function buildMuteBehaviors(
scope: ObservableScope,
muteStates: MuteStates,
): Pick<
ViewModel<FooterSnapshot>,
"audioEnabled$" | "toggleAudio$" | "videoEnabled$" | "toggleVideo$"
> {
return {
audioEnabled$: muteStates.audio.enabled$,
toggleAudio$: scope.behavior(
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
),
videoEnabled$: muteStates.video.enabled$,
toggleVideo$: scope.behavior(
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
),
};
}
/**
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
*/
function buildDeviceBehaviors(
scope: ObservableScope,
mediaDevices: MediaDevices,
/** return empty arrays for audioOptions and videoOptions*/
disableSwitcher$: Behavior<boolean>,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions$"
| "selectedAudio$"
| "selectAudioButtonOption$"
| "videoOptions$"
| "selectedVideo$"
| "selectVideoButtonOption$"
| "toggleBlur$"
| "videoBlurEnabled$"
> {
return {
audioOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.audioInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
),
selectedAudio$: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
),
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
videoOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.videoInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
),
selectedVideo$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
toggleBlur$: scope.behavior(
combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe(
map(([current, switcherDisabled]) => {
return !switcherDisabled && supportsBackgroundProcessors()
? (): void => {
backgroundBlurSettings.setValue(!current);
}
: undefined;
}),
),
),
videoBlurEnabled$: backgroundBlurSettings.value$,
};
}
/**
* Creates the ViewModel for the CallFooter.
*
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button).
*/
export function createCallFooterViewModel(
scope: ObservableScope,
callModel: CallViewModel,
muteStates: MuteStates,
mediaDevices: MediaDevices,
reactionIdentifier: string | undefined,
): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = getUrlParams();
const showLogo = headerStyle === HeaderStyle.Standard;
const isPip$ = scope.behavior(
callModel.layout$.pipe(map((l) => l.type === "pip")),
);
const disableDeviceSwitcher$ = scope.behavior(
isPip$.pipe(map((isPip) => isPip || platform !== "desktop")),
);
return {
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
hideControls$: constant(!showControls),
asOverlay$: callModel.edgeToEdge$,
buttonSize$: scope.behavior(
isPip$.pipe(map<boolean, "md" | "lg">((pip) => (pip ? "md" : "lg"))),
),
openSettings$: scope.behavior(
combineLatest([
isPip$,
callModel.showHeader$,
callModel.setSettingsOpen$,
]).pipe(
map(([isPip, showHeader, setSettingsOpen]) =>
!isPip &&
!(headerStyle === HeaderStyle.AppBar && showHeader) &&
showControls
? (): void => setSettingsOpen(true)
: undefined,
),
),
),
showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))),
layoutMode$: callModel.gridMode$,
setLayoutMode$: scope.behavior(
isPip$.pipe(
map((isPip) =>
!isPip && showControls ? callModel.setGridMode : undefined,
),
),
),
sharingScreen$: callModel.sharingScreen$,
toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined),
audioOutputSwitcher$: scope.behavior(
callModel.audioOutputSwitcher$.pipe(
map((switcher) => switcher ?? undefined),
),
),
hangup$: constant(callModel.hangup),
reactionIdentifier$: constant(reactionIdentifier),
reactionData$: constant(
reactionIdentifier !== undefined
? {
handsRaised$: callModel.handsRaised$,
reactions$: callModel.reactions$,
}
: undefined,
),
debugTileLayout$: debugTileLayoutSetting.value$,
tileStoreGeneration$: callModel.tileStoreGeneration$,
};
}
/**
* Creates a simplified ViewModel for the CallFooter used in the lobby
* (pre-call) screen. Unlike createCallFooterViewModel, this does not require
* a CallViewModel — it only needs mute states, device lists, and callbacks.
*
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param openSettings - Callback to open the settings modal, or undefined.
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
* @param showLogo - Whether to show the Element Call logo.
*/
export function createLobbyFooterViewModel(
scope: ObservableScope,
muteStates: MuteStates,
mediaDevices: MediaDevices,
openSettings: (() => void) | undefined,
hangup: (() => void) | undefined,
showLogo: boolean,
): ViewModel<FooterSnapshot> {
return {
...createStaticViewModel({
// we can safly skip any props that we do not need.
// The view model will then have less keys.
// But as soon as we call `useViewModel` and convert back to a snapshot the missing props will
// be correcty matching the snapshot type.
showLogo,
hideControls: false,
asOverlay: false,
buttonSize: "lg",
showLayoutSwitcher: false,
openSettings,
hangup,
debugTileLayout: false,
showFooter: true,
toggleAudio: undefined,
toggleVideo: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
audioEnabled: undefined,
videoEnabled: undefined,
layoutMode: undefined,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
reactionData: undefined,
tileStoreGeneration: undefined,
audioOptions: undefined,
videoOptions: undefined,
selectedAudio: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined,
}),
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
};
}

View File

@@ -1,37 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.container {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: 32px;
transition: background-color 0.2s ease-in-out;
}
.containerOpen {
background-color: var(--cpd-color-bg-action-primary-pressed);
}
.chevronIconOpen > svg {
color: var(--cpd-color-icon-on-solid-primary);
}
.menuButton {
width: 40px;
background-color: transparent !important;
}
.itemIcon {
color: var(--cpd-color-text-secondary);
}
.rotate {
animation: spinner 1.5s linear infinite;
}
@keyframes spinner {
to {
transform: rotate(360deg);
}
}

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