mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
168 Commits
local-scre
...
v0.19.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b276eeee75 | ||
|
|
58f430eb32 | ||
|
|
5ca50fdb63 | ||
|
|
7916360b52 | ||
|
|
83a9a2a86c | ||
|
|
0dc8fab256 | ||
|
|
69757b06ce | ||
|
|
a081b40fb2 | ||
|
|
88b5db6675 | ||
|
|
22f16174ef | ||
|
|
0470dabff2 | ||
|
|
5a30666939 | ||
|
|
f093946a29 | ||
|
|
6c25048b92 | ||
|
|
2e4ca9b761 | ||
|
|
42aae307f9 | ||
|
|
24c2845958 | ||
|
|
51209aa825 | ||
|
|
e68e62bf41 | ||
|
|
d1dc7cd7bb | ||
|
|
09fc38930f | ||
|
|
e79255e23b | ||
|
|
3caae52ac5 | ||
|
|
b4dffa598e | ||
|
|
df78ff6bd2 | ||
|
|
b5c1b36850 | ||
|
|
6b1b316ce4 | ||
|
|
d335a307bb | ||
|
|
89281c6d70 | ||
|
|
2ce6098085 | ||
|
|
2f2bdfce5b | ||
|
|
82b7e243eb | ||
|
|
0d5c71bb4e | ||
|
|
0864b7d828 | ||
|
|
ca305b9da1 | ||
|
|
6317a06472 | ||
|
|
93657ee3c4 | ||
|
|
f98e6d1c08 | ||
|
|
8bd6b04451 | ||
|
|
39ef815d8b | ||
|
|
72d922468e | ||
|
|
0abc32b80e | ||
|
|
3699138eaa | ||
|
|
c1f821ca0f | ||
|
|
a7788a2bfa | ||
|
|
90ba38d2bc | ||
|
|
d9dc61a49d | ||
|
|
283c606b9d | ||
|
|
ee343935a4 | ||
|
|
032ec662e9 | ||
|
|
9fccd49d7d | ||
|
|
84ac0cc47d | ||
|
|
9f2db7cfc3 | ||
|
|
e5c11eee13 | ||
|
|
6edc4084c6 | ||
|
|
6d70247acb | ||
|
|
21b78d6dad | ||
|
|
4d8e4c66eb | ||
|
|
78e7adc4c6 | ||
|
|
5c4d6d0317 | ||
|
|
1b9682869a | ||
|
|
854cfdfe1b | ||
|
|
f355aa1ed7 | ||
|
|
9e2eef09d4 | ||
|
|
879526ce5e | ||
|
|
9525136c91 | ||
|
|
5dd1c76d1c | ||
|
|
2e149ad08c | ||
|
|
f9dc4d9b82 | ||
|
|
56bab70534 | ||
|
|
2d9802b6f5 | ||
|
|
2b46925e77 | ||
|
|
33172837e7 | ||
|
|
55de3fd216 | ||
|
|
50ad678a42 | ||
|
|
4155d4c9b3 | ||
|
|
a1d0a742b2 | ||
|
|
0e2d2c6f72 | ||
|
|
754a42ffd1 | ||
|
|
701edd9522 | ||
|
|
0127040a8c | ||
|
|
24e721f572 | ||
|
|
4211405e7b | ||
|
|
ea73e5881a | ||
|
|
bbb73b27cb | ||
|
|
ecb2a6846f | ||
|
|
2d16487ca1 | ||
|
|
d28cad3f12 | ||
|
|
92338a2e7c | ||
|
|
6ae88531ec | ||
|
|
8728676eb6 | ||
|
|
741b82b026 | ||
|
|
b58076f1b9 | ||
|
|
ab05e8d250 | ||
|
|
0894659cea | ||
|
|
84ed5943ea | ||
|
|
d6f6542945 | ||
|
|
e9fd84816e | ||
|
|
00d6e034a0 | ||
|
|
4d09651fe1 | ||
|
|
2828b92d91 | ||
|
|
ff6112826c | ||
|
|
1f443cf739 | ||
|
|
66cea929a7 | ||
|
|
4d86580542 | ||
|
|
2adb498620 | ||
|
|
d75f2563c6 | ||
|
|
938a03d0c1 | ||
|
|
8b4762fb29 | ||
|
|
eeafd81d31 | ||
|
|
2c65a75ba8 | ||
|
|
7e39889f78 | ||
|
|
96b343effc | ||
|
|
b58ad639c1 | ||
|
|
2b42b2bdc1 | ||
|
|
8eccb95fd3 | ||
|
|
9c1719a22f | ||
|
|
17ad20cd09 | ||
|
|
5657036f2f | ||
|
|
565a5ab73f | ||
|
|
0dcd761080 | ||
|
|
60fae70a60 | ||
|
|
d31a2a4fdd | ||
|
|
014f645bb3 | ||
|
|
cea32b86a3 | ||
|
|
bbda8ecdcf | ||
|
|
9fa382ed0c | ||
|
|
3fc823e049 | ||
|
|
486b3ea45d | ||
|
|
505e408c47 | ||
|
|
dbf76db590 | ||
|
|
3cb092f541 | ||
|
|
b59600099d | ||
|
|
242c40af14 | ||
|
|
dd80679ec5 | ||
|
|
4b62ab2083 | ||
|
|
4295ae55d4 | ||
|
|
57f12d3d1c | ||
|
|
2ae1df467d | ||
|
|
2d5de234d2 | ||
|
|
3c039be7f2 | ||
|
|
8c5668bf1a | ||
|
|
400259207e | ||
|
|
be74ebbb6c | ||
|
|
02fa29c7b2 | ||
|
|
7e1ee2eece | ||
|
|
5f0afd3edc | ||
|
|
270726d446 | ||
|
|
098a8c0c41 | ||
|
|
c59de789e5 | ||
|
|
9905afd2ad | ||
|
|
8956d55d5c | ||
|
|
16f9c107a5 | ||
|
|
c02f2bf140 | ||
|
|
251a41e7a5 | ||
|
|
8ca1b2c749 | ||
|
|
9e86d517a0 | ||
|
|
134fdeef3a | ||
|
|
9de5938e79 | ||
|
|
f75e91fc2b | ||
|
|
6be06de153 | ||
|
|
9528c4f837 | ||
|
|
5da7dd6413 | ||
|
|
1c5b483a7e | ||
|
|
cbb9e7a289 | ||
|
|
4006a8d9f9 | ||
|
|
7b1eb03e2a | ||
|
|
d3a4234f4a |
@@ -1,11 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,11 +1,9 @@
|
||||
#!/usr/bin/sh
|
||||
#!/usr/bin/env bash
|
||||
|
||||
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."
|
||||
# Checks if there currently is linking configured. Informs the user to disable linking before committing.
|
||||
|
||||
PNPMFILE=.pnpmfile.cjs
|
||||
if test -f "$PNPMFILE"; then
|
||||
echo "[pnpm-linker] The pre-commit hook detected $PNPMFILE which implies you have linked packages in your pnpm-lock.yaml. Run pnpm links:off and commit again. See also linking.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
9
.github/workflows/build-element-call.yaml
vendored
9
.github/workflows/build-element-call.yaml
vendored
@@ -37,15 +37,16 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
- name: pnpm cache
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
- name: Build Element Call
|
||||
run: yarn run build:"$PACKAGE":"$BUILD_MODE"
|
||||
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
|
||||
17
.github/workflows/lint.yaml
vendored
17
.github/workflows/lint.yaml
vendored
@@ -12,20 +12,21 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
- name: pnpm cache
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
- name: Prettier
|
||||
run: "yarn run prettier:check"
|
||||
run: "pnpm run prettier:check"
|
||||
- name: i18n
|
||||
run: "yarn run i18n:check"
|
||||
run: "pnpm run i18n:check"
|
||||
- name: ESLint
|
||||
run: "yarn run lint:eslint"
|
||||
run: "pnpm run lint:eslint"
|
||||
- name: Type check
|
||||
run: "yarn run lint:types"
|
||||
run: "pnpm run lint:types"
|
||||
- name: Dead code analysis
|
||||
run: "yarn run lint:knip"
|
||||
run: "pnpm run lint:knip"
|
||||
|
||||
2
.github/workflows/test-netlify.yaml
vendored
2
.github/workflows/test-netlify.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
revision: ${{ github.event.workflow_run.head_sha }}
|
||||
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site_id: ${{ vars.NETLIFY_SITE_ID }}
|
||||
site_id: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
desc: Playwright Report
|
||||
deployment_env: EndToEndTests
|
||||
prefix: "e2e-"
|
||||
|
||||
18
.github/workflows/test.yaml
vendored
18
.github/workflows/test.yaml
vendored
@@ -14,15 +14,16 @@ jobs:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
- name: pnpm cache
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
- name: Vitest
|
||||
run: "yarn run test:coverage"
|
||||
run: "pnpm run test:coverage"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
||||
env:
|
||||
@@ -42,12 +43,13 @@ jobs:
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: pnpm install --frozen-lockfile --ignore-pnpmfile
|
||||
- name: Install Playwright Browsers
|
||||
run: yarn playwright install --with-deps
|
||||
run: pnpm exec 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
|
||||
@@ -56,7 +58,7 @@ jobs:
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
USE_DOCKER: 1
|
||||
run: yarn playwright test
|
||||
run: pnpm exec playwright test
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
|
||||
7
.github/workflows/translations-download.yaml
vendored
7
.github/workflows/translations-download.yaml
vendored
@@ -22,11 +22,12 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
node-version-file: ".node-version"
|
||||
|
||||
- name: Install Deps
|
||||
run: "yarn install --immutable"
|
||||
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
|
||||
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||
|
||||
- name: Prune i18n
|
||||
run: "rm -R locales"
|
||||
@@ -40,7 +41,7 @@ jobs:
|
||||
run: "sudo chown runner:docker -R locales"
|
||||
|
||||
- name: Prettier
|
||||
run: yarn prettier:format
|
||||
run: pnpm prettier:format
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -21,9 +21,14 @@ 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/
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pnpm-lock.yaml
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/*
|
||||
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/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: ["@storybook/addon-docs"],
|
||||
framework: "@storybook/react-vite",
|
||||
};
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { create } from "storybook/theming";
|
||||
import { addons } from "storybook/manager-api";
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import i18n from "i18next";
|
||||
|
||||
91
.yarn/plugins/linker.cjs
vendored
91
.yarn/plugins/linker.cjs
vendored
@@ -1,91 +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.
|
||||
*/
|
||||
|
||||
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,
|
||||
);
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
plugins:
|
||||
- .yarn/plugins/linker.cjs
|
||||
20
README.md
20
README.md
@@ -108,17 +108,17 @@ recommended method for embedding Element Call.
|
||||
</p>
|
||||
|
||||
For more details on the packages, see the
|
||||
[Embedded vs. Standalone Guide](./docs/embedded-standalone.md).
|
||||
[Embedded vs. Standalone Guide](./docs/embedded_standalone.md).
|
||||
|
||||
## 🛠️ Self-Hosting
|
||||
|
||||
For operating and deploying Element Call on your own server, refer to the
|
||||
[**Self-Hosting Guide**](./docs/self-hosting.md).
|
||||
[**Self-Hosting Guide**](./docs/self_hosting.md).
|
||||
|
||||
## 🧭 MatrixRTC Backend Discovery and Selection
|
||||
|
||||
For proper Element Call operation each site deployment needs a MatrixRTC backend
|
||||
setup as outlined in the [Self-Hosting](#self-hosting). A typical federated site
|
||||
setup as outlined in the [Self-Hosting](#self_hosting). A typical federated site
|
||||
deployment for three different sites A, B and C is depicted below.
|
||||
|
||||
<p align="center">
|
||||
@@ -186,7 +186,7 @@ To get started clone and set up this project:
|
||||
git clone https://github.com/element-hq/element-call.git
|
||||
cd element-call
|
||||
corepack enable
|
||||
yarn
|
||||
pnpm install
|
||||
```
|
||||
|
||||
To use it, create a local config by, e.g.,
|
||||
@@ -197,7 +197,7 @@ environment as outlined in the next section out of box.
|
||||
You're now ready to launch the development server:
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
See also:
|
||||
@@ -230,7 +230,7 @@ only for local development and **_never be exposed to the public Internet._**
|
||||
Run backend components:
|
||||
|
||||
```sh
|
||||
yarn backend
|
||||
pnpm backend
|
||||
# or for podman-compose
|
||||
# podman-compose -f dev-backend-docker-compose.yml up
|
||||
```
|
||||
@@ -260,13 +260,13 @@ on https://localhost:3000 (this is configured in `playwright.config.ts`) - this
|
||||
is what will be tested.
|
||||
|
||||
The local backend environment should be running for the test to work:
|
||||
`yarn backend`
|
||||
`pnpm backend`
|
||||
|
||||
There are a few different ways to run the tests yourself. The simplest is to
|
||||
run:
|
||||
|
||||
```shell
|
||||
yarn run test:playwright
|
||||
pnpm run test:playwright
|
||||
```
|
||||
|
||||
This will run the Playwright tests once, non-interactively.
|
||||
@@ -274,7 +274,7 @@ This will run the Playwright tests once, non-interactively.
|
||||
There is a more user-friendly way to run the tests in interactive mode:
|
||||
|
||||
```shell
|
||||
yarn run test:playwright:open
|
||||
pnpm run test:playwright:open
|
||||
```
|
||||
|
||||
The easiest way to develop new test is to use the codegen feature of Playwright:
|
||||
@@ -316,7 +316,7 @@ To add a new translation key you can do these steps:
|
||||
|
||||
1. Add the new key entry to the code where the new key is used:
|
||||
`t("some_new_key")`
|
||||
1. Run `yarn i18n` to extract the new key and update the translation files. This
|
||||
1. Run `pnpm i18n` to extract the new key and update the translation files. This
|
||||
will add a skeleton entry to the `locales/en/app.json` file:
|
||||
|
||||
```jsonc
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Testing Element-Call in widget mode
|
||||
|
||||
When running `yarn backend` the latest element-web develop will be deployed and served on `http://localhost:8081`.
|
||||
When running `pnpm backend` the latest element-web develop will be deployed and served on `http://localhost:8081`.
|
||||
In a development environment, you might prefer to just use the `element-web` repo directly, but this setup is useful for CI/CD testing.
|
||||
|
||||
## Setup
|
||||
@@ -18,7 +18,7 @@ that uses
|
||||
It is part of the existing backend setup. To start the backend, run:
|
||||
|
||||
```sh
|
||||
yarn backend
|
||||
pnpm backend
|
||||
```
|
||||
|
||||
Then open `http://localhost:8081` in your browser.
|
||||
|
||||
@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
|
||||
# Shared secret for admin user registration via API (for testing only!)
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
|
||||
# Shared secret for admin user registration via API (for testing only!)
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
|
||||
@@ -18,3 +18,7 @@ keys:
|
||||
devkey: secret
|
||||
room:
|
||||
auto_create: false
|
||||
webhook:
|
||||
api_key: devkey
|
||||
urls:
|
||||
- https://matrix-rtc.othersite.m.localhost/livekit/jwt/sfu_webhook
|
||||
|
||||
@@ -18,3 +18,7 @@ keys:
|
||||
devkey: secret
|
||||
room:
|
||||
auto_create: false
|
||||
webhook:
|
||||
api_key: devkey
|
||||
urls:
|
||||
- https://matrix-rtc.m.localhost/livekit/jwt/sfu_webhook
|
||||
|
||||
@@ -28,11 +28,18 @@ server {
|
||||
# Reason: the lk-jwt-service uses the federation API for the openid token
|
||||
# verification, which requires TLS
|
||||
location ~ ^(/_matrix|/_synapse/client) {
|
||||
proxy_pass "http://homeserver:8008";
|
||||
proxy_pass "http://homeserver:8008";
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
location ~ ^(/_matrix|/_synapse/admin) {
|
||||
proxy_pass "http://homeserver:8008";
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
@@ -73,7 +80,15 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location ~ ^(/_matrix|/_synapse/admin) {
|
||||
proxy_pass "http://homeserver-1:18008";
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
@@ -108,7 +123,7 @@ server {
|
||||
|
||||
# JWT Service running at port 6080
|
||||
proxy_pass http://jwt-auth-services/;
|
||||
|
||||
|
||||
}
|
||||
|
||||
location ^~ /livekit/sfu/ {
|
||||
@@ -128,7 +143,7 @@ server {
|
||||
# LiveKit SFU websocket connection running at port 7880
|
||||
proxy_pass http://livekit-sfu:7880/;
|
||||
}
|
||||
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
@@ -156,7 +171,7 @@ server {
|
||||
|
||||
# JWT Service running at port 16080
|
||||
proxy_pass http://auth-service-1:16080/;
|
||||
|
||||
|
||||
}
|
||||
|
||||
location ^~ /livekit/sfu/ {
|
||||
@@ -176,14 +191,14 @@ server {
|
||||
# LiveKit SFU websocket connection running at port 17880
|
||||
proxy_pass http://livekit-sfu-1:17880/;
|
||||
}
|
||||
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
|
||||
# Convenience reverse proxy for the call.m.localhost domain to element call
|
||||
# running on the host either via
|
||||
# - yarn dev --host or
|
||||
# - pnpm dev --host or
|
||||
# - falling back to http (the element call docker container)
|
||||
server {
|
||||
listen 80;
|
||||
@@ -260,7 +275,7 @@ server {
|
||||
proxy_ssl_verify off;
|
||||
|
||||
}
|
||||
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
@@ -293,7 +308,7 @@ server {
|
||||
proxy_ssl_verify off;
|
||||
|
||||
}
|
||||
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
|
||||
# Shared secret for admin user registration via API (for testing only!)
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
|
||||
# Shared secret for admin user registration via API (for testing only!)
|
||||
registration_shared_secret: "test_shared_secret_for_local_dev_only"
|
||||
|
||||
report_stats: false
|
||||
serve_server_wellknown: true
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
@@ -62,7 +62,10 @@ services:
|
||||
- 7882:7882/tcp
|
||||
- 50100-50200:50100-50200/udp
|
||||
volumes:
|
||||
- ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
|
||||
- ./backend/dev_livekit.yaml:/etc/livekit.yaml:Z
|
||||
environment:
|
||||
- SSL_CERT_FILE=/local_cert.pem
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
@@ -82,7 +85,10 @@ services:
|
||||
- 17882:17882/tcp
|
||||
- 50300-50400:50300-50400/udp
|
||||
volumes:
|
||||
- ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
|
||||
- ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
|
||||
environment:
|
||||
- SSL_CERT_FILE=/local_cert.pem
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
@@ -164,6 +170,8 @@ services:
|
||||
- "8448:8448"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
- "auth-server:127.0.0.1"
|
||||
- "auth-server-1:127.0.0.1"
|
||||
depends_on:
|
||||
- synapse
|
||||
networks:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,7 +14,7 @@ The table below provides a comparison of the two packages:
|
||||
| **Release artifacts** | Docker Image, Tarball | Tarball, NPM for Web, Android AAR, SwiftPM for iOS |
|
||||
| **Recommended for** | Standalone/guest access usage | Embedding within messenger apps |
|
||||
| **Responsibility for regulatory compliance** | The administrator that is deploying the app is responsible for compliance with any applicable regulations (e.g. privacy) | The developer of the messenger app is responsible for compliance |
|
||||
| **Analytics consent** | Element Call will show a consent UI. | Element Call will not show a consent UI. The messenger app should only provide the embedded Element Call with the [analytics URL parameters](./url-params.md#embedded-only-parameters) if consent has been granted. |
|
||||
| **Analytics consent** | Element Call will show a consent UI. | Element Call will not show a consent UI. The messenger app should only provide the embedded Element Call with the [analytics URL parameters](./url_params.md#embedded-only-parameters) if consent has been granted. |
|
||||
| **Analytics data** | Element Call will send data to the Posthog, Sentry and Open Telemetry targets specified by the administrator in the `config.json` | Element Call will send data to the Posthog and Sentry targets specified in the URL parameters by the messenger app |
|
||||
|
||||
### Using the embedded package within a messenger app
|
||||
@@ -26,7 +26,7 @@ The basics are:
|
||||
1. Add the appropriate platform dependency as given for a [release](https://github.com/element-hq/element-call/releases), or use the embedded tarball. e.g. `npm install @element-hq/element-call-embedded@0.9.0`
|
||||
2. Include the assets from the platform dependency in the build process. e.g. copy the assets during a [Webpack](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/webpack.config.js#L677-L682) build.
|
||||
3. Use the `index.html` entrypointof the imported assets when you are constructing the WebView or iframe. e.g. using a [relative path in a webapp](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/src/models/Call.ts#L680), or on the the Android [WebViewAssetLoader](https://github.com/element-hq/element-x-android/blob/fe5aab6588ecdcf9354a3bfbd9e97c1b31175a8f/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt#L20)
|
||||
4. Set any of the [embedded-only URL parameters](./url-params.md#embedded-only-parameters) that you need.
|
||||
4. Set any of the [embedded-only URL parameters](./url_params.md#embedded-only-parameters) that you need.
|
||||
|
||||
## Widget vs standalone mode
|
||||
|
||||
@@ -36,4 +36,4 @@ As a widget, the app only uses the core calling (MatrixRTC) parts. The rest (aut
|
||||
Element Call and the hosting client are connected via the widget API.
|
||||
|
||||
Element Call detects that it is run as a widget if a widgetId is defined in the url parameters. If `widgetId` is present then Element Call will try to connect to the client via the widget postMessage API using the parameters provided in [Url Format and parameters
|
||||
](./url-params.md).
|
||||
](./url_params.md).
|
||||
@@ -1,39 +1,65 @@
|
||||
# Developing with linked packages
|
||||
## Quickstart guide
|
||||
|
||||
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.
|
||||
run
|
||||
|
||||
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:
|
||||
|
||||
```yaml
|
||||
matrix-js-sdk: ../path/to/matrix-js-sdk
|
||||
"@vector-im/compound-web": /home/alice/path/to/compound-web
|
||||
```bash
|
||||
./scripts/setup-linking.sh
|
||||
```
|
||||
|
||||
Then run `yarn install`.
|
||||
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.
|
||||
|
||||
Instead, create a file named `.links.cjs` in the Element Call project directory (or run `./scripts/setup-linking.sh` to create a template), listing the names and paths of any dependencies you want to link. For example:
|
||||
|
||||
```cjs
|
||||
// Packages to link to local checkouts
|
||||
module.exports = {
|
||||
"matrix-js-sdk": "../your/path/matrix-js-sdk",
|
||||
"matrix-widget-api": "../your/path/matrix-widget-api",
|
||||
};
|
||||
```
|
||||
|
||||
Then run `pnpm links:on`. (this will activate the pnpm file + run `pnpm install` to setup the linking)
|
||||
|
||||
## Hooks
|
||||
|
||||
Changes in `.links.yaml` will also update `yarn.lock` when `yarn` is executed. The lockfile will then contain the local
|
||||
Changes in `.links.cjs` will also update `pnpm-lock.yaml` when `pnpm install` is executed. The lockfile will then contain the local
|
||||
version of the package which would not work on others dev setups or the github CI.
|
||||
One always needs to run:
|
||||
|
||||
One always needs to remove the pnpm `readPackage` script (the `.pnpmfile.cjs`) and run:
|
||||
|
||||
```bash
|
||||
mv .links.yaml .links.disabled.yaml
|
||||
yarn
|
||||
pnpm install
|
||||
```
|
||||
|
||||
before committing a change.
|
||||
|
||||
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 make this less of a foot gun we added a git hook.
|
||||
A `pre-commit` hook will check if linking is currently used. If it detects
|
||||
a `.pnpmfile.cjs` file it will abort the commit with an explanatory message.
|
||||
You will than need to run `pnpm links:off` and commit again.
|
||||
|
||||
A `post-commit` hook will setup the linking as it was
|
||||
before if a `.links.disabled.yaml` is present. It runs `mv .links.disabled.yaml .links.yaml` and `yarn`.
|
||||
|
||||
To activate the hooks automatically configure git with
|
||||
To activate the hooks configure git with (when using the setup script (`./scripts/setup-linking.sh`) this is already done):
|
||||
|
||||
```bash
|
||||
git config --local core.hooksPath .githooks/
|
||||
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.
|
||||
|
||||
30
docs/linking_concept_reasoning.md
Normal file
30
docs/linking_concept_reasoning.md
Normal file
@@ -0,0 +1,30 @@
|
||||
### Why do we not enable .pnpmfile.cjs by default
|
||||
|
||||
Background: The presence of the `.pnpmfile.cjs` adds a field to the `pnpm-lock.yaml` called: `pnpmfileChecksum`. This field is a checksum of the content of the `.pnpmfile.cjs` file.
|
||||
`pnpm install --frozen-lockfile` **fails** if there is a `.pnpmfile.cjs` but no `pnpmfileChecksum` or vice versa (or on mismatch).
|
||||
|
||||
_TLDR: running with `--ignore-pnpmfile` will fail if `pnpmfileChecksum` is present._
|
||||
|
||||
#### `pnpmfileChecksum` + renovate bot
|
||||
|
||||
When the renovate bot creates a PR it runs `pnpm install --ignore-pnpmfile`. This means that the `pnpmfileChecksum` in the lockfile will be **empty**.
|
||||
This breaks builds that **don't** ignore the `.pnpmfile.cjs`-file. (CI that runs on the renovate PR)
|
||||
From here we have two possible paths:
|
||||
|
||||
- ignore `.pnpmfile.cjs` in all CI builds CI will also fail if we accidently add it locally.
|
||||
- fixup the `pnpm-lock.yaml` in the renovate PR to contain the correct `pnpmfileChecksum`.
|
||||
|
||||
Ignoring in all CI builds means that CI will always fail if we enable the linking system.
|
||||
This is annoying but can be worked around with the git hook we provide that at least lets us know that we are
|
||||
commiting with enabled linking.
|
||||
Only if we remember setting it back/disbale linking (or let ourselves remember by the git hook) the CI will work.
|
||||
|
||||
#### Summary
|
||||
|
||||
- We will always run into conflicts with the `pnpmfileChecksum` because in renovate prs it will be empty (`--ignore-pnpmfile`)
|
||||
- To keep it simple we set `--ignore-pnpmfile` in all of ours CI to see issues immediately.
|
||||
- The only solution is to never have a `.pnpmfile.cjs` in the repository when pushing.
|
||||
- This way there will never be a commit with `pnpmfileChecksum` in the lockfile.
|
||||
- renovate (which uses `--ignore-pnpmfile` which we cannot disable) and other CI will work
|
||||
- We are able to use the linking system locally if we `cp` this file from the scripts folder into `./` on demand.
|
||||
- `pnpm links:on` and `pnpm links:off` + `./scripts/setup-linking.sh` will help us with this.
|
||||
@@ -237,8 +237,8 @@ source. First, clone and install the package:
|
||||
git clone https://github.com/element-hq/element-call.git
|
||||
cd element-call
|
||||
corepack enable
|
||||
yarn
|
||||
yarn build
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
If all went well, you can now find the build output under `dist` as a series of
|
||||
@@ -4,7 +4,7 @@ There are two formats for Element Call URLs.
|
||||
|
||||
## Link for sharing
|
||||
|
||||
Requires Element Call to be deployed in [standalone](./embedded-standalone.md) mode.
|
||||
Requires Element Call to be deployed in [standalone](./embedded_standalone.md) mode.
|
||||
|
||||
```text
|
||||
https://element_call.domain/room/#
|
||||
@@ -36,15 +36,15 @@ possible to support encryption.
|
||||
|
||||
| Package | Deployment | URL |
|
||||
| ------------------------------------ | ----------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [Full](./embedded-standalone.md) | All | `https://element_call.domain/room` |
|
||||
| [Embedded](./embedded-standalone.md) | Remote URL | `https://element_call.domain/` n.b. no `/room` part |
|
||||
| [Embedded](./embedded-standalone.md) | Embedded within messenger app | Platform dependent, but you load the `index.html` file without a `/room` part |
|
||||
| [Full](./embedded_standalone.md) | All | `https://element_call.domain/room` |
|
||||
| [Embedded](./embedded_standalone.md) | Remote URL | `https://element_call.domain/` n.b. no `/room` part |
|
||||
| [Embedded](./embedded_standalone.md) | Embedded within messenger app | Platform dependent, but you load the `index.html` file without a `/room` part |
|
||||
|
||||
## Parameters
|
||||
|
||||
### Common Parameters
|
||||
|
||||
These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes:
|
||||
These parameters are relevant to both [widget](./embedded_standalone.md) and [standalone](./embedded_standalone.md) modes:
|
||||
|
||||
| Name | Values | Required for widget | Required for SPA | Description |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -74,7 +74,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
|
||||
|
||||
### Widget-only parameters
|
||||
|
||||
These parameters are only supported in [widget](./embedded-standalone.md) mode.
|
||||
These parameters are only supported in [widget](./embedded_standalone.md) mode.
|
||||
|
||||
| Name | Values | Required | Description |
|
||||
| --------------- | ----------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -89,7 +89,7 @@ These parameters are only supported in [widget](./embedded-standalone.md) mode.
|
||||
|
||||
### Embedded-only parameters
|
||||
|
||||
These parameters are only supported in the [embedded](./embedded-standalone.md) package of Element Call and will be ignored in the [full](./embedded-standalone.md) package.
|
||||
These parameters are only supported in the [embedded](./embedded_standalone.md) package of Element Call and will be ignored in the [full](./embedded_standalone.md) package.
|
||||
|
||||
| Name | Values | Required | Description |
|
||||
| -------------------- | -------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -11,7 +11,7 @@ pushd $CURRENT_DIR > /dev/null
|
||||
function build_assets() {
|
||||
echo "Generating Element Call assets..."
|
||||
pushd ../.. > /dev/null
|
||||
yarn build
|
||||
pnpm build
|
||||
popd > /dev/null
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ function copy_assets() {
|
||||
}
|
||||
|
||||
getopts :sh opt
|
||||
case $opt in
|
||||
case $opt in
|
||||
s)
|
||||
SKIP=1
|
||||
;;
|
||||
@@ -41,7 +41,7 @@ if [ ! $SKIP ]; then
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
build_assets
|
||||
else
|
||||
else
|
||||
echo "Using existing assets from ../../dist"
|
||||
fi
|
||||
copy_assets
|
||||
@@ -56,4 +56,4 @@ echo "Publishing the Android project"
|
||||
|
||||
./gradlew publishAndReleaseToMavenCentral --no-daemon
|
||||
|
||||
popd > /dev/null
|
||||
popd > /dev/null
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0"
|
||||
/>
|
||||
<title><%- brand %></title>
|
||||
<script>
|
||||
|
||||
9
knip.ts
9
knip.ts
@@ -18,6 +18,7 @@ export default {
|
||||
// https://docs.docker.com/compose/migrate/
|
||||
"docker-compose",
|
||||
],
|
||||
ignoreFiles: ["scripts/.pnpmfile.cjs"],
|
||||
ignoreDependencies: [
|
||||
// Used in CSS
|
||||
"normalize.css",
|
||||
@@ -30,16 +31,10 @@ export default {
|
||||
"@types/content-type",
|
||||
"@types/sdp-transform",
|
||||
"@types/uuid",
|
||||
// We obviously use this, but if the package has been linked with yarn link,
|
||||
// We obviously use this, but if the package has been linked with pnpm link,
|
||||
// then Knip will flag it as a false positive
|
||||
// https://github.com/webpro-nl/knip/issues/766
|
||||
"@vector-im/compound-web",
|
||||
// Yarn plugins are allowed to depend on packages provided by the Yarn
|
||||
// runtime. These shouldn't be listed in package.json, because plugins
|
||||
// should work before Yarn even installs dependencies for the first time.
|
||||
// https://yarnpkg.com/advanced/plugin-tutorial#what-does-a-plugin-look-like
|
||||
"@yarnpkg/core",
|
||||
"@yarnpkg/parsers",
|
||||
"matrix-widget-api",
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
|
||||
57
package.json
57
package.json
@@ -3,23 +3,23 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "yarn dev:full",
|
||||
"dev": "pnpm dev:full",
|
||||
"dev:full": "vite",
|
||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||
"build": "yarn build:full",
|
||||
"build": "pnpm build:full",
|
||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||
"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",
|
||||
"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",
|
||||
"serve": "vite preview",
|
||||
"prettier:check": "prettier -c .",
|
||||
"prettier:format": "prettier -w .",
|
||||
"lint": "yarn lint:types && yarn lint:eslint && yarn lint:knip",
|
||||
"lint": "pnpm lint:types && pnpm lint:eslint && pnpm lint:knip",
|
||||
"lint:eslint": "eslint --max-warnings 0 src playwright",
|
||||
"lint:eslint-fix": "eslint --max-warnings 0 src playwright --fix",
|
||||
"lint:knip": "knip",
|
||||
@@ -31,9 +31,9 @@
|
||||
"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": "yarn test:playwright --ui",
|
||||
"links:enable": "mv .links.disabled.yaml .links.yaml & touch .links.yaml",
|
||||
"links:disable": "mv .links.yaml .links.disabled.yaml",
|
||||
"test:playwright:open": "pnpm test:playwright --ui",
|
||||
"links:on": "cp scripts/.pnpmfile.cjs .pnpmfile.cjs & pnpm install",
|
||||
"links:off": "rm .pnpmfile.cjs & pnpm install",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
@@ -81,7 +81,7 @@
|
||||
"@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.0.0",
|
||||
"@vector-im/compound-web": "^9.3.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
@@ -110,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#6e3efef0c5f660df47cf00874927dec1c75cc3cf",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
@@ -132,24 +132,29 @@
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint-language-service": "^5.0.5",
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-polyfills": "^0.26.0",
|
||||
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@4.7.0"
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
}
|
||||
|
||||
@@ -7,11 +7,22 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { join } from "path";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const baseURL = process.env.USE_DOCKER
|
||||
? "http://localhost:8080"
|
||||
: "https://localhost:3000";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Needed by the synapse admin API called in fixtures
|
||||
process.env.NODE_EXTRA_CA_CERTS = join(
|
||||
__dirname,
|
||||
"backend/dev_tls_local-ca.crt",
|
||||
);
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
|
||||
@@ -75,9 +75,7 @@ test("Should automatically retry non fatal JWT errors", async ({
|
||||
await expect(page.getByTestId("video").first()).toBeVisible();
|
||||
});
|
||||
|
||||
// We skip this test for now as it appears the livekit does not let us
|
||||
// detect and handle NotAllowed errors anymore. https://github.com/livekit/client-sdk-js/issues/1883
|
||||
test.skip("Should show error screen if call creation is restricted", async ({
|
||||
test("Should show error screen if call creation is restricted", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
|
||||
@@ -91,7 +91,9 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
|
||||
await ewPage1
|
||||
.getByRole("button", { name: "Invite to this room", exact: true })
|
||||
.click();
|
||||
.click({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(
|
||||
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
|
||||
).toBeVisible();
|
||||
@@ -104,6 +106,7 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId);
|
||||
await ewPage1.getByRole("dialog").getByRole("textbox").click();
|
||||
await ewPage1.getByRole("button", { name: "Invite" }).click();
|
||||
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
|
||||
|
||||
// Accept the invite
|
||||
await expect(
|
||||
@@ -126,6 +129,7 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await ewPage1.getByRole("textbox", { name: "Search" }).click();
|
||||
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
|
||||
await ewPage1.getByRole("button", { name: "Go" }).click();
|
||||
await TestHelpers.dismissInviteUnknownUserModalDM(ewPage1);
|
||||
|
||||
// Wait and send the first message to create the DM
|
||||
await expect(
|
||||
|
||||
@@ -53,7 +53,6 @@ test("@mobile Start a new call then leave and show the feedback screen", async (
|
||||
mobileTest(
|
||||
"Test earpiece overlay in controlledAudioDevices mode",
|
||||
async ({ asMobile, browser }) => {
|
||||
test.slow(); // Triples the timeout
|
||||
const { creatorPage, inviteLink } = asMobile;
|
||||
|
||||
// ========
|
||||
@@ -103,7 +102,7 @@ mobileTest(
|
||||
.click();
|
||||
|
||||
// dismiss settings
|
||||
await guestPage.locator("#root").getByLabel("Settings").press("Escape");
|
||||
await guestPage.locator("#root").press("Escape");
|
||||
|
||||
await guestPage.pause();
|
||||
await expect(
|
||||
|
||||
@@ -9,7 +9,9 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
test("When creator left, avoid reconnect to the same SFU", async ({
|
||||
browser,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(browserName === "firefox", "Browser independent");
|
||||
// Use reduce motion to disable animations that are making the tests a bit flaky
|
||||
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const creatorPage = await creatorContext.newPage();
|
||||
@@ -91,8 +93,10 @@ test("When creator left, avoid reconnect to the same SFU", async ({
|
||||
// the creator leaves the call
|
||||
await creatorPage.getByTestId("incall_leave").click();
|
||||
|
||||
await guestCPage.waitForTimeout(2000);
|
||||
// https://github.com/element-hq/element-call/issues/3344
|
||||
// The app used to request a new jwt token then to reconnect to the SFU
|
||||
expect(wsConnectionCount).toBe(1);
|
||||
// Wait a bit to be sure that if there was a reconnect, it would have happened by now
|
||||
await guestCPage.waitForTimeout(6000);
|
||||
expect(wsConnectionCount).toBe(1);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ async function setupTwoUserSpaCall(
|
||||
await page.goto("/");
|
||||
|
||||
let androlHasSentStickyEvent = false;
|
||||
|
||||
const androlResolver = Promise.withResolvers<void>();
|
||||
await interceptEventSend(
|
||||
page,
|
||||
// This room is not encrypted, so the event is sent in clear
|
||||
@@ -36,6 +36,7 @@ async function setupTwoUserSpaCall(
|
||||
(req) => {
|
||||
androlHasSentStickyEvent =
|
||||
androlHasSentStickyEvent || isStickySend(req.url());
|
||||
androlResolver.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -53,6 +54,7 @@ async function setupTwoUserSpaCall(
|
||||
|
||||
let pevaraHasSentStickyEvent = false;
|
||||
|
||||
const pevaraResolver = Promise.withResolvers<void>();
|
||||
await interceptEventSend(
|
||||
guestPage,
|
||||
// This room is not encrypted, so the event is sent in clear
|
||||
@@ -60,6 +62,7 @@ async function setupTwoUserSpaCall(
|
||||
(req) => {
|
||||
pevaraHasSentStickyEvent =
|
||||
pevaraHasSentStickyEvent || isStickySend(req.url());
|
||||
pevaraResolver.resolve();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -70,7 +73,9 @@ async function setupTwoUserSpaCall(
|
||||
"2_0",
|
||||
);
|
||||
// Assert both sides have sent sticky membership events
|
||||
await androlResolver.promise;
|
||||
expect(androlHasSentStickyEvent).toEqual(true);
|
||||
await pevaraResolver.promise;
|
||||
expect(pevaraHasSentStickyEvent).toEqual(true);
|
||||
|
||||
return { guestPage };
|
||||
@@ -110,8 +115,12 @@ test("One to One rejoin after improper leave does not crash EC", async ({
|
||||
await guestPage.getByTestId("lobby_joinCall").click();
|
||||
|
||||
// We cannot use the `expectVideoTilesCount` helper here since one of them is expected to show waiting for media
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(3);
|
||||
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2);
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(3, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
function isStickySend(url: string): boolean {
|
||||
|
||||
@@ -119,25 +119,27 @@ async function setRtcModeFromSettings(
|
||||
async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(page.getByText("Waiting for media...")).not.toBeVisible();
|
||||
await expect(page.getByText("Waiting for media...")).not.toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// There should be 5 video elements, visible and autoplaying
|
||||
const videoElements = await page.locator("video").all();
|
||||
expect(videoElements.length).toBe(count);
|
||||
// There should be `count` video elements, visible and autoplaying
|
||||
await expect(page.locator("video")).toHaveCount(count);
|
||||
|
||||
const blockDisplayCount = await page
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(count);
|
||||
await expect(async () => {
|
||||
const videoBlockCount = await page
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(videoBlockCount).toBe(count);
|
||||
}).toPass({
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
export const SpaHelpers = {
|
||||
|
||||
142
playwright/utils/synapse-admin.ts
Normal file
142
playwright/utils/synapse-admin.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
/**
|
||||
* Response from Synapse registration API
|
||||
*/
|
||||
export interface SynapseRegistrationResponse {
|
||||
access_token: string;
|
||||
user_id: string;
|
||||
home_server: string;
|
||||
device_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility class for interacting with Synapse Admin API
|
||||
* This provides fast user registration without going through the UI
|
||||
*
|
||||
* @see https://matrix-org.github.io/synapse/latest/admin_api/register_api.html
|
||||
*/
|
||||
export class SynapseAdmin {
|
||||
public constructor(
|
||||
private baseUrl: string = "https://synapse.m.localhost",
|
||||
private sharedSecret: string = "test_shared_secret_for_local_dev_only",
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register a user using the Synapse Admin API
|
||||
* This is much faster than going through the UI registration flow
|
||||
*
|
||||
* @param username - The username (localpart) for the new user
|
||||
* @param password - The password for the new user
|
||||
* @param displayName - Optional display name (defaults to username)
|
||||
* @param admin - Whether the user should be an admin (defaults to false)
|
||||
* @returns Registration response containing access token and user ID
|
||||
*/
|
||||
public async registerUser(
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
admin: boolean = false,
|
||||
): Promise<SynapseRegistrationResponse> {
|
||||
// Get a nonce first
|
||||
const nonce = await this.getNonce();
|
||||
|
||||
// Generate the HMAC
|
||||
const mac = this.generateMac(username, password, admin, nonce);
|
||||
|
||||
// Make the registration request
|
||||
const response = await fetch(`${this.baseUrl}/_synapse/admin/v1/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
nonce,
|
||||
username,
|
||||
password,
|
||||
displayname: displayName || username,
|
||||
admin,
|
||||
mac,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(
|
||||
`Failed to register user ${username}: ${response.status} ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nonce for registration
|
||||
* The nonce is required for the HMAC calculation
|
||||
*
|
||||
* @returns A nonce string
|
||||
*/
|
||||
private async getNonce(): Promise<string> {
|
||||
const response = await fetch(`${this.baseUrl}/_synapse/admin/v1/register`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get nonce: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC for shared secret registration
|
||||
* This is the authentication mechanism for the admin API
|
||||
*
|
||||
* @param username - The username
|
||||
* @param password - The password
|
||||
* @param admin - Whether the user is an admin
|
||||
* @param nonce - The nonce from the server
|
||||
* @returns The HMAC hex string
|
||||
*/
|
||||
private generateMac(
|
||||
username: string,
|
||||
password: string,
|
||||
admin: boolean,
|
||||
nonce: string,
|
||||
): string {
|
||||
const mac = createHmac("sha1", this.sharedSecret);
|
||||
mac.update(nonce);
|
||||
mac.update("\x00");
|
||||
mac.update(username);
|
||||
mac.update("\x00");
|
||||
mac.update(password);
|
||||
mac.update("\x00");
|
||||
mac.update(admin ? "admin" : "notadmin");
|
||||
|
||||
return mac.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new SynapseAdmin instance for a different homeserver
|
||||
*
|
||||
* @param baseUrl - The base URL of the homeserver
|
||||
* @param sharedSecret - The shared secret (defaults to test secret)
|
||||
* @returns A new SynapseAdmin instance
|
||||
*/
|
||||
public static forHomeserver(
|
||||
baseUrl: string,
|
||||
sharedSecret: string = "test_shared_secret_for_local_dev_only",
|
||||
): SynapseAdmin {
|
||||
return new SynapseAdmin(baseUrl, sharedSecret);
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,12 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
const florian = await addUser("floriant", HOST1);
|
||||
const timo = await addUser("timo", HOST2);
|
||||
test.slow();
|
||||
|
||||
const [florian, timo] = await Promise.all([
|
||||
addUser("florian", HOST1),
|
||||
addUser("timo", HOST2),
|
||||
]);
|
||||
|
||||
const roomName = "Call Room";
|
||||
|
||||
@@ -57,27 +61,20 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await user.page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(2);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
|
||||
// await florian.page.pause();
|
||||
|
||||
@@ -75,18 +75,11 @@ widgetTest(
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(2);
|
||||
await expect(frame.locator("video")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { HOST1, HOST2, TestHelpers } from "./test-helpers";
|
||||
widgetTest(
|
||||
`Test swapping publisher from ${HOST1} to ${HOST2}`,
|
||||
async ({ addUser, browserName }) => {
|
||||
test.slow();
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
@@ -65,40 +66,26 @@ widgetTest(
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// There are no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await user.page.waitForTimeout(3000);
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
// Wait for "Waiting for media..." to disappear (with timeout)
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
|
||||
timeout: 10000, // Maximum time to wait
|
||||
});
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(2);
|
||||
await expect(frame.locator("video")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
|
||||
// now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1)
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat");
|
||||
await timo.page.waitForTimeout(3000);
|
||||
const blockDisplayCount = await timo.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
expect(blockDisplayCount).toBe(2);
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(
|
||||
timo.page.locator('iframe[title="Element Call"]').contentFrame(),
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,18 +11,21 @@ import { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
// increase the timeouts, it is a long test and it is annoying to retry from the beginning for a single timeout.
|
||||
test.slow();
|
||||
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // We are registering multiple users here, give it more time
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
const timo = await addUser("Timo", HOST1);
|
||||
const robin = await addUser("Robin", HOST1);
|
||||
const halfshot = await addUser("Halfshot", HOST1);
|
||||
const florian = await addUser("florian", HOST1);
|
||||
const [valere, timo, robin, halfshot, florian] = await Promise.all([
|
||||
addUser("Valere", HOST1),
|
||||
addUser("Timo", HOST1),
|
||||
addUser("Robin", HOST1),
|
||||
addUser("Halfshot", HOST1),
|
||||
addUser("florian", HOST1),
|
||||
]);
|
||||
|
||||
const roomName = "Group Call Room";
|
||||
await TestHelpers.createRoom(roomName, valere.page, [
|
||||
@@ -47,52 +50,55 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
|
||||
await TestHelpers.joinCallFromLobby(valere.page);
|
||||
|
||||
for (const user of [timo, robin, halfshot, florian]) {
|
||||
await TestHelpers.joinCallInCurrentRoom(user.page);
|
||||
}
|
||||
await Promise.all(
|
||||
[timo, robin, halfshot, florian].map(async (user) => {
|
||||
await TestHelpers.joinCallInCurrentRoom(user.page);
|
||||
}),
|
||||
);
|
||||
|
||||
for (const user of [timo, robin, halfshot, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
// No lobby, should start with video on
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await Promise.all(
|
||||
[timo, robin, halfshot, florian].map(async (user) => {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// We should see 5 video tiles everywhere now
|
||||
for (const user of [valere, timo, robin, halfshot, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(5);
|
||||
for (const participant of [valere, timo, robin, halfshot, florian]) {
|
||||
// Check the names are correct
|
||||
await expect(frame.getByText(participant.displayName)).toBeVisible();
|
||||
}
|
||||
await Promise.all(
|
||||
[valere, timo, robin, halfshot, florian].map(async (user) => {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(5, {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// There is no other options than to wait for all media to be ready?
|
||||
// Or it is too flaky :/
|
||||
await user.page.waitForTimeout(5000);
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 5 video elements, visible and autoplaying
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(5);
|
||||
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
await Promise.all(
|
||||
[valere, timo, robin, halfshot, florian].map(async (user) => {
|
||||
// Check the names are correct
|
||||
await expect(frame.getByText(user.displayName)).toBeVisible();
|
||||
}),
|
||||
);
|
||||
expect(blockDisplayCount).toBe(5);
|
||||
}
|
||||
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
|
||||
// Use a bigger timeout here
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// There should be 5 video elements, visible and autoplaying
|
||||
await expect(frame.locator("video")).toHaveCount(5);
|
||||
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 5);
|
||||
}),
|
||||
);
|
||||
|
||||
// Quickly test muting one participant to see it reflects and that our asserts works
|
||||
const florianFrame = florian.page
|
||||
@@ -108,28 +114,16 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
await expect(florianVideoButton).toHaveAccessibleName("Start video");
|
||||
await expect(florianVideoButton).not.toBeChecked();
|
||||
|
||||
// wait a bit for the state to propagate
|
||||
await valere.page.waitForTimeout(3000);
|
||||
{
|
||||
const frame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(5);
|
||||
|
||||
const blockDisplayCount = await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) => window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
await expect(frame.locator("video")).toHaveCount(5, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// out of 5 ONLY 4 are visible (display:block) !!
|
||||
// XXX we need to be better at our HTML markup and accessibility, it would make
|
||||
// this kind of stuff way easier to test if we could look out for aria attributes.
|
||||
expect(blockDisplayCount).toBe(4);
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 4);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,8 +16,6 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
|
||||
const callRoom = "CallRoom";
|
||||
|
||||
@@ -47,7 +47,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
|
||||
// check that the video is on
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
).toBeVisible({
|
||||
// Increase timeout, as this expect was flaky
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
|
||||
@@ -63,8 +66,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
|
||||
const frame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
await expect(frame.locator("video")).toHaveCount(1, { timeout: 10000 });
|
||||
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(1);
|
||||
|
||||
const pipVideo = videoElements[0];
|
||||
await expect(pipVideo).toHaveCSS("object-fit", "cover");
|
||||
|
||||
152
playwright/widget/screen-share.test.ts
Normal file
152
playwright/widget/screen-share.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -17,7 +17,7 @@ widgetTest.skip(
|
||||
);
|
||||
|
||||
widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
|
||||
test.slow(); // Triples the timeout
|
||||
test.slow();
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
@@ -83,8 +83,12 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame()
|
||||
.getByTestId("incall_leave")
|
||||
.click();
|
||||
.click({ timeout: 15000 });
|
||||
|
||||
await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible();
|
||||
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
|
||||
await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
expect,
|
||||
type JSHandle,
|
||||
type Page,
|
||||
type FrameLocator,
|
||||
} from "@playwright/test";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { SynapseAdmin } from "../utils/synapse-admin.ts";
|
||||
|
||||
const PASSWORD = "foobarbaz1!";
|
||||
|
||||
export const HOST1 = "https://app.m.localhost/#/welcome";
|
||||
@@ -26,14 +29,14 @@ export class TestHelpers {
|
||||
voice: boolean = false,
|
||||
): Promise<void> {
|
||||
const buttonName = voice ? "Voice call" : "Video call";
|
||||
await expect(page.getByRole("button", { name: buttonName })).toBeVisible();
|
||||
await page.getByRole("button", { name: buttonName }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Element Call" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: buttonName }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
public static async joinCallFromLobby(page: Page): Promise<void> {
|
||||
@@ -51,15 +54,40 @@ 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> {
|
||||
// This is the header button that notifies about an ongoing call
|
||||
const label = audioOnly ? "Voice call started" : "Video call started";
|
||||
await expect(page.getByText(label)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Join" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Join" }).click();
|
||||
await 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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,43 +102,56 @@ export class TestHelpers {
|
||||
clientHandle: JSHandle<MatrixClient>;
|
||||
mxId: string;
|
||||
}> {
|
||||
// Determine which homeserver to use based on the host
|
||||
const synapseBaseUrl =
|
||||
host === HOST2
|
||||
? "https://synapse.othersite.m.localhost"
|
||||
: "https://synapse.m.localhost";
|
||||
|
||||
// Register user via Synapse Admin API to speed things up
|
||||
const synapseAdmin = SynapseAdmin.forHomeserver(synapseBaseUrl);
|
||||
const credentials = await synapseAdmin.registerUser(
|
||||
username,
|
||||
PASSWORD,
|
||||
username,
|
||||
);
|
||||
|
||||
// STEP 2: Open browser and login
|
||||
const userContext = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const page = await userContext.newPage();
|
||||
await page.goto(host);
|
||||
await page.getByRole("link", { name: "Create Account" }).click();
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(username);
|
||||
await page.getByRole("textbox", { name: "Password", exact: true }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Password", exact: true })
|
||||
.fill(PASSWORD);
|
||||
await page.getByRole("textbox", { name: "Confirm password" }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: "Confirm password" })
|
||||
.fill(PASSWORD);
|
||||
await page.getByRole("button", { name: "Register" }).click();
|
||||
|
||||
await page.getByRole("link", { name: "Sign in" }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.getByRole("textbox", { name: "Username" }).fill(username, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("textbox", { name: "Password" }).fill(PASSWORD, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: `Welcome ${username}` }),
|
||||
).toBeVisible({
|
||||
// Increase timeout as registration can be slow :/
|
||||
timeout: 15_000,
|
||||
// Increase timeout here :/ flaky
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await this.maybeDismissBrowserNotSupportedToast(page);
|
||||
await this.maybeDismissServiceWorkerWarningToast(page);
|
||||
await this.maybeDismissBackupChat(page);
|
||||
|
||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||
|
||||
const clientHandle = await page.evaluateHandle(() =>
|
||||
window.mxMatrixClientPeg.get(),
|
||||
);
|
||||
const mxId = (await clientHandle.evaluate(
|
||||
(cli: MatrixClient) => cli.getUserId(),
|
||||
clientHandle,
|
||||
))!;
|
||||
|
||||
const mxId = credentials.user_id;
|
||||
return { page, clientHandle, mxId };
|
||||
}
|
||||
|
||||
@@ -152,6 +193,22 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
private static async maybeDismissBackupChat(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
|
||||
}
|
||||
}
|
||||
|
||||
public static async maybeDismissKeyBackupToast(page: Page): Promise<void> {
|
||||
const toast = page
|
||||
.locator(".mx_Toast_toast")
|
||||
@@ -178,10 +235,14 @@ export class TestHelpers {
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
.click();
|
||||
|
||||
await page.getByRole("menuitem", { name: "New Room" }).click();
|
||||
await page.getByRole("menuitem", { name: "New Room" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.getByRole("textbox", { name: "Name" }).fill(name);
|
||||
await page.getByRole("button", { name: "Create room" }).click();
|
||||
await expect(page.getByText("You created this room.")).toBeVisible();
|
||||
await expect(page.getByText("You created this room.")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Encryption enabled")).toBeVisible();
|
||||
await TestHelpers.maybeDismissKeyBackupToast(page);
|
||||
|
||||
@@ -199,6 +260,7 @@ export class TestHelpers {
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Invite" }).click();
|
||||
await TestHelpers.dismissInviteUnknownUserModal(page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,9 +273,12 @@ export class TestHelpers {
|
||||
roomName: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("option", { name: roomName })).toBeVisible();
|
||||
await page.getByRole("option", { name: roomName }).click();
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
await page.getByRole("option", { name: roomName }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Accept" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole("main").getByRole("heading", { name: roomName }),
|
||||
@@ -233,8 +298,12 @@ export class TestHelpers {
|
||||
page: Page,
|
||||
mode: RtcMode,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Video call" }).click();
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||
await page.getByRole("button", { name: "Video call" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
|
||||
await page.getByRole("button", { name: "Close lobby" }).click();
|
||||
@@ -308,4 +377,52 @@ export class TestHelpers {
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
|
||||
}
|
||||
|
||||
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Invite new contacts to this" }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Invite" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
public static async dismissInviteUnknownUserModalDM(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "Start a chat with this new contact?",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Continue" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
public static async expectVisibleVideoCount(
|
||||
frame: FrameLocator,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
// XXX we need to be better at our HTML markup and accessibility, it would make
|
||||
// this kind of stuff way easier to test if we could look out for aria attributes.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
return await frame
|
||||
.locator("video")
|
||||
.evaluateAll(
|
||||
(videos: Element[]) =>
|
||||
videos.filter(
|
||||
(v: Element) =>
|
||||
window.getComputedStyle(v).display === "block",
|
||||
).length,
|
||||
);
|
||||
},
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
)
|
||||
.toBe(count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ widgetTest(
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // Triples the timeout
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, true);
|
||||
@@ -43,7 +41,7 @@ widgetTest(
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Accept" }).click();
|
||||
await whistler.page.getByRole("button", { name: "Join" }).click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
@@ -113,8 +111,6 @@ widgetTest(
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // Triples the timeout
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
|
||||
@@ -136,7 +132,7 @@ widgetTest(
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Accept" }).click();
|
||||
await whistler.page.getByRole("button", { name: "Join" }).click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
@@ -200,8 +196,6 @@ widgetTest(
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow(); // Triples the timeout
|
||||
|
||||
const { brooks, whistler } = asWidget;
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
|
||||
@@ -223,7 +217,7 @@ widgetTest(
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Decline" }).click();
|
||||
await whistler.page.getByRole("button", { name: "Ignore" }).click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
|
||||
13991
pnpm-lock.yaml
generated
Normal file
13991
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
# dependencies where we use branches and hashes in the package.json. But that also use a pre/post install script.
|
||||
onlyBuiltDependencies:
|
||||
- "matrix-js-sdk"
|
||||
@@ -54,8 +54,8 @@
|
||||
"matchFileNames": ["embedded/**/*"]
|
||||
},
|
||||
{
|
||||
"groupName": "Yarn",
|
||||
"matchDepNames": ["yarn"]
|
||||
"groupName": "Pnpm",
|
||||
"matchDepNames": ["pnpm"]
|
||||
}
|
||||
],
|
||||
"semanticCommits": "disabled",
|
||||
|
||||
62
scripts/.pnpmfile.cjs
Normal file
62
scripts/.pnpmfile.cjs
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
@@ -5,5 +5,5 @@ set -ex
|
||||
export VITE_APP_VERSION=$(git describe --tags --abbrev=0)
|
||||
|
||||
corepack enable
|
||||
yarn install
|
||||
yarn run build
|
||||
pnpm install
|
||||
pnpm run build
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/bin/sh
|
||||
if [ -n "$USE_DOCKER" ]; then
|
||||
set -ex
|
||||
yarn build
|
||||
pnpm 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 yarn dev
|
||||
exec pnpm dev --host
|
||||
fi
|
||||
|
||||
26
scripts/setup-linking.sh
Executable file
26
scripts/setup-linking.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Checks if there currently is linking configured. Informs the user to disable linking before committing.
|
||||
|
||||
LINKSFILE=.links.cjs
|
||||
echo "Checking for existing linking configuration in $LINKSFILE..."
|
||||
if test -f "$LINKSFILE"; then
|
||||
echo "Linking configuration found in $LINKSFILE."
|
||||
else
|
||||
echo "No $LINKSFILE -> Creating $LINKSFILE with default values. Please edit this file to point to your local checkouts of the dependencies you want to link."
|
||||
echo '''// Packages to link to local checkouts
|
||||
module.exports = {
|
||||
"matrix-js-sdk": "../your/path/matrix-js-sdk",
|
||||
"matrix-widget-api": "../your/path/matrix-widget-api",
|
||||
};''' > $LINKSFILE
|
||||
fi
|
||||
echo "updating local git hookPath to .githooks"
|
||||
git config --local core.hooksPath .githooks
|
||||
echo ""
|
||||
echo "Setup complete."
|
||||
echo "Update: .links.cjs to your liking"
|
||||
echo "Run: 'pnpm links:on' to test your .links.cjs"
|
||||
echo "Run: 'git commit' with links enabled to test the git pre-commit hook."
|
||||
echo "Run: 'pnpm links:off' to be able to commit again"
|
||||
echo "Run: 'git config --local core.hooksPath \"\"' to allow committing with linking on (not recommended)"
|
||||
echo "Run: 'rm links.cjs' & 'git config --local core.hooksPath \"\"' to fully revert what this script did"
|
||||
@@ -13,8 +13,8 @@ This folder contains an example index.html file that showcases the sdk in use (h
|
||||
To get started run
|
||||
|
||||
```
|
||||
yarn
|
||||
yarn build:sdk
|
||||
pnpm install
|
||||
pnpm build:sdk
|
||||
```
|
||||
|
||||
in the repository root.
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
|
||||
* This file is the entrypoint for the sdk build of element call: `pnpm build:sdk`
|
||||
* use in widgets.
|
||||
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
||||
* can be used to join a rtc session and exchange realtime data.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
.bar {
|
||||
block-size: 64px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bar > header {
|
||||
position: absolute;
|
||||
position: sticky;
|
||||
inset-inline: 0;
|
||||
inset-block-start: 0;
|
||||
block-size: 64px;
|
||||
|
||||
@@ -19,6 +19,7 @@ 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";
|
||||
@@ -49,7 +50,9 @@ export const AppBar: FC<Props> = ({ children }) => {
|
||||
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [hidden, setHidden] = useState<boolean>(false);
|
||||
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
|
||||
const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>(
|
||||
null,
|
||||
);
|
||||
const context = useMemo(
|
||||
() => ({ setTitle, setSecondaryButton, setHidden }),
|
||||
[setTitle, setHidden, setSecondaryButton],
|
||||
@@ -114,6 +117,10 @@ 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]);
|
||||
}
|
||||
@@ -129,6 +136,10 @@ 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]);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ Please see LICENSE in the repository root for full details.
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
padding-inline: var(--inline-content-inset);
|
||||
padding-left: var(--content-inset-left);
|
||||
padding-right: var(--content-inset-right);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.nav {
|
||||
|
||||
@@ -92,6 +92,11 @@ 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}
|
||||
>
|
||||
|
||||
@@ -87,7 +87,7 @@ export const RTCConnectionStats: FC<Props> = ({
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => showFullModal("audio")}
|
||||
size="sm"
|
||||
size="md"
|
||||
kind="tertiary"
|
||||
Icon={MicOnSolidIcon}
|
||||
>
|
||||
@@ -103,7 +103,7 @@ export const RTCConnectionStats: FC<Props> = ({
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => showFullModal("video")}
|
||||
size="sm"
|
||||
size="md"
|
||||
kind="tertiary"
|
||||
Icon={VideoCallSolidIcon}
|
||||
>
|
||||
|
||||
162
src/analytics/PosthogEvents.test.ts
Normal file
162
src/analytics/PosthogEvents.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
expect,
|
||||
describe,
|
||||
it,
|
||||
vi,
|
||||
beforeEach,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||
import { CallEndedTracker } from "./PosthogEvents";
|
||||
import { mockConfig } from "../utils/test";
|
||||
|
||||
const defaultCounters = {
|
||||
roomEventEncryptionKeysSent: 10,
|
||||
roomEventEncryptionKeysReceived: 5,
|
||||
};
|
||||
|
||||
const defaultTotals = {
|
||||
roomEventEncryptionKeysReceivedTotalAge: 500,
|
||||
};
|
||||
|
||||
function createMockRtcSession(overrides?: {
|
||||
counters?: Partial<typeof defaultCounters>;
|
||||
totals?: Partial<typeof defaultTotals>;
|
||||
}): MatrixRTCSession {
|
||||
return {
|
||||
statistics: {
|
||||
counters: { ...defaultCounters, ...overrides?.counters },
|
||||
totals: { ...defaultTotals, ...overrides?.totals },
|
||||
},
|
||||
} as unknown as MatrixRTCSession;
|
||||
}
|
||||
|
||||
describe("CallEnded", () => {
|
||||
beforeAll(() => {
|
||||
mockConfig();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
it("warns if startTime is missing when track is called", () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn");
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.track("test-call-id", 2, false, mockSession);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
|
||||
);
|
||||
expect(PosthogAnalytics.instance.trackEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tracks event with correct properties when startTime is set", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.cacheStartCall(new Date(Date.now() - 60000));
|
||||
tracker.cacheParticipantCountChanged(5);
|
||||
tracker.track("test-call-id", 3, true, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
{
|
||||
eventName: "CallEnded",
|
||||
callId: "test-call-id",
|
||||
callParticipantsMax: 5,
|
||||
callParticipantsOnLeave: 3,
|
||||
callDuration: expect.closeTo(60, 1),
|
||||
roomEventEncryptionKeysSent: 10,
|
||||
roomEventEncryptionKeysReceived: 5,
|
||||
roomEventEncryptionKeysReceivedAverageAge: 100,
|
||||
},
|
||||
{ send_instantly: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks maxParticipantsCount correctly across multiple changes", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.cacheParticipantCountChanged(3);
|
||||
tracker.cacheParticipantCountChanged(7);
|
||||
tracker.cacheParticipantCountChanged(2);
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
callParticipantsMax: 7,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("computes roomEventEncryptionKeysReceivedAverageAge as 0 when no keys received", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession({
|
||||
counters: { roomEventEncryptionKeysReceived: 0 },
|
||||
});
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
roomEventEncryptionKeysReceivedAverageAge: 0,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("computes roomEventEncryptionKeysReceivedAverageAge correctly when keys are received", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession({
|
||||
counters: { roomEventEncryptionKeysReceived: 4 },
|
||||
totals: { roomEventEncryptionKeysReceivedTotalAge: 200 },
|
||||
});
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
roomEventEncryptionKeysReceivedAverageAge: 50,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes send_instantly option correctly", () => {
|
||||
const tracker = new CallEndedTracker();
|
||||
const mockSession = createMockRtcSession();
|
||||
|
||||
tracker.cacheStartCall(new Date());
|
||||
tracker.track("test-call-id", 1, false, mockSession);
|
||||
|
||||
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ send_instantly: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -27,8 +27,8 @@ interface CallEnded extends IPosthogEvent {
|
||||
}
|
||||
|
||||
export class CallEndedTracker {
|
||||
private cache: { startTime: Date; maxParticipantsCount: number } = {
|
||||
startTime: new Date(0),
|
||||
private cache: { startTime?: Date; maxParticipantsCount: number } = {
|
||||
startTime: undefined,
|
||||
maxParticipantsCount: 0,
|
||||
};
|
||||
|
||||
@@ -49,26 +49,32 @@ export class CallEndedTracker {
|
||||
sendInstantly: boolean,
|
||||
rtcSession: MatrixRTCSession,
|
||||
): void {
|
||||
PosthogAnalytics.instance.trackEvent<CallEnded>(
|
||||
{
|
||||
eventName: "CallEnded",
|
||||
callId: callId,
|
||||
callParticipantsMax: this.cache.maxParticipantsCount,
|
||||
callParticipantsOnLeave: callParticipantsNow,
|
||||
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
|
||||
roomEventEncryptionKeysSent:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
|
||||
roomEventEncryptionKeysReceived:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
|
||||
roomEventEncryptionKeysReceivedAverageAge:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
|
||||
? rtcSession.statistics.totals
|
||||
.roomEventEncryptionKeysReceivedTotalAge /
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
|
||||
: 0,
|
||||
},
|
||||
{ send_instantly: sendInstantly },
|
||||
);
|
||||
if (this.cache.startTime) {
|
||||
PosthogAnalytics.instance.trackEvent<CallEnded>(
|
||||
{
|
||||
eventName: "CallEnded",
|
||||
callId: callId,
|
||||
callParticipantsMax: this.cache.maxParticipantsCount,
|
||||
callParticipantsOnLeave: callParticipantsNow,
|
||||
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
|
||||
roomEventEncryptionKeysSent:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
|
||||
roomEventEncryptionKeysReceived:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
|
||||
roomEventEncryptionKeysReceivedAverageAge:
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
|
||||
? rtcSession.statistics.totals
|
||||
.roomEventEncryptionKeysReceivedTotalAge /
|
||||
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
|
||||
: 0,
|
||||
},
|
||||
{ send_instantly: sendInstantly },
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,14 +22,17 @@ import {
|
||||
ShareScreenSolidIcon,
|
||||
OverflowHorizontalIcon,
|
||||
OverflowVerticalIcon,
|
||||
VolumeOnSolidIcon,
|
||||
VolumeOffSolidIcon,
|
||||
} 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?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
@@ -44,7 +47,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
<CpdButton
|
||||
iconOnly
|
||||
Icon={Icon}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
kind={enabled ? "secondary" : "primary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
@@ -55,7 +58,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
@@ -70,7 +73,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
<CpdButton
|
||||
iconOnly
|
||||
Icon={Icon}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
kind={enabled ? "secondary" : "primary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
@@ -81,7 +84,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
|
||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size: "sm" | "lg";
|
||||
size: "md" | "lg";
|
||||
}
|
||||
|
||||
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
@@ -108,7 +111,7 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
};
|
||||
|
||||
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
@@ -130,32 +133,89 @@ 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> = (props) => {
|
||||
export const SettingsIconButton: FC<SettingsIconButtonProps> = ({
|
||||
showForScreenWidth,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon =
|
||||
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
|
||||
return (
|
||||
<Tooltip label={t("common.settings")}>
|
||||
<IconButton {...props}>
|
||||
<IconButton
|
||||
className={classNamesForScreenWidth(className, showForScreenWidth)}
|
||||
{...props}
|
||||
>
|
||||
<Icon aria-hidden />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
// size?: "sm" | "lg";
|
||||
// }
|
||||
// const SettingsButton: FC<SettingsButtonProps> = (props) => {
|
||||
// const { t } = useTranslation();
|
||||
// const Icon =
|
||||
// platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
|
||||
// return (
|
||||
// <Tooltip label={t("common.settings")}>
|
||||
// <CpdButton iconOnly Icon={Icon} kind="secondary" {...props} />
|
||||
// </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";
|
||||
}
|
||||
export const SettingsButton: FC<SettingsButtonProps> = ({
|
||||
showForScreenWidth,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip label={t("common.settings")}>
|
||||
<CpdButton
|
||||
className={classNamesForScreenWidth(className, showForScreenWidth)}
|
||||
iconOnly
|
||||
Icon={
|
||||
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon
|
||||
}
|
||||
kind={"secondary"}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export const InviteButton: FC<
|
||||
> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button kind="secondary" size="sm" Icon={UserAddIcon} {...props}>
|
||||
<Button kind="secondary" size="md" Icon={UserAddIcon} {...props}>
|
||||
{t("action.invite")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,13 @@ function TestComponent({
|
||||
vm={vm}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
>
|
||||
<ReactionToggleButton vm={vm} identifier={localIdent} />
|
||||
<ReactionToggleButton
|
||||
reactionData={{
|
||||
reactions$: vm.reactions$,
|
||||
handsRaised$: vm.handsRaised$,
|
||||
}}
|
||||
identifier={localIdent}
|
||||
/>
|
||||
</ReactionsSenderProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -28,13 +28,14 @@ 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;
|
||||
@@ -163,15 +164,22 @@ 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;
|
||||
vm: CallViewModel;
|
||||
size?: "sm" | "lg";
|
||||
size?: "md" | "lg";
|
||||
/** List of participants raising their hand */
|
||||
}
|
||||
|
||||
export function ReactionToggleButton({
|
||||
identifier,
|
||||
vm,
|
||||
reactionData: { handsRaised$, reactions$ },
|
||||
...props
|
||||
}: ReactionToggleButtonProps): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
@@ -180,8 +188,8 @@ export function ReactionToggleButton({
|
||||
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
|
||||
const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
|
||||
const canReact = !useBehavior(vm.reactions$)[identifier];
|
||||
const isHandRaised = !!useBehavior(handsRaised$)[identifier];
|
||||
const canReact = !useBehavior(reactions$)[identifier];
|
||||
|
||||
useEffect(() => {
|
||||
// Clear whenever the reactions menu state changes.
|
||||
|
||||
@@ -10,7 +10,7 @@ exports[`Can close reaction dialog 1`] = `
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_bb_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_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_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_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_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_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_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_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_13vu4_8 raisedButton _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
class="_button_1nw83_8 raisedButton _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
|
||||
37
src/components/CallFooter.mdx
Normal file
37
src/components/CallFooter.mdx
Normal file
@@ -0,0 +1,37 @@
|
||||
{/**
|
||||
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 />
|
||||
166
src/components/CallFooter.module.css
Normal file
166
src/components/CallFooter.module.css
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
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);
|
||||
}
|
||||
}
|
||||
244
src/components/CallFooter.stories.tsx
Normal file
244
src/components/CallFooter.stories.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
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 { fn } from "storybook/test";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type ReactNode } from "react";
|
||||
import { Link } from "@vector-im/compound-web";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CallFooter, type FooterProps } from "./CallFooter";
|
||||
import inCallViewStyles from "../room/InCallView.module.css";
|
||||
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
|
||||
function CallFooterWrapper(props: FooterProps): ReactNode {
|
||||
return (
|
||||
<div className={inCallViewStyles.inRoom}>
|
||||
<ReactionsSenderContext
|
||||
value={{
|
||||
supportsReactions: false,
|
||||
toggleRaisedHand: async () => Promise.resolve(),
|
||||
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
|
||||
}}
|
||||
>
|
||||
<CallFooter {...props} />
|
||||
</ReactionsSenderContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
component: CallFooterWrapper,
|
||||
} satisfies Meta<typeof CallFooterWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const reactionIdentifier = "@user:example.com:DEVICE";
|
||||
const reactionData = {
|
||||
handsRaised$: new BehaviorSubject({}),
|
||||
reactions$: new BehaviorSubject({}),
|
||||
};
|
||||
|
||||
const fnArgType = {
|
||||
control: { type: "select" as const },
|
||||
options: ["MockedCallback", "undefined"],
|
||||
mapping: { MockedCallback: fn(), undefined: undefined },
|
||||
};
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
hideLogo: true,
|
||||
layoutMode: "grid",
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
setLayoutMode: fn(),
|
||||
openSettings: fn(),
|
||||
toggleAudio: fn(),
|
||||
toggleVideo: fn(),
|
||||
toggleScreenSharing: fn(),
|
||||
hangup: fn(),
|
||||
},
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
argTypes: {
|
||||
layoutMode: { control: "radio", options: ["grid", "spotlight"] },
|
||||
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 WithLogo: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const AudioVideoEnabled: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
asPip: true,
|
||||
},
|
||||
};
|
||||
export const NoControlsWithLogo: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideControls: true,
|
||||
hideLogo: false,
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
hideLogo: true,
|
||||
|
||||
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
|
||||
},
|
||||
globals: {
|
||||
viewport: { value: "mobile2", isRotated: false },
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const Lobby: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: true,
|
||||
openSettings: undefined,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyMobile: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
hideLogo: true,
|
||||
|
||||
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>,
|
||||
hideLogo: true,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
export const LobbyRecentButtonMobile: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
hideLogo: true,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
globals: {
|
||||
viewport: { value: "mobile2", isRotated: false },
|
||||
},
|
||||
parameters: {
|
||||
...Default.parameters,
|
||||
},
|
||||
};
|
||||
243
src/components/CallFooter.tsx
Normal file
243
src/components/CallFooter.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
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 { BehaviorSubject } from "rxjs";
|
||||
|
||||
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 { LayoutToggle } from "../room/LayoutToggle";
|
||||
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
switch: () => void;
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
/** Children will only be visible if the component is wider than 5*/
|
||||
children?: JSX.Element | JSX.Element[] | false;
|
||||
|
||||
audioEnabled: boolean;
|
||||
/** Also controls if the audioMute button is disabled */
|
||||
toggleAudio: (() => void) | undefined;
|
||||
videoEnabled: boolean;
|
||||
/** Also controls if the videoMute button is disabled */
|
||||
toggleVideo: (() => void) | undefined;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
hideControls?: boolean;
|
||||
/** hide the entire footer*/
|
||||
hidden?: boolean;
|
||||
/** Pip controls buttonSize and hides: settings button, layout switcher and logo */
|
||||
asPip?: boolean;
|
||||
/** The footer should be used as an overlay.
|
||||
* (Over the Call Grid) This saves spaces on small screens.*/
|
||||
asOverlay?: boolean;
|
||||
|
||||
layoutMode?: GridMode;
|
||||
/** Also controls if the layout button is visible */
|
||||
setLayoutMode?: (mode: GridMode) => void;
|
||||
|
||||
sharingScreen?: boolean;
|
||||
toggleScreenSharing?: () => void;
|
||||
|
||||
/** Also controls if the audio button is visible */
|
||||
audioOutputSwitcher?: AudioOutputSwitcher;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings?: () => void;
|
||||
/** Also controls if the hangup button is visible */
|
||||
hangup?: () => void;
|
||||
|
||||
reactionIdentifier?: string;
|
||||
reactionData?: ReactionData;
|
||||
|
||||
hideLogo?: boolean;
|
||||
// debug stuff
|
||||
debugTileLayout?: boolean;
|
||||
tileStoreGeneration?: number;
|
||||
}
|
||||
|
||||
export const CallFooter: FC<FooterProps> = ({
|
||||
ref,
|
||||
children,
|
||||
asOverlay,
|
||||
hidden,
|
||||
hideControls,
|
||||
hideLogo,
|
||||
asPip,
|
||||
layoutMode,
|
||||
setLayoutMode,
|
||||
openSettings,
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
sharingScreen,
|
||||
toggleScreenSharing,
|
||||
reactionIdentifier,
|
||||
reactionData,
|
||||
audioOutputSwitcher,
|
||||
hangup,
|
||||
debugTileLayout,
|
||||
tileStoreGeneration,
|
||||
}) => {
|
||||
const buttons: JSX.Element[] = [];
|
||||
const buttonSize = asPip ? "md" : "lg";
|
||||
const showSettingsButton =
|
||||
openSettings !== undefined && !asPip && !hideControls;
|
||||
const showLayoutSwitcher = !asPip && !hideControls;
|
||||
const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout);
|
||||
const showLogo = !hideLogo && !asPip;
|
||||
if (showSettingsButton) {
|
||||
// add the settings button to the center group of buttons, so it will be visible on small screens.
|
||||
// On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible.
|
||||
buttons.push(
|
||||
<SettingsButton
|
||||
key="settings"
|
||||
showForScreenWidth="narrow"
|
||||
onClick={openSettings}
|
||||
data-testid="settings-bottom-center"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
enabled={audioEnabled ?? false}
|
||||
onClick={toggleAudio}
|
||||
disabled={toggleAudio === undefined}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<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 ?? {
|
||||
handsRaised$: new BehaviorSubject({}),
|
||||
reactions$: new BehaviorSubject({}),
|
||||
}
|
||||
}
|
||||
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}
|
||||
className={classNames(styles.footer, {
|
||||
[styles.overlay]: asOverlay,
|
||||
[styles.hidden]: hidden,
|
||||
})}
|
||||
>
|
||||
<div className={styles.settingsLogoContainer}>
|
||||
{showSettingsButton && (
|
||||
<SettingsIconButton
|
||||
key="settings"
|
||||
kind="secondary"
|
||||
showForScreenWidth="wide"
|
||||
onClick={openSettings}
|
||||
data-testid="settings-bottom-left"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
{showLogoDebugContainer && logoDebugContainer}
|
||||
</div>
|
||||
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
|
||||
{setLayoutMode && layoutMode && showLayoutSwitcher && (
|
||||
<LayoutToggle
|
||||
className={styles.layout}
|
||||
layout={layoutMode}
|
||||
setLayout={setLayoutMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -97,6 +97,13 @@ export interface ConfigOptions {
|
||||
enable_video?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Grace period in milliseconds to wait before reporting the sync loop as disconnected.
|
||||
* This allows brief sync interruptions without triggering a reconnection message.
|
||||
* Default is 10000ms (10 seconds). Set to 0 to disable the grace period.
|
||||
*/
|
||||
sync_disconnect_grace_period_ms?: number;
|
||||
|
||||
/**
|
||||
* These are low level options that are used to configure the MatrixRTC session.
|
||||
* Take care when changing these options.
|
||||
@@ -155,6 +162,7 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
server_name: string;
|
||||
};
|
||||
};
|
||||
sync_disconnect_grace_period_ms: number;
|
||||
ssla: string;
|
||||
}
|
||||
|
||||
@@ -168,5 +176,6 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
features: {
|
||||
feature_use_device_session_member_events: true,
|
||||
},
|
||||
sync_disconnect_grace_period_ms: 10000,
|
||||
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
};
|
||||
|
||||
@@ -266,6 +266,20 @@ export function Grid<
|
||||
}, []),
|
||||
useCallback(() => window.innerHeight, []),
|
||||
);
|
||||
const orientation = useSyncExternalStore(
|
||||
useCallback((onChange) => {
|
||||
// Support for the change event is experimental
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/change_event#browser_compatibility
|
||||
(screen as unknown as EventTarget).addEventListener?.("change", onChange);
|
||||
return (): void =>
|
||||
(screen as unknown as EventTarget).removeEventListener?.(
|
||||
"change",
|
||||
onChange,
|
||||
);
|
||||
}, []),
|
||||
useCallback(() => window.innerHeight, []),
|
||||
);
|
||||
|
||||
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
||||
const [generation, setGeneration] = useState<number | null>(null);
|
||||
const [visibleTilesCallback, setVisibleTilesCallback] =
|
||||
@@ -336,10 +350,10 @@ export function Grid<
|
||||
}
|
||||
|
||||
return result;
|
||||
// The rects may change due to the grid resizing or updating to a new
|
||||
// generation, but eslint can't statically verify this
|
||||
// The rects may change due to the grid resizing, changing orientation, or
|
||||
// updating to a new generation, but eslint can't statically verify this
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gridRoot, layoutRoot, tiles, gridBounds, generation]);
|
||||
}, [gridRoot, layoutRoot, tiles, gridBounds, orientation, generation]);
|
||||
|
||||
// The height of the portion of the grid visible at any given time
|
||||
const visibleHeight = useMemo(
|
||||
|
||||
@@ -31,8 +31,9 @@ Please see LICENSE in the repository root for full details.
|
||||
position: absolute;
|
||||
inline-size: 404px;
|
||||
block-size: 233px;
|
||||
inset-block: 0;
|
||||
inset-inline: var(--cpd-space-3x);
|
||||
/* Ensure that spotlight tile lies within the safe area */
|
||||
inset: 0 calc(env(safe-area-inset-right) + var(--cpd-space-3x)) 0
|
||||
calc(env(safe-area-inset-left) + var(--cpd-space-3x));
|
||||
}
|
||||
|
||||
.fixed > .slot[data-block-alignment="start"] {
|
||||
|
||||
@@ -18,7 +18,11 @@ Please see LICENSE in the repository root for full details.
|
||||
position: absolute;
|
||||
inline-size: 135px;
|
||||
block-size: 160px;
|
||||
inset: var(--cpd-space-4x);
|
||||
/* Ensure that PiP lies within the safe area */
|
||||
inset: calc(env(safe-area-inset-top) + var(--cpd-space-4x))
|
||||
var(--content-inset-right)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x))
|
||||
var(--content-inset-left);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
|
||||
@@ -37,10 +37,20 @@ layer(compound);
|
||||
--cpd-color-border-accent: var(--cpd-color-green-800);
|
||||
/* The distance to inset non-full-width content from the edge of the window
|
||||
along the inline axis. This ramps up from 16px for typical mobile windows, to
|
||||
96px for typical desktop windows. */
|
||||
--inline-content-inset: min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
96px for typical desktop windows, and accounts for the safe area. */
|
||||
--content-inset-left: calc(
|
||||
env(safe-area-inset-left) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--content-inset-right: calc(
|
||||
env(safe-area-inset-right) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
|
||||
|
||||
@@ -113,7 +113,7 @@ export const AvatarInputField: FC<Props> = ({
|
||||
iconOnly
|
||||
Icon={EditIcon}
|
||||
kind="tertiary"
|
||||
size="sm"
|
||||
size="md"
|
||||
aria-label={t("action.edit")}
|
||||
/>
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export const AvatarInputField: FC<Props> = ({
|
||||
iconOnly
|
||||
Icon={EditIcon}
|
||||
kind="tertiary"
|
||||
size="sm"
|
||||
size="md"
|
||||
aria-label={t("action.edit")}
|
||||
onClick={onSelectUpload}
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ interface WasmFileset {
|
||||
// MediaPipe and depend on node_modules having this specific structure. It's
|
||||
// easy to see this breaking if our dependencies changed and MediaPipe were
|
||||
// no longer hoisted, or if we switched to another dependency loader such as
|
||||
// Yarn PnP.
|
||||
// yarn PnP.
|
||||
// https://github.com/google-ai-edge/mediapipe/issues/5961
|
||||
const wasmFileset: WasmFileset = {
|
||||
wasmLoaderPath: new URL(
|
||||
|
||||
@@ -29,7 +29,7 @@ interface ReactionsSenderContextType {
|
||||
sendReaction: (reaction: ReactionOption) => Promise<void>;
|
||||
}
|
||||
|
||||
const ReactionsSenderContext = createContext<
|
||||
export const ReactionsSenderContext = createContext<
|
||||
ReactionsSenderContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
|
||||
@@ -57,7 +57,8 @@ Please see LICENSE in the repository root for full details.
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-inline: var(--inline-content-inset);
|
||||
padding-left: var(--content-inset-left);
|
||||
padding-right: var(--content-inset-right);
|
||||
}
|
||||
|
||||
.logo {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
|
||||
<Text>{t("handset.overlay_description")}</Text>
|
||||
<Button
|
||||
kind="primary"
|
||||
size="sm"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
onBackToVideoPressed?.();
|
||||
}}
|
||||
|
||||
@@ -49,7 +49,6 @@ import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type MuteStates } from "../state/MuteStates.ts";
|
||||
|
||||
@@ -173,7 +172,6 @@ function createGroupCallView(
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
header={HeaderStyle.Standard}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
|
||||
@@ -93,7 +93,6 @@ interface Props {
|
||||
confineToRoom: boolean;
|
||||
preload: UrlParams["preload"];
|
||||
skipLobby: UrlParams["skipLobby"];
|
||||
header: HeaderStyle;
|
||||
rtcSession: MatrixRTCSession;
|
||||
joined: boolean;
|
||||
setJoined: (value: boolean) => void;
|
||||
@@ -107,7 +106,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
confineToRoom,
|
||||
preload,
|
||||
skipLobby,
|
||||
header,
|
||||
rtcSession,
|
||||
joined,
|
||||
setJoined,
|
||||
@@ -182,6 +180,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
perParticipantE2EE,
|
||||
returnToLobby,
|
||||
password: passwordFromUrl,
|
||||
header,
|
||||
} = useUrlParams();
|
||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||
|
||||
@@ -437,7 +436,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
muteStates={muteStates}
|
||||
onEnter={() => setJoined(true)}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={header === HeaderStyle.None}
|
||||
hideHeader={header !== HeaderStyle.Standard}
|
||||
participantCount={participantCount}
|
||||
onShareClick={onShareClick}
|
||||
/>
|
||||
@@ -463,7 +462,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
matrixRoom={room}
|
||||
onLeft={onLeft}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
e2eeSystem={e2eeSystem}
|
||||
//otelGroupCallMembership={otelGroupCallMembership}
|
||||
|
||||
@@ -31,144 +31,12 @@ Please see LICENSE in the repository root for full details.
|
||||
background: none;
|
||||
}
|
||||
|
||||
.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);
|
||||
padding: var(--cpd-space-10x) var(--cpd-space-6x);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
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";
|
||||
}
|
||||
|
||||
/*.settingsLogoContainer {
|
||||
display: none;
|
||||
}*/
|
||||
|
||||
.layout {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 800px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-8x);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 400px) {
|
||||
.footer {
|
||||
padding-block: 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: var(--cpd-space-2x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.invite,
|
||||
.raiseHand {
|
||||
.invite {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.buttons {
|
||||
gap: var(--cpd-space-4x);
|
||||
}
|
||||
}
|
||||
|
||||
.fixedGrid {
|
||||
position: absolute;
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { render, type RenderResult } from "@testing-library/react";
|
||||
import { type LocalParticipant } from "livekit-client";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { BrowserRouter, MemoryRouter } from "react-router-dom";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -34,13 +34,15 @@ import {
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel";
|
||||
import { alice, local } from "../utils/test-fixtures";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { AppBar } from "../AppBar";
|
||||
import { initializeWidget } from "../widget";
|
||||
|
||||
initializeWidget();
|
||||
@@ -97,6 +99,11 @@ beforeEach(() => {
|
||||
});
|
||||
interface CreateInCallViewArgs {
|
||||
mediaDevices?: ECMediaDevices;
|
||||
callViewModelOptions?: Partial<CallViewModelOptions>;
|
||||
/** If set, uses a MemoryRouter with this as the initial entry instead of BrowserRouter */
|
||||
initialRoute?: string;
|
||||
/** If true, wraps the rendered tree in an AppBar provider */
|
||||
withAppBar?: boolean;
|
||||
}
|
||||
function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
rtcSession: MockRTCSession;
|
||||
@@ -115,47 +122,59 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
[local, alice],
|
||||
undefined,
|
||||
mediaDevices,
|
||||
{},
|
||||
args.callViewModelOptions,
|
||||
);
|
||||
|
||||
rtcSession.joined = true;
|
||||
const room = rtcSession.room;
|
||||
const client = room.client;
|
||||
|
||||
const Router = args.initialRoute
|
||||
? ({ children }: { children: React.ReactNode }): React.ReactNode => (
|
||||
<MemoryRouter initialEntries={[args.initialRoute!]}>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
)
|
||||
: BrowserRouter;
|
||||
|
||||
const inCallView = (
|
||||
<InCallView
|
||||
client={client}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
vm={vm}
|
||||
matrixInfo={{
|
||||
userId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
e2eeSystem: {
|
||||
kind: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
matrixRoom={room}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = args.withAppBar ? <AppBar>{inCallView}</AppBar> : inCallView;
|
||||
|
||||
const renderResult = render(
|
||||
<BrowserRouter>
|
||||
<Router>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<RoomContext value={livekitRoom}>
|
||||
<InCallView
|
||||
client={client}
|
||||
header={HeaderStyle.Standard}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
vm={vm}
|
||||
matrixInfo={{
|
||||
userId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
e2eeSystem: {
|
||||
kind: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
matrixRoom={room}
|
||||
onShareClick={null}
|
||||
/>
|
||||
</RoomContext>
|
||||
<RoomContext value={livekitRoom}>{content}</RoomContext>
|
||||
</TooltipProvider>
|
||||
</ReactionsSenderProvider>
|
||||
</MediaDevicesContext>
|
||||
</BrowserRouter>,
|
||||
</Router>,
|
||||
);
|
||||
return {
|
||||
...renderResult,
|
||||
@@ -170,6 +189,57 @@ describe("InCallView", () => {
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe("settings button with AppBar header", () => {
|
||||
it("mobile landscape, is accessible when showHeader is false", () => {
|
||||
// windowSize with height <= 600 results in "flat" windowMode,
|
||||
// which means showHeader$ emits false.
|
||||
const { getAllByRole } = createInCallView({
|
||||
initialRoute: "/?header=app_bar",
|
||||
withAppBar: true,
|
||||
callViewModelOptions: {
|
||||
// Set windowMode$ to "flat" (height <= 600)
|
||||
windowSize$: constant({ width: 1000, height: 500 }),
|
||||
},
|
||||
});
|
||||
// When showHeader is false, hideSettingsButton is false,
|
||||
// so the settings button is visible in the footer.
|
||||
const settingsBtn = getAllByRole("button", { name: "Settings" });
|
||||
// here we check for two settings buttons because there are two buttons in the bottom bar. One for the
|
||||
// the narrow layout and another one for the wide layout.
|
||||
// Their visibility uses @media css queries, which cannot be tested in JSDOM,
|
||||
// but we can at least check that both buttons are rendered and have the correct classes.
|
||||
expect(settingsBtn.length).toBe(2);
|
||||
expect(settingsBtn[0]).toHaveAttribute(
|
||||
"data-testid",
|
||||
"settings-bottom-left",
|
||||
);
|
||||
expect(settingsBtn[0]).toBeVisible();
|
||||
});
|
||||
|
||||
it("mobile portrait, is accessible when showHeader is true", () => {
|
||||
// windowSize with height > 600 and width > 600 results in "normal" windowMode,
|
||||
// which means showHeader$ emits true.
|
||||
const { getAllByRole } = createInCallView({
|
||||
initialRoute: "/?header=app_bar",
|
||||
withAppBar: true,
|
||||
callViewModelOptions: {
|
||||
// Set windowMode$ to "normal" (height >= 600)
|
||||
windowSize$: constant({ width: 1000, height: 800 }),
|
||||
},
|
||||
});
|
||||
// When showHeader is true and headerStyle is AppBar,
|
||||
// hideSettingsButton is true in the footer, but the settings
|
||||
// button is rendered in the AppBar via useAppBarSecondaryButton.
|
||||
const settingsBtns = getAllByRole("button", { name: "Settings" });
|
||||
|
||||
expect(settingsBtns.length).toBe(1);
|
||||
expect(settingsBtns[0]).toHaveAttribute(
|
||||
"data-testid",
|
||||
"settings-app-bar",
|
||||
);
|
||||
expect(settingsBtns[0]).toBeVisible();
|
||||
});
|
||||
});
|
||||
describe("audioOutputSwitcher", () => {
|
||||
it("is visible and can be clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -183,9 +253,10 @@ describe("InCallView", () => {
|
||||
["earpiece-id", { type: "earpiece" }],
|
||||
]),
|
||||
);
|
||||
const selected$ = new BehaviorSubject<
|
||||
{ id: string; virtualEarpiece: boolean } | undefined
|
||||
>({ id: "speaker-id", virtualEarpiece: false });
|
||||
const selected$ = new BehaviorSubject({
|
||||
id: "speaker-id",
|
||||
virtualEarpiece: false,
|
||||
});
|
||||
|
||||
const mediaDevices = mockMediaDevices({
|
||||
audioOutput: {
|
||||
@@ -197,8 +268,7 @@ describe("InCallView", () => {
|
||||
|
||||
const { getByRole } = createInCallView({ mediaDevices });
|
||||
// The button should be visible. When current output is "speaker",
|
||||
// the switcher targets "earpiece", so the tooltip label is "Handset".
|
||||
const audioOutputBtn = getByRole("button", { name: "Handset" });
|
||||
const audioOutputBtn = getByRole("button", { name: "Loudspeaker" });
|
||||
expect(audioOutputBtn).toBeVisible();
|
||||
|
||||
await user.click(audioOutputBtn);
|
||||
|
||||
@@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { IconButton, Tooltip } from "@vector-im/compound-web";
|
||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
@@ -25,22 +24,8 @@ import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
VoiceCallSolidIcon,
|
||||
VolumeOnSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
import {
|
||||
EndCallButton,
|
||||
MicButton,
|
||||
VideoButton,
|
||||
ShareScreenButton,
|
||||
ReactionToggleButton,
|
||||
SettingsIconButton,
|
||||
} from "../button";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
@@ -55,7 +40,6 @@ import { useMergedRefs } from "../useMergedRefs";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
import {
|
||||
type CallViewModel,
|
||||
createCallViewModel$,
|
||||
@@ -106,6 +90,8 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t
|
||||
import { type Layout } from "../state/layout-types.ts";
|
||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { useLatest } from "../useLatest.ts";
|
||||
import { CallFooter } from "../components/CallFooter.tsx";
|
||||
import { SettingsIconButton } from "../button/Button.tsx";
|
||||
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
|
||||
@@ -185,7 +171,6 @@ export interface InCallViewProps {
|
||||
rtcSession: MatrixRTCSession;
|
||||
matrixRoom: MatrixRoom;
|
||||
muteStates: MuteStates;
|
||||
header: HeaderStyle;
|
||||
onShareClick: (() => void) | null;
|
||||
}
|
||||
|
||||
@@ -195,8 +180,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
matrixInfo,
|
||||
matrixRoom,
|
||||
muteStates,
|
||||
|
||||
header: headerStyle,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -220,7 +203,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// Merge the refs so they can attach to the same element
|
||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||
|
||||
const { showControls } = useUrlParams();
|
||||
const { showControls, header: headerStyle } = useUrlParams();
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
|
||||
@@ -244,10 +227,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
||||
|
||||
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||
useCallViewKeyboardShortcuts(
|
||||
containerRef1,
|
||||
toggleAudio,
|
||||
toggleVideo,
|
||||
setAudioEnabled,
|
||||
@@ -378,7 +358,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
let header: ReactNode = null;
|
||||
if (showHeader) {
|
||||
switch (headerStyle) {
|
||||
case "none":
|
||||
case HeaderStyle.AppBar: {
|
||||
// dont build a header here. The AppBar will take care of it.
|
||||
break;
|
||||
}
|
||||
case HeaderStyle.None:
|
||||
// Cosmetic header to fill out space while still affecting the bounds of
|
||||
// the grid
|
||||
header = (
|
||||
@@ -388,7 +372,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "standard":
|
||||
case HeaderStyle.Standard:
|
||||
header = (
|
||||
<Header
|
||||
className={styles.header}
|
||||
@@ -575,138 +559,46 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
matrixRoom.roomId,
|
||||
);
|
||||
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
const buttonSize = layout.type === "pip" ? "sm" : "lg";
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
enabled={audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
disabled={toggleAudio === null}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
enabled={videoEnabled}
|
||||
onClick={toggleVideo ?? undefined}
|
||||
disabled={toggleVideo === null}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
if (vm.toggleScreenSharing !== null) {
|
||||
buttons.push(
|
||||
<ShareScreenButton
|
||||
size={buttonSize}
|
||||
key="share_screen"
|
||||
className={styles.shareScreen}
|
||||
enabled={sharingScreen}
|
||||
onClick={vm.toggleScreenSharing}
|
||||
data-testid="incall_screenshare"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (supportsReactions) {
|
||||
buttons.push(
|
||||
<ReactionToggleButton
|
||||
size={buttonSize}
|
||||
vm={vm}
|
||||
key="raise_hand"
|
||||
className={styles.raiseHand}
|
||||
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// In this PR we just move the button ot the bottom bar. We do not yet update its apperance
|
||||
const audioOutputButton = useMemo(() => {
|
||||
if (audioOutputSwitcher === null) return null;
|
||||
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
|
||||
const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
|
||||
const label = isEarpieceTarget
|
||||
? t("settings.devices.handset")
|
||||
: t("settings.devices.loudspeaker");
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<IconButton
|
||||
key="audio_output_switcher"
|
||||
onClick={(e) => {
|
||||
audioOutputSwitcher.switch();
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [t, audioOutputSwitcher]);
|
||||
if (audioOutputButton) buttons.push(audioOutputButton);
|
||||
|
||||
const settingsButtonInAppBar =
|
||||
headerStyle === HeaderStyle.AppBar && showHeader;
|
||||
useAppBarSecondaryButton(
|
||||
<SettingsIconButton key="settings" onClick={openSettings} />,
|
||||
);
|
||||
|
||||
buttons.push(
|
||||
<EndCallButton
|
||||
size={buttonSize}
|
||||
key="end_call"
|
||||
onClick={function (): void {
|
||||
vm.hangup();
|
||||
}}
|
||||
data-testid="incall_leave"
|
||||
<SettingsIconButton
|
||||
key="settings"
|
||||
onClick={openSettings}
|
||||
data-testid="settings-app-bar"
|
||||
/>,
|
||||
);
|
||||
|
||||
const logo = (
|
||||
<div className={styles.logo}>
|
||||
<LogoMark width={24} height={24} aria-hidden />
|
||||
<LogoType
|
||||
width={80}
|
||||
height={11}
|
||||
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
|
||||
/>
|
||||
{/* Don't mind this odd placement, it's just a little debug label */}
|
||||
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Only hide the settings button if we have an AppBar header and we are showing the header
|
||||
const footer = (
|
||||
<div
|
||||
<CallFooter
|
||||
ref={footerRef}
|
||||
className={classNames(styles.footer, {
|
||||
[styles.overlay]: windowMode === "flat",
|
||||
[styles.hidden]:
|
||||
!showFooter || (!showControls && headerStyle === "none"),
|
||||
})}
|
||||
>
|
||||
<div className={styles.settingsLogoContainer}>
|
||||
{showControls &&
|
||||
// Settings button is also shown in the app bar if present
|
||||
headerStyle !== HeaderStyle.AppBar &&
|
||||
layout.type !== "pip" && (
|
||||
<SettingsIconButton
|
||||
kind="secondary"
|
||||
key="settings"
|
||||
onClick={openSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{headerStyle !== "none" && logo}
|
||||
</div>
|
||||
|
||||
{showControls && <div className={styles.buttons}>{buttons}</div>}
|
||||
{showControls && (
|
||||
<LayoutToggle
|
||||
className={styles.layout}
|
||||
layout={gridMode}
|
||||
setLayout={setGridMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
hidden={!showFooter}
|
||||
hideControls={!showControls}
|
||||
asOverlay={windowMode === "flat"}
|
||||
asPip={layout.type === "pip"}
|
||||
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
|
||||
hideLogo={headerStyle !== HeaderStyle.Standard}
|
||||
layoutMode={gridMode}
|
||||
setLayoutMode={setGridMode}
|
||||
audioEnabled={audioEnabled}
|
||||
toggleAudio={toggleAudio ?? undefined}
|
||||
videoEnabled={videoEnabled}
|
||||
toggleVideo={toggleVideo ?? undefined}
|
||||
sharingScreen={sharingScreen}
|
||||
toggleScreenSharing={vm.toggleScreenSharing ?? undefined}
|
||||
reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
||||
reactionData={supportsReactions ? vm : undefined}
|
||||
audioOutputSwitcher={audioOutputSwitcher ?? undefined}
|
||||
// Only pass the openSettings function if the settings button is not in the app bar.
|
||||
// If there is no fn the button will be hidden in the footer.
|
||||
openSettings={settingsButtonInAppBar ? undefined : openSettings}
|
||||
hangup={vm.hangup}
|
||||
//Debug props
|
||||
debugTileLayout={debugTileLayout}
|
||||
tileStoreGeneration={tileStoreGeneration}
|
||||
/>
|
||||
);
|
||||
|
||||
const allConnections = useBehavior(vm.allConnections$);
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,7 +33,7 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.toggle, className)}>
|
||||
<form className={classNames(styles.toggle, className)}>
|
||||
<Tooltip label={t("layout_spotlight_label")}>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -54,6 +54,6 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
<GridIcon aria-hidden width={24} height={24} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,12 +33,6 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import {
|
||||
EndCallButton,
|
||||
MicButton,
|
||||
SettingsIconButton,
|
||||
VideoButton,
|
||||
} from "../button/Button";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
@@ -52,6 +46,8 @@ import {
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { CallFooter } from "../components/CallFooter";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -96,6 +92,11 @@ export const LobbyView: FC<Props> = ({
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||
|
||||
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||
// Next to the keyboard shortcuts, this is also responsible for catching escape key presses and forwarding the to mobile -> pip.
|
||||
useCallViewKeyboardShortcuts(toggleAudio, toggleVideo, null, null, null);
|
||||
|
||||
const openSettings = useCallback(
|
||||
() => setSettingsModalOpen(true),
|
||||
[setSettingsModalOpen],
|
||||
@@ -214,7 +215,7 @@ export const LobbyView: FC<Props> = ({
|
||||
className={classNames(styles.join, {
|
||||
[styles.wait]: waitingForInvite,
|
||||
})}
|
||||
size={waitingForInvite ? "sm" : "lg"}
|
||||
size={waitingForInvite ? "md" : "lg"}
|
||||
disabled={waitingForInvite}
|
||||
onClick={() => {
|
||||
if (!waitingForInvite) onEnter();
|
||||
@@ -226,23 +227,18 @@ export const LobbyView: FC<Props> = ({
|
||||
</VideoPreview>
|
||||
{!recentsButtonInFooter && recentsButton}
|
||||
</div>
|
||||
<div className={inCallStyles.footer}>
|
||||
<CallFooter
|
||||
audioEnabled={audioEnabled}
|
||||
videoEnabled={videoEnabled}
|
||||
toggleAudio={toggleAudio ?? undefined}
|
||||
toggleVideo={toggleVideo ?? undefined}
|
||||
openSettings={openSettings}
|
||||
hangup={!confineToRoom ? onLeaveClick : undefined}
|
||||
// Logo and header are connected. We will only show the logo in SPA with header.
|
||||
hideLogo={hideHeader}
|
||||
>
|
||||
{recentsButtonInFooter && recentsButton}
|
||||
<SettingsIconButton kind="secondary" onClick={openSettings} />
|
||||
<div className={inCallStyles.buttons}>
|
||||
<MicButton
|
||||
enabled={audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
disabled={toggleAudio === null}
|
||||
/>
|
||||
<VideoButton
|
||||
enabled={videoEnabled}
|
||||
onClick={toggleVideo ?? undefined}
|
||||
disabled={toggleVideo === null}
|
||||
/>
|
||||
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
|
||||
</div>
|
||||
</div>
|
||||
</CallFooter>
|
||||
</div>
|
||||
{client && (
|
||||
<SettingsModal
|
||||
|
||||
@@ -132,7 +132,6 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,8 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.preview {
|
||||
margin-inline: var(--inline-content-inset);
|
||||
margin-left: var(--content-inset-left);
|
||||
margin-right: var(--content-inset-right);
|
||||
min-block-size: 0;
|
||||
block-size: 50vh;
|
||||
border-radius: var(--cpd-space-4x);
|
||||
@@ -80,6 +81,7 @@ video.mirror {
|
||||
}
|
||||
|
||||
.buttonBar {
|
||||
padding-inline: var(--inline-content-inset);
|
||||
padding-left: var(--content-inset-left);
|
||||
padding-right: var(--content-inset-right);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
||||
You were disconnected from the call.
|
||||
</p>
|
||||
<button
|
||||
class="_button_13vu4_8"
|
||||
class="_button_1nw83_8"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -143,7 +143,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
||||
Reconnect
|
||||
</button>
|
||||
<button
|
||||
class="_button_13vu4_8 homeLink"
|
||||
class="_button_1nw83_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -297,7 +297,7 @@ exports[`should have a close button in widget mode 1`] = `
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
|
||||
</p>
|
||||
<button
|
||||
class="_button_13vu4_8"
|
||||
class="_button_1nw83_8"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -451,7 +451,7 @@ exports[`should render the error page with link back to home 1`] = `
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
|
||||
</p>
|
||||
<button
|
||||
class="_button_13vu4_8 homeLink"
|
||||
class="_button_1nw83_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -605,7 +605,7 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
|
||||
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
|
||||
</p>
|
||||
<button
|
||||
class="_button_13vu4_8 homeLink"
|
||||
class="_button_1nw83_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -754,7 +754,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
||||
You were disconnected from the call.
|
||||
</p>
|
||||
<button
|
||||
class="_button_13vu4_8"
|
||||
class="_button_1nw83_8"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -763,7 +763,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
||||
Reconnect
|
||||
</button>
|
||||
<button
|
||||
class="_button_13vu4_8 homeLink"
|
||||
class="_button_1nw83_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -912,7 +912,7 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
|
||||
Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.
|
||||
</p>
|
||||
<button
|
||||
class="_button_13vu4_8 homeLink"
|
||||
class="_button_1nw83_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -1061,7 +1061,7 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
|
||||
The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.
|
||||
</p>
|
||||
<button
|
||||
class="_button_13vu4_8 homeLink"
|
||||
class="_button_1nw83_8 homeLink"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
|
||||
@@ -17,7 +17,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
<span
|
||||
aria-label=""
|
||||
class="_avatar_7h2br_8 roomAvatar _avatar-imageless_7h2br_55"
|
||||
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
|
||||
data-color="1"
|
||||
data-type="round"
|
||||
role="img"
|
||||
@@ -147,9 +147,9 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
Only works while using app
|
||||
</p>
|
||||
<button
|
||||
class="_button_13vu4_8"
|
||||
class="_button_1nw83_8"
|
||||
data-kind="primary"
|
||||
data-size="sm"
|
||||
data-size="md"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
@@ -170,8 +170,9 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_8_"
|
||||
class="_icon-button_1215g_8"
|
||||
class="_icon-button_1215g_8 settingsOnlyShowWide"
|
||||
data-kind="secondary"
|
||||
data-testid="settings-bottom-left"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
@@ -302,12 +303,34 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
<div
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_d_"
|
||||
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="settings-bottom-center"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_d_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
data-kind="secondary"
|
||||
aria-labelledby="_r_i_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="switch"
|
||||
@@ -329,9 +352,9 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-labelledby="_r_i_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
data-kind="secondary"
|
||||
aria-labelledby="_r_n_"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_videomute"
|
||||
role="switch"
|
||||
@@ -351,8 +374,8 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_n_"
|
||||
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
|
||||
aria-labelledby="_r_s_"
|
||||
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_leave"
|
||||
@@ -373,11 +396,11 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
<form
|
||||
class="toggle layout"
|
||||
>
|
||||
<input
|
||||
aria-labelledby="_r_s_"
|
||||
aria-labelledby="_r_11_"
|
||||
name="layout"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
@@ -395,7 +418,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby="_r_11_"
|
||||
aria-labelledby="_r_16_"
|
||||
checked=""
|
||||
name="layout"
|
||||
type="radio"
|
||||
@@ -413,7 +436,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,7 +204,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_7_"
|
||||
class="_control_sqdq4_10"
|
||||
class="_control_d83jn_10"
|
||||
id="radix-_r_6_"
|
||||
name="input"
|
||||
title=""
|
||||
|
||||
@@ -82,7 +82,7 @@ import { constant, type Behavior } from "../Behavior";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
||||
import { type MuteStates } from "../MuteStates";
|
||||
import { getUrlParams } from "../../UrlParams";
|
||||
import { getUrlParams, HeaderStyle } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { ElementWidgetActions, widget } from "../../widget";
|
||||
import {
|
||||
@@ -180,6 +180,8 @@ export interface CallViewModelOptions {
|
||||
connectionFactory?: ConnectionFactory;
|
||||
/** The version & compatibility mode of MatrixRTC that we should use. */
|
||||
matrixRTCMode$?: Behavior<MatrixRTCMode>;
|
||||
/** Optional behavior overriding for the screensharing, for testing */
|
||||
toggleScreensharing?: () => void;
|
||||
}
|
||||
|
||||
// Do not play any sounds if the participant count has exceeded this
|
||||
@@ -1326,7 +1328,11 @@ export function createCallViewModel$(
|
||||
windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")),
|
||||
);
|
||||
|
||||
const showFooter$ = scope.behavior<boolean>(
|
||||
const urlParams = getUrlParams();
|
||||
const showFooterUrlParams = !(
|
||||
urlParams.header === HeaderStyle.None && urlParams.showControls === false
|
||||
);
|
||||
const showFooterLayout$ = scope.behavior<boolean>(
|
||||
windowMode$.pipe(
|
||||
switchMap((mode) => {
|
||||
switch (mode) {
|
||||
@@ -1380,7 +1386,11 @@ export function createCallViewModel$(
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const showFooter$ = scope.behavior(
|
||||
showFooterLayout$.pipe(
|
||||
map((showFooter) => showFooter && showFooterUrlParams),
|
||||
),
|
||||
);
|
||||
/**
|
||||
* Whether audio is currently being output through the earpiece.
|
||||
*/
|
||||
@@ -1507,7 +1517,8 @@ export function createCallViewModel$(
|
||||
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
||||
*/
|
||||
// reassigned here to make it publicly accessible
|
||||
const toggleScreenSharing = localMembership.toggleScreenSharing;
|
||||
const toggleScreenSharing =
|
||||
options.toggleScreensharing ?? localMembership.toggleScreenSharing;
|
||||
|
||||
const errors$ = scope.behavior<{
|
||||
transportError?: ElementCallError;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user