mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
97 Commits
v0.21.0
...
valere/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bc4a096b | ||
|
|
bfb5fb922f | ||
|
|
3f4d55343a | ||
|
|
73d8d2c2c3 | ||
|
|
fcfe73c50d | ||
|
|
5564015a8b | ||
|
|
62eeb10d38 | ||
|
|
2de7fdde10 | ||
|
|
5a96d1769e | ||
|
|
a443a55bec | ||
|
|
9b2d9fdd1f | ||
|
|
05ad9b2475 | ||
|
|
7962d02be5 | ||
|
|
5c35288162 | ||
|
|
aa6732da19 | ||
|
|
65ac11e952 | ||
|
|
0b21cff020 | ||
|
|
062e0a00fb | ||
|
|
53ae78c609 | ||
|
|
b046c4b569 | ||
|
|
46cb89b232 | ||
|
|
e20abb19fb | ||
|
|
bf76f47357 | ||
|
|
9c2d3d067d | ||
|
|
ae7ede3201 | ||
|
|
e5ff431b7a | ||
|
|
66a676f2f5 | ||
|
|
d33dedd038 | ||
|
|
1f2837f86a | ||
|
|
927cf68110 | ||
|
|
cd72ad0008 | ||
|
|
2d2e85e340 | ||
|
|
4da1b9a02a | ||
|
|
73758f7f77 | ||
|
|
c433c36687 | ||
|
|
3e9d7b7cf7 | ||
|
|
2a56d2afb8 | ||
|
|
bff41ad66a | ||
|
|
1d088911e1 | ||
|
|
f23eb96555 | ||
|
|
0d4e7defb6 | ||
|
|
023aec22ca | ||
|
|
bf4cee9898 | ||
|
|
147ff131f4 | ||
|
|
3e5edd03a6 | ||
|
|
0afefa4889 | ||
|
|
49a762832b | ||
|
|
3cf5cab1d2 | ||
|
|
5968554e4c | ||
|
|
8d826b2f31 | ||
|
|
32e30dd4f5 | ||
|
|
bb06905aa2 | ||
|
|
2684fa1d62 | ||
|
|
de75a69cad | ||
|
|
f2c42c314c | ||
|
|
4b50a191ba | ||
|
|
11fadebb87 | ||
|
|
943ce5b853 | ||
|
|
137093350b | ||
|
|
000ee66cfe | ||
|
|
58a8b7316e | ||
|
|
1e6d3d3357 | ||
|
|
9f681b87a0 | ||
|
|
dc21233767 | ||
|
|
27315a7090 | ||
|
|
bebe79d109 | ||
|
|
4ca0270115 | ||
|
|
374f56651c | ||
|
|
eefa6f843d | ||
|
|
2b84c587f2 | ||
|
|
706cddafb0 | ||
|
|
a6921e2bfb | ||
|
|
3cc396f42c | ||
|
|
59999b9b37 | ||
|
|
e68321e16c | ||
|
|
ad76969ce2 | ||
|
|
c170aad364 | ||
|
|
9e8bebc3bf | ||
|
|
d5cdbfd4d1 | ||
|
|
1811b36205 | ||
|
|
7840f4bccb | ||
|
|
9199dcb583 | ||
|
|
cbc4b77a4f | ||
|
|
ffb881436a | ||
|
|
9cf87efc6b | ||
|
|
b71454d63a | ||
|
|
b0feb33ec4 | ||
|
|
900d0abd27 | ||
|
|
a3d3a1e951 | ||
|
|
8fa3f33f37 | ||
|
|
360a4ff026 | ||
|
|
8c6a16f3d7 | ||
|
|
9d21581259 | ||
|
|
3837576a5f | ||
|
|
db2d20ac56 | ||
|
|
c4fe0223ab | ||
|
|
9a2fc883b6 |
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
container:
|
||||
# Make sure to grab the latest version of the Playwright image
|
||||
# https://playwright.dev/docs/docker#pull-the-image
|
||||
image: mcr.microsoft.com/playwright:v1.60.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
@@ -56,8 +56,8 @@ jobs:
|
||||
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
|
||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml pull
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml up -d
|
||||
docker ps
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
|
||||
7
.github/workflows/translations-download.yaml
vendored
7
.github/workflows/translations-download.yaml
vendored
@@ -40,6 +40,11 @@ jobs:
|
||||
- name: Fix the owner of the downloaded files
|
||||
run: "sudo chown runner:docker -R locales"
|
||||
|
||||
# Localazy doesn't write file contents in the same order that i18next-cli's
|
||||
# extractor uses. We re-run the extractor after downloading to fix the order.
|
||||
- name: i18n
|
||||
run: pnpm i18n
|
||||
|
||||
- name: Formatting
|
||||
run: pnpm format
|
||||
|
||||
@@ -53,7 +58,7 @@ jobs:
|
||||
title: Localazy Download
|
||||
commit-message: Translations updates
|
||||
labels: |
|
||||
T-Task
|
||||
PR-Task
|
||||
|
||||
- name: Enable automerge
|
||||
run: gh pr merge --merge --auto "$PR_NUMBER"
|
||||
|
||||
4
.github/workflows/zizmor.yml
vendored
4
.github/workflows/zizmor.yml
vendored
@@ -15,9 +15,9 @@ jobs:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"/*\nCopyright %%CURRENT_YEAR%% Element Creations Ltd.\n\nSPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial\nPlease see LICENSE in the repository root for full details.\n*/\n\n"
|
||||
],
|
||||
"element-call/no-observablescope-leak": "error",
|
||||
"element-call/no-top-level-logger-get-child": "error",
|
||||
"jsdoc/empty-tags": "error",
|
||||
"jsdoc/check-property-names": "error",
|
||||
"jsdoc/require-param-description": "warn",
|
||||
|
||||
@@ -212,7 +212,7 @@ See also:
|
||||
|
||||
### Backend
|
||||
|
||||
A docker compose file `dev-backend-docker-compose.yml` is provided to start the
|
||||
A docker compose file `docker-compose-dev.yml` is provided to start the
|
||||
whole stack of components which is required for a local development environment
|
||||
including federation:
|
||||
|
||||
@@ -239,7 +239,7 @@ the backend components:
|
||||
```sh
|
||||
pnpm backend
|
||||
# or for podman-compose:
|
||||
# podman-compose -f dev-backend-docker-compose.yml up
|
||||
# podman-compose -f docker-compose-dev.yml up
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -156,7 +156,7 @@ services:
|
||||
nginx:
|
||||
# see backend/dev_tls_setup for how to generate the tls certs
|
||||
hostname: synapse.m.localhost
|
||||
image: nginx:latest
|
||||
image: nginx:latest@sha256:4ae259ae64fbedb67918c07d167fdcb0e05855a1615480ca445bea485e7d65ff
|
||||
pull_policy: always
|
||||
volumes:
|
||||
- ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z
|
||||
19
docker-compose-playwright.yml
Normal file
19
docker-compose-playwright.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
# This file contains overrides to docker-compose-dev.yml and should
|
||||
# only be used in combination with that file.
|
||||
services:
|
||||
synapse:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
synapse-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:d1841828a390d96da2ee64232949db641f11766fb506ec561374819d10533b9a
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:d1841828a390d96da2ee64232949db641f11766fb506ec561374819d10533b9a
|
||||
@@ -67,6 +67,7 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `background` | One of: `solid`, `gradient` | No, defaults to `gradient` | No, defaults to `gradient` | Visual style of the page background. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
| `autoLeave` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
|
||||
@@ -9,7 +9,21 @@ import { ESLintUtils } from "@typescript-eslint/utils";
|
||||
|
||||
// These ObservableScope methods will not generally cause resource leaks even if
|
||||
// called from a callback
|
||||
const safeScopeMethods = ["bind", "end"];
|
||||
const safeScopeMethods = new Set(["bind", "end"]);
|
||||
|
||||
/**
|
||||
* Determines whether the variable with the given name is local to
|
||||
* the enclosing function or class scope.
|
||||
*/
|
||||
function isLocal(name, scope) {
|
||||
// If it is nowhere to be found in the "through" scope, it is local.
|
||||
if (!scope.through.some(({ identifier }) => identifier.name === name))
|
||||
return true;
|
||||
if (scope.type === "function" || scope.type === "class") return false;
|
||||
// If this is something other than a function or class scope, check its outer
|
||||
// scope.
|
||||
return !scope.upper || isLocal(name, scope.upper);
|
||||
}
|
||||
|
||||
const rule = ESLintUtils.RuleCreator(
|
||||
() => "https://github.com/element-hq/element-call",
|
||||
@@ -32,15 +46,13 @@ const rule = ESLintUtils.RuleCreator(
|
||||
Identifier(node) {
|
||||
const scope = context.sourceCode.getScope(node);
|
||||
if (
|
||||
// Is this a reference to a variable defined in an outer ("through") scope?
|
||||
scope.through.some(
|
||||
({ identifier }) => identifier.name === node.name,
|
||||
) &&
|
||||
// Is this a reference to a variable defined in an outer scope?
|
||||
!isLocal(node.name, scope) &&
|
||||
// Exclude calls to "safe" ObservableScope methods
|
||||
node.parent?.type === "MemberExpression" &&
|
||||
node.parent.object === node &&
|
||||
node.parent.property.type === "Identifier" &&
|
||||
!safeScopeMethods.includes(node.parent.property.name) &&
|
||||
!safeScopeMethods.has(node.parent.property.name) &&
|
||||
/(^s|S)cope$/.test(node.name)
|
||||
) {
|
||||
// TODO: Once oxlint supports lint rules that rely on TypeScript type-awareness,
|
||||
|
||||
92
eslint/NoTopLevelLoggerGetChild.js
Normal file
92
eslint/NoTopLevelLoggerGetChild.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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 { ESLintUtils } from "@typescript-eslint/utils";
|
||||
|
||||
/**
|
||||
* Node types that introduce a new non-module scope. A getChild() call nested
|
||||
* inside any of these is considered "not at the top level".
|
||||
*/
|
||||
const FUNCTION_OR_CLASS_TYPES = new Set([
|
||||
"FunctionDeclaration",
|
||||
"FunctionExpression",
|
||||
"ArrowFunctionExpression",
|
||||
"ClassBody",
|
||||
]);
|
||||
|
||||
const rule = ESLintUtils.RuleCreator(
|
||||
() => "https://github.com/element-hq/element-call",
|
||||
)({
|
||||
name: "no-top-level-logger-get-child",
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow calling logger.getChild() at the top level of a module." +
|
||||
"`getChild` has to be called after the rageshake logger `init()`." +
|
||||
"If it is called at the top level the child logger will never be setup for rageshakes.",
|
||||
},
|
||||
messages: {
|
||||
noTopLevelGetChild:
|
||||
"Do not call logger.getChild() at the top level of a module; move it inside a function or class instead that gets called after rageshake logger `init()` is called.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
// Tracks the local binding names that refer to the logger imported from
|
||||
// 'matrix-js-sdk/lib/logger', e.g. both `logger` and `rootLogger` in:
|
||||
// import { logger } from "matrix-js-sdk/lib/logger";
|
||||
// import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
const loggerNames = new Set();
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value !== "matrix-js-sdk/lib/logger") return;
|
||||
for (const specifier of node.specifiers) {
|
||||
if (
|
||||
specifier.type === "ImportSpecifier" &&
|
||||
specifier.imported.name === "logger"
|
||||
) {
|
||||
loggerNames.add(specifier.local.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
CallExpression(node) {
|
||||
// Must be a non-computed member expression call: something.getChild(...)
|
||||
if (
|
||||
node.callee.type !== "MemberExpression" ||
|
||||
node.callee.computed ||
|
||||
node.callee.property.type !== "Identifier" ||
|
||||
node.callee.property.name !== "getChild"
|
||||
)
|
||||
return;
|
||||
|
||||
// The receiver must be one of the tracked logger names.
|
||||
const object = node.callee.object;
|
||||
if (object.type !== "Identifier" || !loggerNames.has(object.name))
|
||||
return;
|
||||
|
||||
// Flag the call only when it is at module top level — i.e. there is no
|
||||
// enclosing function or class body anywhere in the ancestor chain.
|
||||
const ancestors = context.sourceCode.getAncestors(node);
|
||||
const isTopLevel = !ancestors.some((a) =>
|
||||
FUNCTION_OR_CLASS_TYPES.has(a.type),
|
||||
);
|
||||
|
||||
if (isTopLevel) {
|
||||
context.report({
|
||||
messageId: "noTopLevelGetChild",
|
||||
node,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default rule;
|
||||
@@ -2,5 +2,7 @@ module.exports = {
|
||||
rules: {
|
||||
"copyright-header": require("./CopyrightHeader").default,
|
||||
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
|
||||
"no-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
|
||||
.default,
|
||||
},
|
||||
};
|
||||
|
||||
7
knip.ts
7
knip.ts
@@ -20,7 +20,12 @@ export default {
|
||||
// This is a shell built-in.
|
||||
"printf",
|
||||
],
|
||||
ignoreFiles: ["scripts/.pnpmfile.cjs"],
|
||||
ignoreFiles: [
|
||||
"scripts/.pnpmfile.cjs",
|
||||
// Deliberately added prior to any component or business logic
|
||||
// implementation
|
||||
"src/state/ServiceInterruptionsViewModel.ts",
|
||||
],
|
||||
ignoreDependencies: [
|
||||
// Used in CSS
|
||||
"normalize.css",
|
||||
|
||||
@@ -102,11 +102,15 @@
|
||||
"connection_lost_description": "Hovor byl přerušen.",
|
||||
"e2ee_unsupported": "Nekompatibilní prohlížeč",
|
||||
"e2ee_unsupported_description": "Váš webový prohlížeč nepodporuje šifrované hovory. Mezi podporované prohlížeče patří Chrome, Safari a Firefox 117+.",
|
||||
"failed_to_start_livekit": "Nepodařilo se navázat připojení k Livekitu",
|
||||
"generic": "Něco se pokazilo.",
|
||||
"generic_description": "Odeslání protokolů ladění nám pomůže vystopovat problém.",
|
||||
"insufficient_capacity": "Nedostatečná kapacita",
|
||||
"insufficient_capacity_description": "Server dosáhl své maximální kapacity a v tuto chvíli se nemůžete připojit k hovoru. Zkuste to později nebo se obraťte na správce serveru, pokud problém přetrvává.",
|
||||
"matrix_rtc_transport_missing": "Server není nakonfigurován pro práci s {{brand}}. Obraťte se na správce serveru (Doména: {{domain}}, Kód chyby: {{ errorCode }}).",
|
||||
"membership_manager": "Chyba Správce členství",
|
||||
"membership_manager_description": "Správce členství musel být ukončen. To je způsobeno mnoha po sobě jdoucími neúspěšnými síťovými požadavky.",
|
||||
"no_matrix_2_authorization_service": "Autorizační služba vašeho mediálního serveru (SFU) je zastaralá.",
|
||||
"open_elsewhere": "Otevřeno na jiné kartě",
|
||||
"open_elsewhere_description": "{{brand}} byl otevřen v jiné záložce. Pokud to nezní správně, zkuste stránku znovu načíst.",
|
||||
"room_creation_restricted": "Nepodařilo se vytvořit hovor",
|
||||
@@ -141,6 +145,7 @@
|
||||
},
|
||||
"layout_grid_label": "Mřížka",
|
||||
"layout_spotlight_label": "Soustředěný mód",
|
||||
"layout_switch_label": "Rozvržení",
|
||||
"lobby": {
|
||||
"ask_to_join": "Žádost o připojení k hovoru",
|
||||
"join_as_guest": "Připojte se jako host",
|
||||
@@ -241,11 +246,14 @@
|
||||
"version": "{{productName}}verze: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Vždy zobrazit",
|
||||
"call_ended": "Hovor ukončen",
|
||||
"calling": "Volání…",
|
||||
"camera_starting": "Načítání videa...",
|
||||
"collapse": "Sbalit",
|
||||
"expand": "Rozbalit",
|
||||
"mute_for_me": "Pro mě ztlumit",
|
||||
"muted_for_me": "Pro mě ztlumené",
|
||||
"screen_share_volume": "Hlasitost sdílení obrazovky",
|
||||
"volume": "Hlasitost",
|
||||
"waiting_for_media": "Čekání na média..."
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
|
||||
"developer_tab_title": "Developer",
|
||||
"devices": {
|
||||
"activating": "Activating…",
|
||||
"camera": "Camera",
|
||||
"camera_numbered": "Camera {{n}}",
|
||||
"change_device_button": "Change audio device",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"user_menu": "Kasutajamenüü"
|
||||
},
|
||||
"action": {
|
||||
"blur_background": "Hägusta tausta",
|
||||
"close": "Sulge",
|
||||
"copy_link": "Kopeeri link",
|
||||
"edit": "Muuda",
|
||||
@@ -72,8 +73,20 @@
|
||||
"environment_variables": "Keskkonnamuutujad",
|
||||
"hostname": "Hosti nimi: {{hostname}}",
|
||||
"livekit_server_info": "LiveKiti serveri teave",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"livekit_sfu": "LiveKiti meediaedastusserver (SFU): {{url}}",
|
||||
"matrixRTCMode": {
|
||||
"Comptibility": {
|
||||
"description": "Ühildub koduserveritega, mis ei toeta määratud kestusega sündmuseid (kuid kõik teised EC kliendid on v0.17.0 või hilisemad)",
|
||||
"label": "Ühilduvus: olekusündmused ja mitu meediaedastusserverit (SFU)"
|
||||
},
|
||||
"Legacy": {
|
||||
"description": "Ühildub EC vanemate versioonidega, millel puudub mitme meediaedastusserveri (SFU) tugi",
|
||||
"label": "Vana lahendus: oleku üritused ja vanim meediaedastusserver (SFU)"
|
||||
},
|
||||
"Matrix_2_0": {
|
||||
"description": "Ühildub ainult määratud kestusega sündmuseid toetavate koduserveritega ja kõigi EC-klientidega alates versioonist 0.17.0",
|
||||
"label": "Matrix 2.0: määratud kestusega sündmused ja mitu meediaedastusserverit (SFU)"
|
||||
},
|
||||
"title": "MatrixRTC režiim"
|
||||
},
|
||||
"matrix_id": "Matrixi kasutajatunnus: {{id}}",
|
||||
@@ -95,13 +108,20 @@
|
||||
"generic_description": "Silumis- ja vealogide saatmine võib aidata meid vea põhjuseni jõuda.",
|
||||
"insufficient_capacity": "Mittepiisav jõudlus",
|
||||
"insufficient_capacity_description": "Serveri jõudluse ülempiir on hetkel ületatud ja sa ei saa hetkel selle kõnega liituda. Proovi hiljem uuesti või kui probleem kestab kauem, siis võta ühendust serveri haldajaga.",
|
||||
"livekit_connection_error": "Livekiti serveriga ühendamine ei õnnestunud",
|
||||
"livekit_connection_error_description": "Livekiti serveriga ühendamisel tekkis viga (<1>Põhjus: </1> <2>{{ reason }}</2>).",
|
||||
"matrix_rtc_transport_missing": "See server pole seadistatud töötama rakendusega {{brand}}. Palun võta ühendust serveri halduriga (domeen: {{domain}}, veakood: {{ errorCode }}).",
|
||||
"membership_manager": "Viga liikmelisuse haldamisel",
|
||||
"membership_manager_description": "Liikmelisuse haldur pidi oma töö lõpetama. Selle põhjuseks olid paljud järjestikused ebaõnnestunud võrgupäringud.",
|
||||
"no_matrix_2_authorization_service": "Sinu meediaedastusserveri (SFU) autoriseerimisteenus on aegunud.",
|
||||
"open_elsewhere": "Avatud teisel vahekaardil",
|
||||
"open_elsewhere_description": "{{brand}} on avatud teisel vahekaardil. Kui see ei tundu olema õige, proovi selle lehe uuesti laadimist.",
|
||||
"peer_connection_timeout": "Ühendus aegus",
|
||||
"peer_connection_timeout_description": "Meediaserveriga ühenduspäring aegus. Proovi kasutada muud võrku või keelata VPN-i kasutamine. Kui probleem püsib, vaata meie <0> veaotsingu juhendit</0> või võta ühendust oma serveri peakasutajaga.",
|
||||
"room_creation_restricted": "Kõne loomine ei õnnestunud",
|
||||
"room_creation_restricted_description": "Kõne loomine võib olla lubatud ainult volitatud kasutajatele. Proovi hiljem uuesti või probleemi püsimisel võta ühendust oma serveri haldajaga.",
|
||||
"sticky_events_required": "Koduserver ei toeta Matrix 2.0 kõnesid",
|
||||
"sticky_events_required_description": "See server on seadistatud kasutama Matrix 2.0 kõnerežiimi, kuid koduserver ei teata, et tal selleks vajalike sündmuste (MSC4354) tugi. Palu serveri peakasutajal see uuendada või kasuta koduserveris ühilduvat režiimi.",
|
||||
"unexpected_ec_error": "Tekkis ootamatu viga (<0>Veakood:</0> <1>{{ errorCode }}</1>). Palun võta ühendust serveri haldajaga."
|
||||
},
|
||||
"group_call_loader": {
|
||||
@@ -132,6 +152,7 @@
|
||||
},
|
||||
"layout_grid_label": "Ruudustik",
|
||||
"layout_spotlight_label": "Rambivalgus",
|
||||
"layout_switch_label": "Paigutus",
|
||||
"lobby": {
|
||||
"ask_to_join": "Küsi võimalust liituda kõnega",
|
||||
"join_as_guest": "Liitu külalisena",
|
||||
@@ -184,6 +205,7 @@
|
||||
"blur_not_supported_by_browser": "(Tausta hägustamine pole selles seadmes toetatud.)",
|
||||
"developer_tab_title": "Arendaja",
|
||||
"devices": {
|
||||
"activating": "Aktiveerin…",
|
||||
"camera": "Kaamera",
|
||||
"camera_numbered": "Kaamera {{n}}",
|
||||
"change_device_button": "Muuda heliseadet",
|
||||
@@ -223,6 +245,7 @@
|
||||
"stop_video_button_label": "Peata videovoog",
|
||||
"submitting": "Saadan…",
|
||||
"switch_camera": "Vaheta kaamerat",
|
||||
"technical_details": "Tehnilised üksikasjad",
|
||||
"unauthenticated_view_body": "Sa pole veel registreerunud? <2>Loo kasutajakonto</2>",
|
||||
"unauthenticated_view_login_button": "Logi oma kontosse sisse",
|
||||
"unauthenticated_view_ssla_caption": "Klõpsides „Jätka“ nõustud sa meie <2>Tarkvara ja teenuste litsentseerimise lepinguga (Software and Services License Agreement - SSLA)</2>",
|
||||
@@ -230,11 +253,14 @@
|
||||
"version": "{{productName}}, versioon: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Näita alati",
|
||||
"call_ended": "Kõne lõppes",
|
||||
"calling": "Helistan…",
|
||||
"camera_starting": "Video on laadimisel...",
|
||||
"collapse": "Näita vähem",
|
||||
"expand": "Näita rohkem",
|
||||
"mute_for_me": "Summuta minu jaoks",
|
||||
"muted_for_me": "Minule summutatud",
|
||||
"screen_share_volume": "Helivaljus ekraanijagaamisel",
|
||||
"volume": "Helivaljus",
|
||||
"waiting_for_media": "Ootame kuni meedia on olemas..."
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"user_menu": "Käyttäjävalikko"
|
||||
},
|
||||
"action": {
|
||||
"blur_background": "Sumenna tausta",
|
||||
"close": "Sulje",
|
||||
"copy_link": "Kopioi linkki",
|
||||
"edit": "Muokkaa",
|
||||
@@ -107,14 +108,20 @@
|
||||
"generic_description": "Vianmäärityslokien lähettäminen auttaa meitä jäljittämään ongelman.",
|
||||
"insufficient_capacity": "Riittämätön kapasiteetti",
|
||||
"insufficient_capacity_description": "Palvelin on saavuttanut maksimikapasiteettinsa, etkä voi liittyä puheluun tällä hetkellä. Yritä myöhemmin uudelleen tai ota yhteyttä palvelimen ylläpitäjään, jos ongelma jatkuu.",
|
||||
"livekit_connection_error": "Yhteyden muodostaminen Livekit-palvelimeen epäonnistui",
|
||||
"livekit_connection_error_description": "Livekit-palvelimeen yhteyden muodostamisessa tapahtui virhe (<1>Syy: </1> <2>{{ reason }}</2>).",
|
||||
"matrix_rtc_transport_missing": "Palvelinta ei ole määritetty toimimaan {{brand}} -sovelluksen kanssa. Ota yhteyttä palvelimen ylläpitäjään (Verkkotunnus: {{domain}}, Virhekoodi: {{ errorCode }}).",
|
||||
"membership_manager": "Jäsenyydenhallinnan virhe",
|
||||
"membership_manager_description": "Jäsenyyshallinta jouduttiin sulkemaan. Tämä johtui useista peräkkäisistä epäonnistuneista verkkopyynnöistä.",
|
||||
"no_matrix_2_authorization_service": "Mediapalvelimesi (SFU) valtuutuspalvelu on vanhentunut.",
|
||||
"open_elsewhere": "Avattu toisessa välilehdessä",
|
||||
"open_elsewhere_description": "{{brand}} on avattu toisessa välilehdessä. Jos tämä ei kuulosta oikealta, yritä ladata sivu uudelleen.",
|
||||
"peer_connection_timeout": "Yhteyden aikakatkaisu",
|
||||
"peer_connection_timeout_description": "Yhteys mediapalvelimeen aikakatkaistiin. Kokeile vaihtaa verkkoa tai poistaa VPN käytöstä. Jos ongelma jatkuu, katso <0>vianetsintäoppaamme</0> tai ota yhteyttä palvelimesi ylläpitäjään.",
|
||||
"room_creation_restricted": "Puhelun luominen epäonnistui",
|
||||
"room_creation_restricted_description": "Puheluiden luominen saattaa olla rajoitettu vain valtuutetuille käyttäjille. Yritä myöhemmin uudelleen tai ota yhteyttä palvelimen ylläpitäjään, jos ongelma jatkuu.",
|
||||
"sticky_events_required": "Kotipalvelin ei tue Matrix 2.0 -puheluita",
|
||||
"sticky_events_required_description": "Tämä asennus on määritetty käyttämään Matrix 2.0 -puhelutilaa, mutta kotipalvelin ei mainosta tukea tarttuville tapahtumille (MSC4354). Pyydä palvelimen järjestelmänvalvojaa päivittämään tai vaihtamaan asennus yhteensopivaan tilaan.",
|
||||
"unexpected_ec_error": "Tapahtui odottamaton virhe (<0>Virhekoodi:</0> <1>{{ errorCode }}</1>). Ota yhteyttä palvelimen ylläpitäjään."
|
||||
},
|
||||
"group_call_loader": {
|
||||
@@ -145,6 +152,7 @@
|
||||
},
|
||||
"layout_grid_label": "Ruudukko",
|
||||
"layout_spotlight_label": "Valokeila",
|
||||
"layout_switch_label": "Asettelu",
|
||||
"lobby": {
|
||||
"ask_to_join": "Pyydä liittymistä puheluun",
|
||||
"join_as_guest": "Liity vieraana",
|
||||
@@ -197,6 +205,7 @@
|
||||
"blur_not_supported_by_browser": "(Tämä laite ei tue taustan sumennusta.)",
|
||||
"developer_tab_title": "Kehittäjä",
|
||||
"devices": {
|
||||
"activating": "Aktivoidaan…",
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"change_device_button": "Vaihda äänilaite",
|
||||
@@ -236,6 +245,7 @@
|
||||
"stop_video_button_label": "Lopeta video",
|
||||
"submitting": "Lähetetään…",
|
||||
"switch_camera": "Vaihda kameraa",
|
||||
"technical_details": "Tekniset tiedot",
|
||||
"unauthenticated_view_body": "Etkö ole vielä rekisteröitynyt? <2>Luo tili</2>",
|
||||
"unauthenticated_view_login_button": "Kirjaudu tilillesi",
|
||||
"unauthenticated_view_ssla_caption": "Klikkaamalla \"Siirry\" hyväksyt <2>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</2>",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"user_menu": "Menu użytkownika"
|
||||
},
|
||||
"action": {
|
||||
"blur_background": "Rozmycie tła",
|
||||
"close": "Zamknij",
|
||||
"copy_link": "Kopiuj link",
|
||||
"edit": "Edytuj",
|
||||
@@ -21,7 +22,7 @@
|
||||
"submit": "Wyślij",
|
||||
"upload_file": "Prześlij plik"
|
||||
},
|
||||
"analytics_notice": "Uczestnicząc w tej becie, upoważniasz nas do zbierania anonimowych danych, które wykorzystamy do ulepszenia produktu. Dowiedz się więcej na temat danych, które zbieramy w naszej <2>Polityce prywatności</2> i <5>Polityce ciasteczek</5>.",
|
||||
"analytics_notice": "Biorąc udział w wersji beta, wyrażasz zgodę na gromadzenie anonimowych danych, które wykorzystujemy w celu ulepszenia naszego produktu. Więcej informacji na temat danych, które śledzimy, znajdziesz w naszej <2>Polityce prywatności</2> dla i <6>Polityce ciasteczek</6>.",
|
||||
"call_ended_view": {
|
||||
"create_account_button": "Utwórz konto",
|
||||
"create_account_prompt": "<0>Może zechcesz ustawić hasło, aby zachować swoje konto?</0><1>Będziesz w stanie utrzymać swoją nazwę i ustawić awatar do wyświetlania podczas połączeń w przyszłości</1>",
|
||||
@@ -41,7 +42,7 @@
|
||||
"display_name": "Nazwa wyświetlana",
|
||||
"encrypted": "Szyfrowane",
|
||||
"home": "Strona domowa",
|
||||
"loading": "Ładowanie…",
|
||||
"loading": "Wczytywanie…",
|
||||
"next": "Dalej",
|
||||
"options": "Opcje",
|
||||
"password": "Hasło",
|
||||
@@ -58,6 +59,14 @@
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Pokaż opcję słuchawki iPhone na wszystkich platformach",
|
||||
"crypto_version": "Wersja krypto: {{version}}",
|
||||
"custom_livekit_url": {
|
||||
"current_url": "Aktualnie ustawione na: ",
|
||||
"from_config": "Aktualnie nie nadpisano ustawienia. Używany jest adres URL z dobrego źródła lub konfiguracji.",
|
||||
"label": "Własny Livekit-url",
|
||||
"reset": "Zresetuj nadpisanie",
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisuję..."
|
||||
},
|
||||
"debug_tile_layout_label": "Układ kafelków Debug",
|
||||
"device_id": "ID urządzenia: {{id}}",
|
||||
"duplicate_tiles_label": "Liczba dodatkowych kopii kafelków na uczestnika",
|
||||
@@ -65,6 +74,21 @@
|
||||
"hostname": "Nazwa hosta: {{hostname}}",
|
||||
"livekit_server_info": "Informacje serwera LiveKit",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrixRTCMode": {
|
||||
"Comptibility": {
|
||||
"description": "Kompatybilny z serwerami domowymi, które nie obsługują sticky events (ale wszyscy inni klienci EC mają wersję v0.17.0 lub nowszą)",
|
||||
"label": "Kompatybilność: state events & multi SFU"
|
||||
},
|
||||
"Legacy": {
|
||||
"description": "Kompatybilny ze starszymi wersjami EC, które nie wspierają multi SFU",
|
||||
"label": "Przestarzałe: state events & oldest membership SFU"
|
||||
},
|
||||
"Matrix_2_0": {
|
||||
"description": "Kompatybilny tylko z serwerami domowymi obsługującymi sticky events i wszystkimi klientami EC w wersji v0.17.0 lub nowszej",
|
||||
"label": "Matrix 2.0: sticky events & multi SFU"
|
||||
},
|
||||
"title": "Tryb MatrixRTC"
|
||||
},
|
||||
"matrix_id": "ID Matrix: {{id}}",
|
||||
"mute_all_audio": "Wycisz cały dźwięk (uczestnicy, reakcje, dźwięki dołączenia)",
|
||||
"show_connection_stats": "Pokaż statystyki połączenia",
|
||||
@@ -74,25 +98,36 @@
|
||||
"error": {
|
||||
"call_is_not_supported": "Połączenie nie jest obsługiwane",
|
||||
"call_not_found": "Nie znaleziono połączenia",
|
||||
"call_not_found_description": "<0>Wygląda na to, że ten link nie należy do żadnego istniejącego połączenia. Sprawdź, czy posiadasz prawidłowy link lub <1>utwórz nowy</1>.</0>",
|
||||
"call_not_found_description": "<0>Wygląda na to, że ten link nie należy do żadnego istniejącego połączenia. Sprawdź, czy masz prawidłowy link lub <2> utwórz nowy</2>.</0>",
|
||||
"connection_lost": "Utracono połączenie",
|
||||
"connection_lost_description": "Zostałeś rozłączony z rozmowy.",
|
||||
"e2ee_unsupported": "Niekompatybilna przeglądarka",
|
||||
"e2ee_unsupported_description": "Twoja przeglądarka nie wspiera szyfrowanych połączeń. Obsługiwane przeglądarki obejmują Chrome, Safari i Firefox 117+.",
|
||||
"failed_to_start_livekit": "Nie udało się uruchomić połączenia Livekit",
|
||||
"generic": "Coś poszło nie tak",
|
||||
"generic_description": "Wysłanie dziennika debug, pozwoli nam namierzyć problem.",
|
||||
"insufficient_capacity": "Za mało miejsca",
|
||||
"insufficient_capacity_description": "Serwer osiągnął maksymalną pojemność, przez co nie możesz dołączyć do połączenia. Spróbuj ponownie później lub skontaktuj się z administratorem serwera, jeśli problem nie zniknie.",
|
||||
"livekit_connection_error": "Nie udało się połączyć z serwerem Livekit",
|
||||
"livekit_connection_error_description": "Wystąpił błąd podczas łączenia się z serwerem Livekit (<1>Przyczyna:</1> <2>{{ reason }}</2>).",
|
||||
"matrix_rtc_transport_missing": "Serwer nie jest skonfigurowany do pracy z {{brand}}. Prosimy o kontakt z administratorem serwera (Domena: {{domain}}, Kod błędu: {{ errorCode }}).",
|
||||
"membership_manager": "Błąd menedżera członkostwa",
|
||||
"membership_manager_description": "Menedżer członkostwa został zamknięty przez zbyt wiele powtarzających się błędów połączenia.",
|
||||
"no_matrix_2_authorization_service": "Usługa autoryzacji dla serwera multimediów (SFU) jest nieaktualna.",
|
||||
"open_elsewhere": "Otwarto w innej karcie",
|
||||
"open_elsewhere_description": "{{brand}} został otwarty w innej karcie. Jeśli tak nie jest, spróbuj odświeżyć stronę.",
|
||||
"peer_connection_timeout": "Upłynął limit czasu połączenia",
|
||||
"peer_connection_timeout_description": "Upłynął czas oczekiwania na połączenie z serwerem multimediów. Spróbuj połączyć się z inną siecią lub wyłączyć VPN. Jeśli problem nadal występuje, zapoznaj się z naszym <0>poradnikiem rozwiązywania problemów</0> lub skontaktuj się z administratorem serwera.",
|
||||
"room_creation_restricted": "Nie udało się utworzyć połączenia",
|
||||
"room_creation_restricted_description": "Tworzenie połączeń mogło zostać ograniczone tylko do autoryzowanych użytkowników. Spróbuj ponownie później lub skontaktuj się z administratorem serwera, jeśli problem nie minie.",
|
||||
"sticky_events_required": "Serwer domowy nie wspiera połączeń Matrix 2.0",
|
||||
"sticky_events_required_description": "Wybrana konfiguracja została ustawiona na korzystanie z połączeń Matrix 2.0, lecz serwer domowy nie wspiera ogłaszania sticky events (MSC4354). Poproś administratora serwera o aktualizację lub zmień konfigurację.",
|
||||
"unexpected_ec_error": "Wystąpił nieoczekiwany błąd (<0>Kod błędu:</0> <1>{{ errorCode }}</1>). Skontaktuj się z administratorem serwera."
|
||||
},
|
||||
"group_call_loader": {
|
||||
"banned_body": "Zostałeś zbanowany z pokoju.",
|
||||
"banned_heading": "Zbanowany",
|
||||
"call_ended_body": "Zostałeś usunięty z rozmowy.",
|
||||
"call_ended_body": "Zostałeś usunięty z połączenia.",
|
||||
"call_ended_heading": "Połączenie zakończone",
|
||||
"knock_reject_body": "Członkowie pokoju odrzucili Twoją prośbę o dołączenie.",
|
||||
"knock_reject_heading": "Nie możesz dołączyć",
|
||||
@@ -112,17 +147,18 @@
|
||||
},
|
||||
"join_existing_call_modal": {
|
||||
"join_button": "Tak, dołącz do połączenia",
|
||||
"text": "Te połączenie już istnieje, czy chcesz do niego dołączyć?",
|
||||
"text": "Połączenie już istnieje, czy chcesz do niej dołączyć?",
|
||||
"title": "Dołączyć do istniejącego połączenia?"
|
||||
},
|
||||
"layout_grid_label": "Siatka",
|
||||
"layout_spotlight_label": "Centrum uwagi",
|
||||
"layout_switch_label": "Układ",
|
||||
"lobby": {
|
||||
"ask_to_join": "Poproś o dołączenie do rozmowy",
|
||||
"ask_to_join": "Poproś o dołączenie do połączenia",
|
||||
"join_as_guest": "Dołącz jako gość",
|
||||
"join_button": "Dołącz do połączenia",
|
||||
"leave_button": "Wróć do ostatnie",
|
||||
"waiting_for_invite": "Żądanie wysłane"
|
||||
"waiting_for_invite": "Prośba wysłana! Oczekiwanie na zatwierdzenie…"
|
||||
},
|
||||
"log_in": "Zaloguj",
|
||||
"logging_in": "Logowanie…",
|
||||
@@ -162,14 +198,15 @@
|
||||
"screenshare_button_label": "Udostępnij ekran",
|
||||
"settings": {
|
||||
"audio_tab": {
|
||||
"effect_volume_description": "Dostosuj głośność, z jaką odtwarzane są efekty reakcji i podniesionej ręki",
|
||||
"effect_volume_label": "Głośność efektu dźwiękowego"
|
||||
"effect_volume_description": "Dostosuj głośność efektów reakcji i podniesienia ręki.",
|
||||
"effect_volume_label": "Głośność efektów dźwiękowych"
|
||||
},
|
||||
"background_blur_header": "Tło",
|
||||
"background_blur_label": "Rozmycie tła wideo",
|
||||
"blur_not_supported_by_browser": "(Rozmycie tła nie jest wspierane przez to urządzenie.)",
|
||||
"developer_tab_title": "Programista",
|
||||
"devices": {
|
||||
"activating": "Aktywuję…",
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"change_device_button": "Zmień urządzenie audio",
|
||||
@@ -192,7 +229,7 @@
|
||||
"preferences_tab": {
|
||||
"developer_mode_label": "Tryb programisty",
|
||||
"developer_mode_label_description": "Włącz tryb programisty i wyświetl kartę ustawień programisty.",
|
||||
"introduction": "Tutaj możesz skonfigurować dodatkowe opcje dla lepszych doświadczeń.",
|
||||
"introduction": "Tutaj możesz skonfigurować dodatkowe opcje dla lepszego komfortu użytkowania.",
|
||||
"reactions_play_sound_description": "Odtwórz efekt dźwiękowy, gdy ktoś wyślę reakcję w trakcie połączenia.",
|
||||
"reactions_play_sound_label": "Odtwórz dźwięki reakcji",
|
||||
"reactions_show_description": "Odtwórz animację, gdy ktoś wyślę reakcję.",
|
||||
@@ -201,26 +238,31 @@
|
||||
"show_hand_raised_timer_label": "Pokaż czas po podniesieniu ręki"
|
||||
}
|
||||
},
|
||||
"star_rating_input_label_one": "{{count}} gwiazdki",
|
||||
"star_rating_input_label_other": "{{count}} gwiazdki",
|
||||
"star_rating_input_label_one": "{{count}} gwiazdka",
|
||||
"star_rating_input_label_few": "{{count}} gwiazdki",
|
||||
"star_rating_input_label_many": "{{count}} gwiazdek",
|
||||
"start_new_call": "Rozpocznij nowe połączenie",
|
||||
"start_video_button_label": "Rozpocznij wideo",
|
||||
"stop_screenshare_button_label": "Udostępnianie ekranu",
|
||||
"stop_video_button_label": "Zakończ wideo",
|
||||
"submitting": "Wysyłanie…",
|
||||
"switch_camera": "Przełącz kamerę",
|
||||
"technical_details": "Szczegóły techniczne",
|
||||
"unauthenticated_view_body": "Nie masz konta? <2>Utwórz je</2>",
|
||||
"unauthenticated_view_login_button": "Zaloguj się do swojego konta",
|
||||
"unauthenticated_view_login_button": "Zaloguj się na swoje konto",
|
||||
"unauthenticated_view_ssla_caption": "Klikając \"Przejdź\", wyrażasz zgodę na naszą <2>Umowę licencyjną oprogramowania i usług (SSLA)</2>",
|
||||
"unmute_microphone_button_label": "Odcisz mikrofon",
|
||||
"version": "Wersja: {{version}}",
|
||||
"unmute_microphone_button_label": "Wyłącz wyciszenie mikrofonu",
|
||||
"version": "{{productName}} wersja: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Zawsze pokazuj",
|
||||
"call_ended": "Połączenie zakończone",
|
||||
"calling": "Łączenie...",
|
||||
"camera_starting": "Wczytywanie wideo...",
|
||||
"collapse": "Zwiń",
|
||||
"expand": "Rozwiń",
|
||||
"mute_for_me": "Wycisz dla mnie",
|
||||
"muted_for_me": "Wyciszone dla mnie",
|
||||
"screen_share_volume": "Głośność udostępniania ekranu",
|
||||
"volume": "Głośność",
|
||||
"waiting_for_media": "Oczekiwanie na media..."
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -30,8 +30,8 @@
|
||||
"test:storybook": "vitest --project=storybook",
|
||||
"test:unit": "vitest --project=unit",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"backend": "docker-compose -f dev-backend-docker-compose.yml up",
|
||||
"backend-playwright": "docker-compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up",
|
||||
"backend": "docker-compose -f docker-compose-dev.yml up",
|
||||
"backend-playwright": "docker-compose -f docker-compose-dev.yml -f docker-compose-playwright.yml up",
|
||||
"test:playwright": "playwright test",
|
||||
"test:playwright:open": "pnpm test:playwright --ui",
|
||||
"links:on": "cp scripts/.pnpmfile.cjs .pnpmfile.cjs & pnpm install",
|
||||
@@ -47,7 +47,6 @@
|
||||
"@formatjs/intl-segmenter": "^11.7.3",
|
||||
"@livekit/components-core": "^0.12.0",
|
||||
"@livekit/components-react": "^2.0.0",
|
||||
"@livekit/protocol": "^1.42.2",
|
||||
"@livekit/track-processors": "^0.7.1",
|
||||
"@mediapipe/tasks-vision": "^0.10.18",
|
||||
"@playwright/test": "^1.60.0",
|
||||
@@ -81,13 +80,12 @@
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "4.1.7",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"eslint-plugin-element-call": "link:eslint",
|
||||
"eslint-plugin-storybook": "^10.3.6",
|
||||
"fetch-mock": "11.1.5",
|
||||
"global-jsdom": "^26.0.0",
|
||||
"i18next": "^25.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-cli": "^1.61.0",
|
||||
@@ -101,7 +99,7 @@
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"oxfmt": "^0.55.0",
|
||||
"oxfmt": "^0.56.0",
|
||||
"oxlint": "^1.70.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"pako": "^2.0.4",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
services:
|
||||
synapse:
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
synapse-1:
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
@@ -1,2 +0,0 @@
|
||||
include:
|
||||
- dev-backend-docker-compose.yml
|
||||
@@ -120,6 +120,11 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
.getByRole("heading", { name: "Welcome Room" }),
|
||||
).toBeVisible();
|
||||
} else if (callType === "dm") {
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage1,
|
||||
"Introducing Sections",
|
||||
);
|
||||
|
||||
await ewPage1
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
|
||||
@@ -125,21 +125,10 @@ async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
|
||||
});
|
||||
|
||||
// There should be `count` video elements, visible and autoplaying
|
||||
await expect(page.locator("video")).toHaveCount(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,
|
||||
});
|
||||
await expect(page.locator("video").filter({ visible: true })).toHaveCount(
|
||||
count,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
|
||||
export const SpaHelpers = {
|
||||
|
||||
@@ -73,9 +73,6 @@ export class TestHelpers {
|
||||
audioOnly: boolean = false,
|
||||
isDM: boolean = false,
|
||||
): Promise<void> {
|
||||
// XXX This using the notification toast to join the room.
|
||||
// Not the button in the header
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
const label = isDM
|
||||
? audioOnly
|
||||
@@ -85,7 +82,9 @@ export class TestHelpers {
|
||||
await expect(page.getByText(label)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Join" }).click({
|
||||
// XXX This using the notification toast to join the room.
|
||||
// Not the buttons in the header or timeline
|
||||
await page.getByRole("alert").getByRole("button", { name: "Join" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
@@ -190,11 +189,27 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
public static async closeReleaseAnnouncement(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
.getByRole("dialog", { name })
|
||||
.getByRole("button", { name: "OK" })
|
||||
.click({ timeout: 2000 });
|
||||
} catch {
|
||||
// Announcement not shown; nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
public static async createRoom(
|
||||
name: string,
|
||||
page: Page,
|
||||
andInvite: string[] = [],
|
||||
): Promise<void> {
|
||||
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
|
||||
|
||||
await page
|
||||
.getByRole("navigation", { name: "Room list" })
|
||||
.getByRole("button", { name: "New conversation" })
|
||||
@@ -369,25 +384,9 @@ export class TestHelpers {
|
||||
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);
|
||||
await expect(frame.locator("video").filter({ visible: true })).toHaveCount(
|
||||
count,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,17 +31,18 @@ widgetTest(
|
||||
const brooksFrame = brooks.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// We should show a ringing tile, let's check for that
|
||||
await expect(
|
||||
brooksFrame
|
||||
.getByTestId("videoTile")
|
||||
.filter({ has: brooksFrame.getByText(whistler.displayName) })
|
||||
.filter({ has: brooksFrame.getByText("Calling…") }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Join" }).click();
|
||||
await whistler.page
|
||||
.getByRole("alert")
|
||||
.getByRole("button", { name: "Join" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
@@ -83,23 +84,22 @@ widgetTest(
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
|
||||
// So first we need to confirm that it is hidden when in the call.
|
||||
await expect(
|
||||
whistler.page.locator(".mx_BasicMessageComposer"),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
brooks.page.locator(".mx_BasicMessageComposer"),
|
||||
).not.toBeVisible();
|
||||
|
||||
// ASSERT hanging up on one side ends the call for both
|
||||
await brooksFrame.getByRole("button", { name: "End call" }).click();
|
||||
|
||||
// The widget should be closed on both sides and the timeline should be back on screen
|
||||
// We confirm that we started in Pip mode (voice call) and check that we still see the composer.
|
||||
await expect(
|
||||
whistler.page.locator(".mx_BasicMessageComposer"),
|
||||
).toBeVisible();
|
||||
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
|
||||
|
||||
// ASSERT hanging up on one side ends the call for both
|
||||
await brooksFrame.getByRole("button", { name: "End call" }).click();
|
||||
|
||||
// The widget should be closed on both sides
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -132,7 +132,10 @@ widgetTest(
|
||||
).toBeVisible();
|
||||
|
||||
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
|
||||
await whistler.page.getByRole("button", { name: "Join" }).click();
|
||||
await whistler.page
|
||||
.getByRole("alert")
|
||||
.getByRole("button", { name: "Join" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
whistler.page.locator('iframe[title="Element Call"]'),
|
||||
|
||||
3990
pnpm-lock.yaml
generated
3990
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,8 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- "@vector-im/compound-design-tokens"
|
||||
- "@vector-im/compound-web"
|
||||
- matrix-js-sdk
|
||||
- matrix-widget-api
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
"@sentry/cli": true
|
||||
@@ -24,12 +29,30 @@ overrides:
|
||||
# Security fixes:
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/110
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/122
|
||||
"qs": "^6.14.1"
|
||||
# Security fix: https://github.com/element-hq/element-call/security/dependabot/106
|
||||
"js-yaml": "^4.1.1"
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/179
|
||||
"qs": "^6.15.2"
|
||||
# Security fixes:
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/106
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/203
|
||||
"js-yaml": "^4.2.0"
|
||||
# Storybook declares support for 0.27.0 only but empirically works fine with 0.28.0.
|
||||
"esbuild": "^0.28.0"
|
||||
# Multiple security fixes: https://github.com/nodejs/undici/releases/tag/v6.24.0
|
||||
"undici": "^6.24.0"
|
||||
# Security fixes:
|
||||
# - https://github.com/nodejs/undici/releases/tag/v6.24.0
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/201
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/199
|
||||
"undici": "^6.27.0"
|
||||
# Security fix: https://github.com/advisories/GHSA-rf6f-7fwh-wjgh
|
||||
"flatted": "^3.4.2"
|
||||
# Security fix: https://github.com/element-hq/element-call/security/dependabot/184
|
||||
"ws": "^8.21.0"
|
||||
# Security fixes:
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/191
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/190
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/192
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/194
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/198
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/196
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/195
|
||||
# - https://github.com/element-hq/element-call/security/dependabot/193
|
||||
"dompurify": "^3.4.11"
|
||||
|
||||
@@ -61,6 +61,15 @@
|
||||
{
|
||||
"groupName": "Pnpm",
|
||||
"matchDepNames": ["pnpm"]
|
||||
},
|
||||
{
|
||||
"description": "Update the element-web image digest daily, in its own PR",
|
||||
"groupName": "Element Web",
|
||||
"matchPackageNames": ["ghcr.io/element-hq/element-web"],
|
||||
"matchUpdateTypes": ["digest"],
|
||||
"schedule": ["* 0-3 * * *"],
|
||||
"minimumReleaseAge": null,
|
||||
"automerge": true
|
||||
}
|
||||
],
|
||||
"semanticCommits": "disabled",
|
||||
|
||||
@@ -15,9 +15,8 @@ import { scan } from "rxjs";
|
||||
import { type WidgetHelpers } from "../src/widget";
|
||||
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
|
||||
|
||||
export const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
|
||||
export const tryMakeSticky = (widget: WidgetHelpers): void => {
|
||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
logger.info("try making sticky MatrixRTCSdk");
|
||||
void widget.api
|
||||
.setAlwaysOnScreen(true)
|
||||
|
||||
@@ -52,7 +52,8 @@ import { getUrlParams } from "../src/UrlParams";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
import { currentAndPrev, logger, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
widget as _widget,
|
||||
@@ -104,6 +105,7 @@ export async function createMatrixRTCSdk(
|
||||
id: string = "",
|
||||
sticky: boolean = false,
|
||||
): Promise<MatrixRTCSdk> {
|
||||
const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// widget client
|
||||
|
||||
18
src/App.tsx
18
src/App.tsx
@@ -30,7 +30,7 @@ import { useTheme } from "./useTheme";
|
||||
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
|
||||
import { type AppViewModel } from "./state/AppViewModel";
|
||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||
import { getUrlParams, HeaderStyle } from "./UrlParams";
|
||||
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
|
||||
import { AppBar } from "./AppBar";
|
||||
|
||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||
@@ -41,19 +41,17 @@ interface SimpleProviderProps {
|
||||
|
||||
const BackgroundProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const { pathname } = useLocation();
|
||||
const { background } = useUrlParams();
|
||||
|
||||
useEffect(() => {
|
||||
let backgroundImage = "";
|
||||
if (!["/login", "/register"].includes(pathname) && !widget) {
|
||||
backgroundImage = "var(--background-gradient)";
|
||||
}
|
||||
document
|
||||
.getElementsByTagName("body")[0]
|
||||
.setAttribute("data-background", background);
|
||||
}, [pathname, background]);
|
||||
|
||||
document.getElementsByTagName("body")[0].style.backgroundImage =
|
||||
backgroundImage;
|
||||
}, [pathname]);
|
||||
|
||||
return <>{children}</>;
|
||||
return children;
|
||||
};
|
||||
|
||||
const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
useTheme();
|
||||
return children;
|
||||
|
||||
@@ -45,6 +45,11 @@ export enum HeaderStyle {
|
||||
AppBar = "app_bar",
|
||||
}
|
||||
|
||||
export enum BackgroundStyle {
|
||||
Solid = "solid",
|
||||
Gradient = "gradient",
|
||||
}
|
||||
|
||||
/**
|
||||
* The UrlProperties are used to pass required data to the widget.
|
||||
* Those are different in different rooms, users, devices. They do not configure the behavior of the
|
||||
@@ -145,6 +150,10 @@ export interface UrlProperties {
|
||||
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
|
||||
*/
|
||||
theme: string | null;
|
||||
/**
|
||||
* The visual style of the page background.
|
||||
*/
|
||||
background: BackgroundStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -452,6 +461,9 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
fonts: parser.getAllParams("font"),
|
||||
fontScale: Number.isNaN(fontScale) ? null : fontScale,
|
||||
theme: parser.getParam("theme"),
|
||||
background:
|
||||
parser.getEnumParam("background", BackgroundStyle) ??
|
||||
BackgroundStyle.Gradient,
|
||||
viaServers: !isWidget ? parser.getParam("viaServers") : null,
|
||||
homeserver: !isWidget ? parser.getParam("homeserver") : null,
|
||||
posthogApiHost: parser.getParam("posthogApiHost"),
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
exports[`AppBar > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="bar"
|
||||
class="_bar_221541"
|
||||
>
|
||||
<header>
|
||||
<button
|
||||
aria-labelledby="_r_0_"
|
||||
class="_icon-button_1215g_8 primaryButton"
|
||||
class="_icon-button_1215g_8 _primaryButton_221541"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
@@ -33,7 +33,7 @@ exports[`AppBar > renders 1`] = `
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="secondaryButton"
|
||||
class="_secondaryButton_221541"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
@@ -46,12 +46,12 @@ exports[`AppBar > renders 1`] = `
|
||||
exports[`AppBar > renders with title and subtitle 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="bar"
|
||||
class="_bar_221541"
|
||||
>
|
||||
<header>
|
||||
<button
|
||||
aria-labelledby="_r_6_"
|
||||
class="_icon-button_1215g_8 primaryButton"
|
||||
class="_icon-button_1215g_8 _primaryButton_221541"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 24px;"
|
||||
@@ -76,17 +76,17 @@ exports[`AppBar > renders with title and subtitle 1`] = `
|
||||
</div>
|
||||
</button>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-body-lg-medium_6v6n8_79 title"
|
||||
class="_typography_6v6n8_153 _font-body-lg-medium_6v6n8_79 _title_221541"
|
||||
>
|
||||
Title
|
||||
</h1>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69 subtitle"
|
||||
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69 _subtitle_221541"
|
||||
>
|
||||
Subtitle
|
||||
</span>
|
||||
<div
|
||||
class="secondaryButton"
|
||||
class="_secondaryButton_221541"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
exports[`the content is rendered when the modal is open 1`] = `
|
||||
<div
|
||||
aria-labelledby="radix-_r_4_"
|
||||
class="overlay animate modal dialog _glass_sepwu_8"
|
||||
class="_overlay_2f5303 _animate_2f5303 _modal_dbeffe _dialog_dbeffe _glass_sepwu_8"
|
||||
data-state="open"
|
||||
id="radix-_r_3_"
|
||||
role="dialog"
|
||||
@@ -11,10 +11,10 @@ exports[`the content is rendered when the modal is open 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_dbeffe"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
class="_header_dbeffe"
|
||||
>
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
@@ -24,7 +24,7 @@ exports[`the content is rendered when the modal is open 1`] = `
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
class="body"
|
||||
class="_body_dbeffe"
|
||||
>
|
||||
<p>
|
||||
This is the content.
|
||||
@@ -37,7 +37,7 @@ exports[`the content is rendered when the modal is open 1`] = `
|
||||
exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
<div
|
||||
aria-labelledby="radix-_r_a_"
|
||||
class="overlay modal drawer"
|
||||
class="_overlay_2f5303 _modal_dbeffe _drawer_dbeffe"
|
||||
data-state="open"
|
||||
data-vaul-animate="true"
|
||||
data-vaul-custom-container="false"
|
||||
@@ -51,13 +51,13 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_dbeffe"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
class="_header_dbeffe"
|
||||
>
|
||||
<div
|
||||
class="handle"
|
||||
class="_handle_dbeffe"
|
||||
/>
|
||||
<h2
|
||||
id="radix-_r_a_"
|
||||
@@ -67,7 +67,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
class="body"
|
||||
class="_body_dbeffe"
|
||||
>
|
||||
<p>
|
||||
This is the content.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`QrCode > renders 1`] = `
|
||||
<div
|
||||
class="qrCode bar"
|
||||
class="_qrCode_458264 bar"
|
||||
>
|
||||
<img
|
||||
alt="QR Code"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
exports[`Toast > renders 1`] = `
|
||||
<button
|
||||
aria-labelledby="radix-_r_4_"
|
||||
class="overlay animate toast"
|
||||
class="_overlay_2f5303 _animate_2f5303 _toast_15a045"
|
||||
data-state="open"
|
||||
id="radix-_r_3_"
|
||||
role="dialog"
|
||||
|
||||
@@ -5,10 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.endCall > svg {
|
||||
color: var(--stopgap-color-on-solid-accent);
|
||||
}
|
||||
|
||||
.rotate > svg {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -135,21 +135,12 @@ interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
export const EndCallButton: FC<EndCallButtonProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip label={t("hangup_button_label")}>
|
||||
<CpdButton
|
||||
className={classNames(className, styles.endCall)}
|
||||
iconOnly
|
||||
Icon={EndCallIcon}
|
||||
destructive
|
||||
{...props}
|
||||
/>
|
||||
<CpdButton iconOnly Icon={EndCallIcon} destructive {...props} />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -140,7 +140,7 @@ exports[`Can raise hand 1`] = `
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_1j_"
|
||||
class="_button_1nw83_8 raisedButton _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
class="_button_1nw83_8 _raisedButton_fb25ab _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
|
||||
@@ -19,11 +19,6 @@ Please see LICENSE in the repository root for full details.
|
||||
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 {
|
||||
|
||||
@@ -16,10 +16,12 @@ import inCallViewStyles from "../room/InCallView.module.css";
|
||||
import { useStaticViewModel } from "../state/ViewModel";
|
||||
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
|
||||
|
||||
// consts for tests
|
||||
const reactionIdentifier = "@user:example.com:DEVICE";
|
||||
const reactionData = {
|
||||
@@ -32,6 +34,7 @@ const mediaDevices = new MediaDevices(globalScope);
|
||||
/**
|
||||
* A wrapper component that is used for:
|
||||
* - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm)
|
||||
* - constructing the layout switch view model
|
||||
* - Add additional react context
|
||||
* The paraeters are all params from the FooterSnapshot,
|
||||
* the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter.
|
||||
@@ -40,11 +43,18 @@ const mediaDevices = new MediaDevices(globalScope);
|
||||
*/
|
||||
function CallFooterStoryWrapper({
|
||||
children,
|
||||
layout,
|
||||
setLayout,
|
||||
...vmSnapshot
|
||||
}: FooterSnapshot & {
|
||||
}: Omit<FooterSnapshot, "layoutSwitchVm"> & {
|
||||
children?: false | JSX.Element | JSX.Element[] | undefined;
|
||||
layout: LayoutMode | null;
|
||||
setLayout: (value: LayoutMode) => void;
|
||||
}): ReactNode {
|
||||
const vm = useStaticViewModel(vmSnapshot);
|
||||
const vm = useStaticViewModel({
|
||||
...vmSnapshot,
|
||||
layoutSwitchVm: layout && { layout$: constant(layout), setLayout },
|
||||
});
|
||||
return (
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<div className={inCallViewStyles.inRoom}>
|
||||
@@ -62,28 +72,50 @@ function CallFooterStoryWrapper({
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
component: CallFooterStoryWrapper,
|
||||
} satisfies Meta<typeof CallFooterStoryWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const fnArgType = {
|
||||
control: { type: "select" as const },
|
||||
options: ["MockedCallback", "undefined"],
|
||||
mapping: { MockedCallback: fn(), undefined: undefined },
|
||||
};
|
||||
|
||||
const meta = {
|
||||
component: CallFooterStoryWrapper,
|
||||
argTypes: {
|
||||
layout: {
|
||||
control: "radio",
|
||||
options: ["grid", "spotlight"] satisfies LayoutMode[],
|
||||
},
|
||||
audioOutputSwitcher: {
|
||||
control: "select",
|
||||
options: ["NoOutputCallback", "speaker", "earpiece"],
|
||||
table: { defaultValue: { summary: "NoOutputCallback" } },
|
||||
mapping: {
|
||||
NoOutputCallback: undefined,
|
||||
// This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one.
|
||||
speaker: { targetOutput: "earpiece", switch: fn() },
|
||||
earpiece: { targetOutput: "speaker", switch: fn() },
|
||||
},
|
||||
},
|
||||
toggleScreenSharing: fnArgType,
|
||||
openSettings: fnArgType,
|
||||
toggleAudio: fnArgType,
|
||||
toggleVideo: fnArgType,
|
||||
hangup: fnArgType,
|
||||
},
|
||||
} satisfies Meta<typeof CallFooterStoryWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
showLogo: false,
|
||||
layoutMode: "grid",
|
||||
layout: "grid",
|
||||
setLayout: fn(),
|
||||
audioEnabled: true,
|
||||
audioBusy: false,
|
||||
videoEnabled: true,
|
||||
videoBusy: false,
|
||||
setLayoutMode: fn(),
|
||||
openSettings: fn(),
|
||||
toggleAudio: fn(),
|
||||
toggleVideo: fn(),
|
||||
@@ -111,29 +143,6 @@ export const Default: Story = {
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
argTypes: {
|
||||
layoutMode: {
|
||||
control: "radio",
|
||||
options: ["grid", "spotlight"] satisfies GridMode[],
|
||||
},
|
||||
audioOutputSwitcher: {
|
||||
control: "select",
|
||||
options: ["NoOutputCallback", "speaker", "earpiece"],
|
||||
table: { defaultValue: { summary: "NoOutputCallback" } },
|
||||
mapping: {
|
||||
NoOutputCallback: undefined,
|
||||
// This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one.
|
||||
speaker: { targetOutput: "earpiece", switch: fn() },
|
||||
earpiece: { targetOutput: "speaker", switch: fn() },
|
||||
},
|
||||
},
|
||||
toggleScreenSharing: fnArgType,
|
||||
setLayoutMode: fnArgType,
|
||||
openSettings: fnArgType,
|
||||
toggleAudio: fnArgType,
|
||||
toggleVideo: fnArgType,
|
||||
hangup: fnArgType,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAudioAndVideoOptions: Story = {
|
||||
@@ -194,7 +203,7 @@ export const AudioVideoEnabled: Story = {
|
||||
|
||||
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
|
||||
await userEvent.click(spotlightRadio);
|
||||
await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight");
|
||||
await expect(args.setLayout).toHaveBeenCalledWith("spotlight");
|
||||
|
||||
const micButtonMute = canvas.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
@@ -225,14 +234,14 @@ export const SpotlightMode: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
layoutMode: "spotlight",
|
||||
layout: "spotlight",
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
|
||||
await userEvent.click(spotlightRadio);
|
||||
await expect(args.setLayoutMode).toHaveBeenCalledWith("grid");
|
||||
await expect(args.setLayout).toHaveBeenCalledWith("grid");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -264,7 +273,7 @@ export const Pip: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
buttonSize: "md",
|
||||
layoutMode: undefined,
|
||||
layout: null,
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -348,7 +357,7 @@ export const Lobby: Story = {
|
||||
...Default.args,
|
||||
showLogo: false,
|
||||
openSettings: undefined,
|
||||
setLayoutMode: undefined,
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
parameters: {
|
||||
@@ -362,7 +371,7 @@ export const LobbyMobile: Story = {
|
||||
...Default.args,
|
||||
showLogo: false,
|
||||
|
||||
setLayoutMode: undefined,
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
globals: {
|
||||
@@ -379,7 +388,7 @@ export const LobbyRecentButton: Story = {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
showLogo: false,
|
||||
setLayoutMode: undefined,
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
parameters: {
|
||||
@@ -393,7 +402,7 @@ export const LobbyRecentButtonMobile: Story = {
|
||||
...Default.args,
|
||||
children: <Link>Back To Recents</Link>,
|
||||
showLogo: false,
|
||||
setLayoutMode: undefined,
|
||||
layout: null,
|
||||
toggleScreenSharing: undefined,
|
||||
},
|
||||
globals: {
|
||||
|
||||
@@ -7,12 +7,6 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type FC, type JSX, type Ref, useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
SpotlightIcon,
|
||||
GridIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { Switch } from "@vector-im/compound-web";
|
||||
import { t } from "i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -28,13 +22,14 @@ import {
|
||||
type ReactionData,
|
||||
} from "../button";
|
||||
import styles from "./CallFooter.module.css";
|
||||
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
MediaMuteAndSwitchButton,
|
||||
type MenuOptions,
|
||||
} from "./MediaMuteAndSwitchButton";
|
||||
import { type ViewModel } from "../state/ViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
|
||||
import { LayoutSwitch } from "../room/LayoutSwitch";
|
||||
|
||||
export interface AudioOutputSwitcher {
|
||||
targetOutput: string;
|
||||
@@ -61,8 +56,6 @@ export interface FooterActions {
|
||||
/** Also controls if the videoMute button is disabled */
|
||||
toggleVideo: (() => void) | undefined;
|
||||
toggleBlur: (() => void) | undefined;
|
||||
/** Also controls if the layout button is visible */
|
||||
setLayoutMode: ((mode: GridMode) => void) | undefined;
|
||||
toggleScreenSharing: (() => void) | undefined;
|
||||
/** Also controls if the settings button is visible */
|
||||
openSettings: (() => void) | undefined;
|
||||
@@ -87,7 +80,8 @@ export interface FooterState {
|
||||
buttonSize: "md" | "lg";
|
||||
showLogo: boolean;
|
||||
|
||||
layoutMode: GridMode | undefined;
|
||||
/** Also controls if the layout switch is visible */
|
||||
layoutSwitchVm: LayoutSwitchViewModel | null;
|
||||
|
||||
sharingScreen: boolean;
|
||||
|
||||
@@ -112,16 +106,21 @@ export interface FooterState {
|
||||
}
|
||||
|
||||
export interface FooterProps {
|
||||
className?: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
children?: JSX.Element | JSX.Element[] | false;
|
||||
vm: ViewModel<FooterSnapshot>;
|
||||
}
|
||||
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
||||
export const CallFooter: FC<FooterProps> = ({
|
||||
className,
|
||||
ref,
|
||||
children,
|
||||
vm,
|
||||
}) => {
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
const layoutMode = useBehavior(vm.layoutMode$);
|
||||
const setLayoutMode = useBehavior(vm.setLayoutMode$);
|
||||
const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
|
||||
const openSettings = useBehavior(vm.openSettings$);
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const audioBusy = useBehavior(vm.audioBusy$);
|
||||
@@ -292,7 +291,7 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid="footer-container"
|
||||
className={classNames(styles.footer, {
|
||||
className={classNames(className, styles.footer, {
|
||||
[styles.overlay]: asOverlay,
|
||||
[styles.hidden]: !showFooter,
|
||||
})}
|
||||
@@ -311,20 +310,8 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
|
||||
{(showLogo || debugTileLayout) && logoDebugContainer}
|
||||
</div>
|
||||
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
|
||||
{!hideControls && setLayoutMode && layoutMode && (
|
||||
<Switch<"spotlight", "grid">
|
||||
name="layoutMode"
|
||||
aria-label={t("layout_switch_label")}
|
||||
leftLabel={t("layout_spotlight_label")}
|
||||
leftValue="spotlight"
|
||||
leftIcon={SpotlightIcon}
|
||||
rightLabel={t("layout_grid_label")}
|
||||
rightValue="grid"
|
||||
rightIcon={GridIcon}
|
||||
className={styles.layout}
|
||||
value={layoutMode}
|
||||
onChange={setLayoutMode}
|
||||
/>
|
||||
{!hideControls && layoutSwitchVm && (
|
||||
<LayoutSwitch vm={layoutSwitchVm} className={styles.layout} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -182,14 +182,7 @@ export function createCallFooterViewModel(
|
||||
|
||||
showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))),
|
||||
|
||||
layoutMode$: callModel.gridMode$,
|
||||
setLayoutMode$: scope.behavior(
|
||||
isPip$.pipe(
|
||||
map((isPip) =>
|
||||
!isPip && showControls ? callModel.setGridMode : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
layoutSwitchVm$: callModel.layoutSwitchVm$,
|
||||
|
||||
sharingScreen$: callModel.sharingScreen$,
|
||||
toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined),
|
||||
@@ -247,20 +240,18 @@ export function createLobbyFooterViewModel(
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
buttonSize: "lg",
|
||||
showLayoutSwitcher: false,
|
||||
openSettings,
|
||||
hangup,
|
||||
debugTileLayout: false,
|
||||
showFooter: true,
|
||||
toggleAudio: undefined,
|
||||
toggleVideo: undefined,
|
||||
setLayoutMode: undefined,
|
||||
toggleScreenSharing: undefined,
|
||||
audioEnabled: undefined,
|
||||
audioBusy: false,
|
||||
videoEnabled: undefined,
|
||||
videoBusy: false,
|
||||
layoutMode: undefined,
|
||||
layoutSwitchVm: null,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
|
||||
@@ -180,12 +180,12 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Microphone" }));
|
||||
screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||
screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 1" });
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2" });
|
||||
await user.keyboard("[Escape]");
|
||||
await user.click(screen.getByRole("button", { name: "Camera" }));
|
||||
screen.getByRole("menuitem", { name: "Camera 1" });
|
||||
screen.getByRole("menuitem", { name: "Camera 2" });
|
||||
screen.getByRole("menuitemradio", { name: "Camera 1" });
|
||||
screen.getByRole("menuitemradio", { name: "Camera 2" });
|
||||
});
|
||||
|
||||
test("calls select callback on menu click", async () => {
|
||||
@@ -206,7 +206,9 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
|
||||
await user.click(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("mic2");
|
||||
});
|
||||
@@ -228,7 +230,9 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Microphone 1" }));
|
||||
await user.click(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
|
||||
);
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -264,18 +268,24 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
const { getByRole } = renderComponent(<Wrapper />);
|
||||
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
|
||||
await user.click(
|
||||
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
|
||||
);
|
||||
|
||||
expect(onSelectPressed).toHaveBeenCalled();
|
||||
expect(onOptionUpdated).not.toHaveBeenCalled();
|
||||
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
|
||||
// so a spinner should appear on the mic2 item
|
||||
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||
expect(mic2Item.querySelector(".rotate")).toBeTruthy();
|
||||
// so mic2 should be in an activating state
|
||||
screen.getByRole("menuitemradio", {
|
||||
name: "Microphone 2 Activating…",
|
||||
checked: false,
|
||||
});
|
||||
|
||||
// The currently-selected mic1 item should not have a spinner
|
||||
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||
expect(mic1Item.querySelector(".rotate")).toBeNull();
|
||||
// The currently-selected mic1 item should not be activating
|
||||
screen.getByRole("menuitemradio", {
|
||||
name: "Microphone 1",
|
||||
checked: true,
|
||||
});
|
||||
await act(async () => {
|
||||
// resolve the promise that acutally updates the select option.
|
||||
resolve();
|
||||
@@ -284,7 +294,7 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
|
||||
expect(onOptionUpdated).toHaveBeenCalled();
|
||||
// Spinner should now be gone since the selection has caught up
|
||||
const mic2ItemAfter = screen.getByRole("menuitem", {
|
||||
const mic2ItemAfter = screen.getByRole("menuitemradio", {
|
||||
name: "Microphone 2",
|
||||
});
|
||||
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
|
||||
@@ -336,11 +346,15 @@ describe("MediaMuteAndSwitchButton", () => {
|
||||
await user.click(getByRole("button", { name: "Microphone" }));
|
||||
|
||||
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
|
||||
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||
const mic1Item = screen.getByRole("menuitemradio", {
|
||||
name: "Microphone 2",
|
||||
});
|
||||
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
|
||||
|
||||
// The unselected item (mic1) only renders its IconOptions SVG
|
||||
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||
const mic2Item = screen.getByRole("menuitemradio", {
|
||||
name: "Microphone 1",
|
||||
});
|
||||
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,6 +191,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.itemIcon}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -201,10 +202,23 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
onSelect?.(id);
|
||||
}}
|
||||
key={id}
|
||||
role="menuitemradio"
|
||||
aria-checked={selectedOption === id}
|
||||
>
|
||||
{selectedOption === id && <CheckIcon width={24} height={24} />}
|
||||
{selectedOption === id && (
|
||||
<CheckIcon
|
||||
width={24}
|
||||
height={24}
|
||||
aria-hidden // A label would be redundant to aria-checked above
|
||||
/>
|
||||
)}
|
||||
{selectedOption !== id && plannedSelection === id && (
|
||||
<SpinnerIcon width={24} height={24} className={styles.rotate} />
|
||||
<SpinnerIcon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.rotate}
|
||||
aria-label={t("settings.devices.activating")}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_e649de"
|
||||
>
|
||||
<button
|
||||
aria-busy="false"
|
||||
@@ -35,7 +35,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
||||
aria-expanded="false"
|
||||
aria-haspopup="menu"
|
||||
aria-label="Microphone"
|
||||
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
class="_button_1nw83_8 _menuButton_e649de _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="tertiary"
|
||||
data-size="lg"
|
||||
data-state="closed"
|
||||
|
||||
@@ -10,15 +10,15 @@ import {
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
const logger = rootLogger.getChild("[MatrixKeyProvider]");
|
||||
|
||||
export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
private rtcSession?: MatrixRTCSession;
|
||||
|
||||
private logger: Logger;
|
||||
public constructor() {
|
||||
super({ ratchetWindowSize: 10, keyringSize: 256 });
|
||||
this.logger = rootLogger.getChild("[MatrixKeyProvider]");
|
||||
}
|
||||
|
||||
public setRTCSession(rtcSession: MatrixRTCSession): void {
|
||||
@@ -60,12 +60,12 @@ export class MatrixKeyProvider extends BaseKeyProvider {
|
||||
encryptionKeyIndex,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
this.logger.debug(
|
||||
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
);
|
||||
},
|
||||
(e) => {
|
||||
logger.error(
|
||||
this.logger.error(
|
||||
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId before hash=${membershipParts.userId}:${membershipParts.deviceId} encryptionKeyIndex=${encryptionKeyIndex}`,
|
||||
e,
|
||||
);
|
||||
|
||||
|
Before Width: | Height: | Size: 938 B After Width: | Height: | Size: 938 B |
@@ -1,48 +0,0 @@
|
||||
<svg width="1440" height="500" viewBox="0 0 1440 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_4162_80135)">
|
||||
<circle cx="720" cy="1620" r="1500" fill="url(#paint0_linear_4162_80135)"/>
|
||||
<circle cx="720" cy="1620" r="1498.92" stroke="white" stroke-opacity="0.5" stroke-width="2.16028" style="mix-blend-mode:overlay"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_4162_80135)">
|
||||
<circle cx="720" cy="1550.86" r="1272.86" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_4162_80135" x="-900" y="0" width="3240" height="3240" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_4162_80135"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_4162_80135" x="-672.863" y="158" width="2785.73" height="2785.73" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_4162_80135"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_4162_80135" x1="549.5" y1="120" x2="549.5" y2="505.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#062993"/>
|
||||
<stop offset="0.040404" stop-color="#02389D"/>
|
||||
<stop offset="0.0808081" stop-color="#0045A6"/>
|
||||
<stop offset="0.121212" stop-color="#0051AD"/>
|
||||
<stop offset="0.161616" stop-color="#005DB4"/>
|
||||
<stop offset="0.20202" stop-color="#0069BA"/>
|
||||
<stop offset="0.242424" stop-color="#0075BB"/>
|
||||
<stop offset="0.282828" stop-color="#0081BB"/>
|
||||
<stop offset="0.323232" stop-color="#008CB9"/>
|
||||
<stop offset="0.363636" stop-color="#0098B7"/>
|
||||
<stop offset="0.40404" stop-color="#00A3B3"/>
|
||||
<stop offset="0.444444" stop-color="#00AEAD"/>
|
||||
<stop offset="0.484848" stop-color="#00B8A4"/>
|
||||
<stop offset="0.525253" stop-color="#00C2A0"/>
|
||||
<stop offset="0.565657" stop-color="#00CC99"/>
|
||||
<stop offset="0.606061" stop-color="#3AD396"/>
|
||||
<stop offset="0.646465" stop-color="#5DD898"/>
|
||||
<stop offset="0.686869" stop-color="#79DD99"/>
|
||||
<stop offset="0.727273" stop-color="#92E29B"/>
|
||||
<stop offset="0.767677" stop-color="#A8E69F"/>
|
||||
<stop offset="0.808081" stop-color="#BBEAA5"/>
|
||||
<stop offset="0.848485" stop-color="#CDEEAE"/>
|
||||
<stop offset="0.888889" stop-color="#DCF2B9"/>
|
||||
<stop offset="0.929293" stop-color="#EAF6C7"/>
|
||||
<stop offset="0.969697" stop-color="#F5FBD5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
86
src/graphics/mobile-gradient.svg
Normal file
86
src/graphics/mobile-gradient.svg
Normal file
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="1200"
|
||||
height="285"
|
||||
viewBox="0 0 1200 285"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<g
|
||||
filter="url(#filter0_f_3970_9366)"
|
||||
id="g2"
|
||||
transform="translate(422.00201)">
|
||||
<path
|
||||
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
|
||||
stroke="url(#paint0_linear_3970_9366)"
|
||||
stroke-width="235.517"
|
||||
stroke-linecap="round"
|
||||
id="path1"
|
||||
style="stroke:url(#paint0_linear_3970_9366)" />
|
||||
<path
|
||||
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
|
||||
stroke="url(#paint1_linear_3970_9366)"
|
||||
style="mix-blend-mode:overlay;stroke:url(#paint1_linear_3970_9366)"
|
||||
stroke-width="235.517"
|
||||
stroke-linecap="round"
|
||||
id="path2" />
|
||||
</g>
|
||||
<defs
|
||||
id="defs5">
|
||||
<filter
|
||||
id="filter0_f_3970_9366"
|
||||
x="-517.617"
|
||||
y="-0.00012207"
|
||||
width="1391.23"
|
||||
height="848.409"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feFlood
|
||||
flood-opacity="0"
|
||||
result="BackgroundImageFix"
|
||||
id="feFlood2" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
id="feBlend2" />
|
||||
<feGaussianBlur
|
||||
stdDeviation="117.758"
|
||||
result="effect1_foregroundBlur_3970_9366"
|
||||
id="feGaussianBlur2" />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_3970_9366"
|
||||
x1="349.17099"
|
||||
y1="323.96301"
|
||||
x2="6.82898"
|
||||
y2="666.30499"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#0D5CBD"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="0.730863"
|
||||
stop-color="#0DBDA8"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_3970_9366"
|
||||
x1="349.17099"
|
||||
y1="323.96301"
|
||||
x2="6.82898"
|
||||
y2="666.30499"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#0D5CBD"
|
||||
id="stop4" />
|
||||
<stop
|
||||
offset="0.730863"
|
||||
stop-color="#0DBDA8"
|
||||
id="stop5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/graphics/video-placeholder.gif
Normal file
BIN
src/graphics/video-placeholder.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 B |
@@ -10,28 +10,28 @@ import { type ReactNode, useCallback, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOneLandscapeLayout as OneOnOneLandscapeLayoutModel } from "../state/layout-types.ts";
|
||||
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneLandscapeLayout.module.css";
|
||||
import styles from "./OneOnOneDesktopLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout for landscape screens, in which
|
||||
* An implementation of the "one-on-one" layout for desktop platforms, in which
|
||||
* the remote participant is shown at maximum size, overlaid by a small view of
|
||||
* the local participant.
|
||||
*/
|
||||
export const makeOneOnOneLandscapeLayout: CallLayout<
|
||||
OneOnOneLandscapeLayoutModel
|
||||
export const makeOneOnOneDesktopLayout: CallLayout<
|
||||
OneOnOneDesktopLayoutModel
|
||||
> = ({ minBounds$ }) => ({
|
||||
foreground: "fixed",
|
||||
|
||||
fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode {
|
||||
fixed: function OneOnOneDesktopLayoutFixed({ ref }): ReactNode {
|
||||
useUpdateLayout();
|
||||
return <div ref={ref} />;
|
||||
},
|
||||
|
||||
scrolling: function OneOnOneLandscapeLayoutScrolling({
|
||||
scrolling: function OneOnOneDesktopLayoutScrolling({
|
||||
ref,
|
||||
model,
|
||||
Slot,
|
||||
@@ -19,14 +19,28 @@ Please see LICENSE in the repository root for full details.
|
||||
inset: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
/* Give the PiP a landscape aspect ratio */
|
||||
.pip[data-size="sm"] {
|
||||
inline-size: 88px;
|
||||
block-size: 132px;
|
||||
inline-size: 132px;
|
||||
block-size: 88px;
|
||||
}
|
||||
|
||||
.pip[data-size="lg"] {
|
||||
inline-size: 140px;
|
||||
block-size: 210px;
|
||||
inline-size: 210px;
|
||||
block-size: 140px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
/* Give the PiP a portrait aspect ratio */
|
||||
.pip[data-size="sm"] {
|
||||
inline-size: 88px;
|
||||
block-size: 132px;
|
||||
}
|
||||
|
||||
.pip[data-size="lg"] {
|
||||
inline-size: 140px;
|
||||
block-size: 210px;
|
||||
}
|
||||
}
|
||||
|
||||
.pip[data-block-alignment="start"] {
|
||||
@@ -9,23 +9,23 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts";
|
||||
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import styles from "./OneOnOnePortraitLayout.module.css";
|
||||
import styles from "./OneOnOneMobileLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout for portrait screens, in which
|
||||
* An implementation of the "one-on-one" layout for mobile platforms, in which
|
||||
* the remote participant is shown at maximum size, overlaid by a small view of
|
||||
* the local participant.
|
||||
*/
|
||||
export const makeOneOnOnePortraitLayout: CallLayout<
|
||||
OneOnOnePortraitLayoutModel
|
||||
export const makeOneOnOneMobileLayout: CallLayout<
|
||||
OneOnOneMobileLayoutModel
|
||||
> = () => ({
|
||||
foreground: "scrolling",
|
||||
|
||||
fixed: function OneOnOnePortraitLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
fixed: function OneOnOneMobileLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
return (
|
||||
<div ref={ref} className={styles.layer}>
|
||||
@@ -38,7 +38,7 @@ export const makeOneOnOnePortraitLayout: CallLayout<
|
||||
);
|
||||
},
|
||||
|
||||
scrolling: function OneOnOnePortraitLayoutScrolling({
|
||||
scrolling: function OneOnOneMobileLayoutScrolling({
|
||||
ref,
|
||||
model,
|
||||
Slot,
|
||||
@@ -28,12 +28,6 @@ layer(compound);
|
||||
--font-size-title: calc(24px * var(--font-scale));
|
||||
--font-size-headline: calc(32px * var(--font-scale));
|
||||
|
||||
/* These colors are needed during the transitionary period between the old and
|
||||
new Compound design systems, but should be removed ASAP */
|
||||
--stopgap-color-on-solid-accent: var(--cpd-color-bg-canvas-default);
|
||||
--stopgap-background-85: rgba(255, 255, 255, 0.85);
|
||||
--stopgap-bgColor3: #444;
|
||||
|
||||
--cpd-color-border-accent: var(--cpd-color-green-800);
|
||||
/* The distance to inset non-full-width content from the edge of the window
|
||||
along the inline axis. This ramps up from 16px for typical mobile windows, to
|
||||
@@ -55,7 +49,6 @@ layer(compound);
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
|
||||
--subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
|
||||
--background-gradient: url("graphics/backgroundGradient.svg");
|
||||
|
||||
--call-view-overlay-layer: 1;
|
||||
--call-view-header-footer-layer: 2;
|
||||
@@ -74,9 +67,6 @@ layer(compound);
|
||||
|
||||
body {
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
color: var(--cpd-color-text-primary);
|
||||
color-scheme: dark;
|
||||
margin: 0;
|
||||
@@ -85,6 +75,24 @@ body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (min-height: 330px) {
|
||||
body[data-background="gradient"]::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("graphics/mobile-gradient.svg");
|
||||
background-size: auto;
|
||||
background-position: bottom;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
body[data-background="gradient"][data-platform="desktop"]::before {
|
||||
background-image: url("graphics/desktop-gradient.svg");
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* This prohibits the view to scroll for pages smaller than 122px in width
|
||||
we use this for mobile pip webviews */
|
||||
.no-scroll-body {
|
||||
|
||||
@@ -173,14 +173,6 @@ Please see LICENSE in the repository root for full details.
|
||||
border-color: var(--cpd-color-border-disabled);
|
||||
}
|
||||
|
||||
.checkbox svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox svg * {
|
||||
stroke: var(--stopgap-color-on-solid-accent);
|
||||
}
|
||||
|
||||
.checkboxField input[type="checkbox"]:checked + label > .checkbox {
|
||||
background: var(--cpd-color-text-action-accent);
|
||||
border-color: var(--cpd-color-text-action-accent);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
AudioTrack,
|
||||
type AudioTrackProps,
|
||||
} from "@livekit/components-react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
@@ -40,7 +40,6 @@ export interface MatrixAudioRendererProps {
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const prefixedLogger = logger.getChild("[MatrixAudioRenderer]");
|
||||
/**
|
||||
* Takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible.
|
||||
*
|
||||
@@ -60,6 +59,7 @@ export function LivekitRoomAudioRenderer({
|
||||
validIdentities,
|
||||
muted,
|
||||
}: MatrixAudioRendererProps): ReactNode {
|
||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||
const tracks = useTracks(
|
||||
[
|
||||
Track.Source.Microphone,
|
||||
@@ -80,7 +80,7 @@ export function LivekitRoomAudioRenderer({
|
||||
if (!isValid) {
|
||||
// TODO make sure to also skip the warn logging for the local identity
|
||||
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
|
||||
prefixedLogger.warn(
|
||||
logger.warn(
|
||||
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
|
||||
`current members: ${validIdentities.join()}`,
|
||||
`track will not get rendered`,
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
ProcessorWrapper,
|
||||
supportsBackgroundProcessors,
|
||||
supportsBackgroundProcessors as supportsBackgroundProcessorsLivekitSdk,
|
||||
type BackgroundOptions,
|
||||
} from "@livekit/track-processors";
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type ObservableScope } from "../state/ObservableScope";
|
||||
import { platform } from "../Platform";
|
||||
|
||||
//TODO-MULTI-SFU: This is not yet fully there.
|
||||
// it is a combination of exposing observable and react hooks.
|
||||
@@ -106,6 +107,10 @@ interface Props {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
function supportsBackgroundProcessors(): boolean {
|
||||
return supportsBackgroundProcessorsLivekitSdk() && platform === "desktop";
|
||||
}
|
||||
|
||||
export const ProcessorProvider: FC<Props> = ({ children }) => {
|
||||
// The setting the user wants to have
|
||||
const [blurActivated] = useSetting(backgroundBlurSettings);
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
exports[`RaisedHandIndicator > renders a smaller indicator when miniature is specified 1`] = `
|
||||
<div
|
||||
class="reactionIndicatorWidget"
|
||||
class="_reactionIndicatorWidget_abd277"
|
||||
>
|
||||
<div
|
||||
class="reaction"
|
||||
class="_reaction_abd277"
|
||||
>
|
||||
<span
|
||||
aria-label="Reaction"
|
||||
@@ -22,10 +22,10 @@ exports[`RaisedHandIndicator > renders a smaller indicator when miniature is spe
|
||||
|
||||
exports[`RaisedHandIndicator > renders an indicator when a hand has been raised 1`] = `
|
||||
<div
|
||||
class="reactionIndicatorWidget reactionIndicatorWidgetLarge"
|
||||
class="_reactionIndicatorWidget_abd277 _reactionIndicatorWidgetLarge_abd277"
|
||||
>
|
||||
<div
|
||||
class="reaction reactionLarge"
|
||||
class="_reaction_abd277 _reactionLarge_abd277"
|
||||
>
|
||||
<span
|
||||
aria-label="Reaction"
|
||||
@@ -42,10 +42,10 @@ exports[`RaisedHandIndicator > renders an indicator when a hand has been raised
|
||||
|
||||
exports[`RaisedHandIndicator > renders an indicator when a hand has been raised with the expected time 1`] = `
|
||||
<div
|
||||
class="reactionIndicatorWidget reactionIndicatorWidgetLarge"
|
||||
class="_reactionIndicatorWidget_abd277 _reactionIndicatorWidgetLarge_abd277"
|
||||
>
|
||||
<div
|
||||
class="reaction reactionLarge"
|
||||
class="_reaction_abd277 _reactionLarge_abd277"
|
||||
>
|
||||
<span
|
||||
aria-label="Reaction"
|
||||
|
||||
@@ -14,6 +14,23 @@ Please see LICENSE in the repository root for full details.
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Normally the footer uses a transparent background to allow our expressive
|
||||
page gradients to shine through. However, we sometimes need to visually separate
|
||||
it from the content underneath. If the call layout is overflowing, or if the
|
||||
spotlight tile is maximised and displaying video, apply a gradient background. */
|
||||
.overflowing > .footer,
|
||||
.fixedGrid:has(
|
||||
> .tile[data-maximised="true"]
|
||||
.spotlightItem[data-background="transparent"][data-video-enabled="true"][aria-hidden="false"]
|
||||
)
|
||||
~ .footer {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
var(--cpd-color-bg-canvas-default) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
@@ -82,19 +99,18 @@ Please see LICENSE in the repository root for full details.
|
||||
/* Disable pointer events so the overlay doesn't block interaction with
|
||||
elements behind it */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fixedGrid > :not(:first-child),
|
||||
.scrollingGrid > :not(:first-child) {
|
||||
pointer-events: initial;
|
||||
> :not(:first-child) {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.tile.maximised {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { makeGridLayout } from "../grid/GridLayout";
|
||||
import { type CallLayoutOutputs } from "../grid/CallLayout";
|
||||
import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout";
|
||||
import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout";
|
||||
import { makeOneOnOneDesktopLayout } from "../grid/OneOnOneDesktopLayout";
|
||||
import { makeOneOnOneMobileLayout } from "../grid/OneOnOneMobileLayout";
|
||||
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
||||
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
||||
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
||||
@@ -94,8 +94,6 @@ declare module "react" {
|
||||
}
|
||||
}
|
||||
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
|
||||
export interface ActiveCallProps extends Omit<
|
||||
InCallViewProps,
|
||||
"vm" | "livekitRoom" | "connState" | "footerVm"
|
||||
@@ -116,7 +114,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const mediaDevices = useMediaDevices();
|
||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||
useEffect(() => {
|
||||
logger.info("START CALL VIEW SCOPE");
|
||||
rootLogger.info("START CALL VIEW SCOPE");
|
||||
const scope = new ObservableScope();
|
||||
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
|
||||
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
||||
@@ -218,6 +216,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
muteStates,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
const { t } = useTranslation();
|
||||
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
||||
|
||||
@@ -259,6 +258,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const edgeToEdge = useBehavior(vm.edgeToEdge$);
|
||||
const overflowing = useBehavior(vm.overflowing$);
|
||||
const showNameTags = useBehavior(vm.showNameTags$);
|
||||
const showHeader = useBehavior(vm.showHeader$);
|
||||
const settingsOpen = useBehavior(vm.settingsOpen$);
|
||||
@@ -468,6 +468,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
showRingingStatus={showRingingStatus}
|
||||
focusable={!contentObscured}
|
||||
className={classNames(className, styles.tile)}
|
||||
itemClassName={styles.spotlightItem}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
@@ -482,8 +483,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
||||
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
|
||||
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
|
||||
"one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs),
|
||||
"one-on-one-portrait": makeOneOnOnePortraitLayout(inputs),
|
||||
"one-on-one-desktop": makeOneOnOneDesktopLayout(inputs),
|
||||
"one-on-one-mobile": makeOneOnOneMobileLayout(inputs),
|
||||
};
|
||||
}, [gridBoundsObservable$]);
|
||||
|
||||
@@ -492,7 +493,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
if (layout.type === "pip") {
|
||||
return (
|
||||
<SpotlightTile
|
||||
className={classNames(styles.tile, styles.maximised)}
|
||||
className={styles.tile}
|
||||
itemClassName={styles.spotlightItem}
|
||||
data-maximised
|
||||
vm={layout.spotlight}
|
||||
expanded
|
||||
onToggleExpanded={null}
|
||||
@@ -584,7 +587,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
// Only hide the settings button if we have an AppBar header and we are showing the header
|
||||
const footer = footerVm !== null && (
|
||||
<CallFooter ref={footerRef} vm={footerVm} />
|
||||
<CallFooter className={styles.footer} ref={footerRef} vm={footerVm} />
|
||||
);
|
||||
const allConnections = useBehavior(vm.allConnections$);
|
||||
|
||||
@@ -593,7 +596,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
// and the footer is also viewable by moving focus into it, so this is fine.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
className={classNames(styles.inRoom, {
|
||||
[styles.overflowing]: overflowing,
|
||||
})}
|
||||
ref={containerRef}
|
||||
onPointerUp={onViewPointerUp}
|
||||
onPointerMove={onPointerMove}
|
||||
|
||||
44
src/room/LayoutSwitch.tsx
Normal file
44
src/room/LayoutSwitch.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useId, type FC } from "react";
|
||||
import {
|
||||
SpotlightViewIcon,
|
||||
GridIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch } from "@vector-im/compound-web";
|
||||
|
||||
interface Props {
|
||||
vm: LayoutSwitchViewModel;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LayoutSwitch: FC<Props> = ({ vm, className }) => {
|
||||
const { t } = useTranslation();
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const name = useId();
|
||||
|
||||
return (
|
||||
<Switch<"spotlight", "grid">
|
||||
name={name}
|
||||
aria-label={t("layout_switch_label")}
|
||||
leftLabel={t("layout_spotlight_label")}
|
||||
leftValue="spotlight"
|
||||
leftIcon={SpotlightViewIcon}
|
||||
rightLabel={t("layout_grid_label")}
|
||||
rightValue="grid"
|
||||
rightIcon={GridIcon}
|
||||
className={className}
|
||||
value={layout}
|
||||
onChange={vm.setLayout}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -145,8 +145,7 @@ describe("LobbyView", () => {
|
||||
.querySelector("path")!
|
||||
.getAttribute("d");
|
||||
const primaryButtonSvgPath = container
|
||||
.querySelector(".primaryButton")
|
||||
?.querySelector("path")
|
||||
.querySelector("path")
|
||||
?.getAttribute("d");
|
||||
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
|
||||
expect(container).toMatchSnapshot();
|
||||
@@ -169,8 +168,7 @@ describe("LobbyView", () => {
|
||||
.querySelector("path")!
|
||||
.getAttribute("d");
|
||||
const primaryButtonSvgPath = container
|
||||
.querySelector(".primaryButton")
|
||||
?.querySelector("path")
|
||||
.querySelector("path")
|
||||
?.getAttribute("d");
|
||||
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
|
||||
expect(container).toMatchSnapshot();
|
||||
|
||||
@@ -10,6 +10,8 @@ Please see LICENSE in the repository root for full details.
|
||||
margin-right: var(--content-inset-right);
|
||||
min-block-size: 0;
|
||||
block-size: 50vh;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-width: 100%;
|
||||
border-radius: var(--cpd-space-4x);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -66,12 +68,6 @@ video.mirror {
|
||||
);
|
||||
}
|
||||
|
||||
@media (min-aspect-ratio: 1 / 1) {
|
||||
.preview > video {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
.preview {
|
||||
margin-inline: 0;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TileAvatar } from "../tile/TileAvatar";
|
||||
import styles from "./VideoPreview.module.css";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import videoPlaceholder from "../graphics/video-placeholder.gif";
|
||||
|
||||
export type MatrixInfo = {
|
||||
userId: string;
|
||||
@@ -74,6 +75,9 @@ export const VideoPreview: FC<Props> = ({
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
// Set the placeholder to a small transparent image. (On Android web
|
||||
// views the default poster image is particularly ugly.)
|
||||
poster={videoPlaceholder}
|
||||
/>
|
||||
{(!videoEnabled || cameraIsStarting) && (
|
||||
<>
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -95,20 +95,20 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -161,17 +161,17 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
||||
exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' error correctly 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -253,20 +253,20 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' er
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -318,17 +318,17 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' er
|
||||
exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed' error correctly 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -410,20 +410,20 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed'
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -475,17 +475,17 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed'
|
||||
exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreachable' error correctly 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -567,20 +567,20 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreac
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -632,17 +632,17 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreac
|
||||
exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFound' error correctly 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -724,20 +724,20 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFo
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -789,17 +789,17 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFo
|
||||
exports[`LiveKit ConnectionError variants > should display LiveKit 'timeout' error correctly 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -881,20 +881,20 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'timeout' err
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -946,17 +946,17 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'timeout' err
|
||||
exports[`LiveKit ConnectionError variants > should link to troubleshoot guide when timeout error 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -1038,20 +1038,20 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -1103,17 +1103,17 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
|
||||
exports[`should have a close button in widget mode 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -1195,20 +1195,20 @@ exports[`should have a close button in widget mode 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -1257,17 +1257,17 @@ exports[`should have a close button in widget mode 1`] = `
|
||||
exports[`should render the error page with link back to home 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -1349,20 +1349,20 @@ exports[`should render the error page with link back to home 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -1411,17 +1411,17 @@ exports[`should render the error page with link back to home 1`] = `
|
||||
exports[`should report correct error for 'Call is not supported' 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -1503,20 +1503,20 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -1565,17 +1565,17 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
|
||||
exports[`should report correct error for 'Connection lost' 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -1657,20 +1657,20 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -1723,17 +1723,17 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
||||
exports[`should report correct error for 'Homeserver does not support Matrix 2.…' 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -1815,20 +1815,20 @@ exports[`should report correct error for 'Homeserver does not support Matrix 2.
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -1877,17 +1877,17 @@ exports[`should report correct error for 'Homeserver does not support Matrix 2.
|
||||
exports[`should report correct error for 'Incompatible browser' 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -1969,20 +1969,20 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -2026,17 +2026,17 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
|
||||
exports[`should report correct error for 'Insufficient capacity' 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="page"
|
||||
class="_page_4be5c0"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<a
|
||||
aria-label="Element Call Home"
|
||||
class="headerLogo"
|
||||
class="_headerLogo_e4b327"
|
||||
data-discover="true"
|
||||
href="/"
|
||||
>
|
||||
@@ -2118,20 +2118,20 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_4be5c0"
|
||||
>
|
||||
<div
|
||||
class="error"
|
||||
class="_error_a69dc5"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_a69dc5"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
|
||||
@@ -3,28 +3,28 @@
|
||||
exports[`InCallView > rendering > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="inRoom"
|
||||
class="_inRoom_4e7ff8"
|
||||
>
|
||||
<header
|
||||
class="header header"
|
||||
class="_header_e4b327 _header_4e7ff8"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<div
|
||||
class="roomHeaderInfo"
|
||||
class="_roomHeaderInfo_e4b327"
|
||||
data-size="lg"
|
||||
>
|
||||
<span
|
||||
aria-label=""
|
||||
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
|
||||
class="_avatar_va14e_8 _roomAvatar_e4b327 _avatar-imageless_va14e_55"
|
||||
data-color="1"
|
||||
data-type="round"
|
||||
role="img"
|
||||
style="--cpd-avatar-size: 56px;"
|
||||
/>
|
||||
<div
|
||||
class="nameLine"
|
||||
class="_nameLine_e4b327"
|
||||
>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
@@ -35,7 +35,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
<svg
|
||||
aria-labelledby="_r_0_"
|
||||
class="lock"
|
||||
class="_lock_edc97d"
|
||||
data-encrypted="false"
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
@@ -50,7 +50,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="participantsLine"
|
||||
class="_participantsLine_e4b327"
|
||||
>
|
||||
<svg
|
||||
aria-label="Participants"
|
||||
@@ -80,21 +80,21 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="scrollingGrid grid"
|
||||
class="_scrollingGrid_4e7ff8 _grid_b0d1cd"
|
||||
>
|
||||
<div
|
||||
class="layer"
|
||||
class="_layer_185815"
|
||||
>
|
||||
<div
|
||||
class="container slot"
|
||||
class="_container_185815 _slot_b0d1cd"
|
||||
data-id="1"
|
||||
>
|
||||
<div
|
||||
class="slot local slot"
|
||||
class="_slot_185815 _local_185815 _slot_b0d1cd"
|
||||
data-block-alignment="start"
|
||||
data-id="0"
|
||||
data-inline-alignment="end"
|
||||
@@ -103,22 +103,22 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fixedGrid grid"
|
||||
class="_fixedGrid_4e7ff8 _grid_b0d1cd"
|
||||
style="inset-block-start: NaNpx;"
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
class="bg animate"
|
||||
class="_bg_2f5303 _animate_2f5303"
|
||||
data-state="closed"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="overlay"
|
||||
class="_overlay_eb6724"
|
||||
data-show="false"
|
||||
>
|
||||
<div
|
||||
class="_big-icon_1ssbv_8 icon"
|
||||
class="_big-icon_1ssbv_8 _icon_eb6724"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
>
|
||||
@@ -157,22 +157,22 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
Back to Speaker Mode
|
||||
</button>
|
||||
<div
|
||||
class="spacer"
|
||||
class="_spacer_eb6724"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="container"
|
||||
class="_container_8084b5"
|
||||
/>
|
||||
<div
|
||||
class="footer"
|
||||
class="_footer_4e7ff8 _footer_20b7b4"
|
||||
data-testid="footer-container"
|
||||
>
|
||||
<div
|
||||
class="settingsLogoContainer"
|
||||
class="_settingsLogoContainer_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_8_"
|
||||
class="_icon-button_1215g_8 settingsOnlyShowWide"
|
||||
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
|
||||
data-kind="secondary"
|
||||
data-testid="settings-bottom-left"
|
||||
role="button"
|
||||
@@ -198,7 +198,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="logo"
|
||||
class="_logo_20b7b4"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -303,11 +303,11 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="buttons"
|
||||
class="_buttons_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_d_"
|
||||
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="settings-bottom-center"
|
||||
@@ -382,7 +382,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby="_r_s_"
|
||||
class="_button_1nw83_8 raiseHand _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
class="_button_1nw83_8 _raiseHand_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
@@ -405,7 +405,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_14_"
|
||||
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_leave"
|
||||
@@ -428,12 +428,12 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</div>
|
||||
<fieldset
|
||||
aria-label="Layout"
|
||||
class="_toggle_13rnk_9 layout"
|
||||
class="_toggle_1t5ha_9 _layout_20b7b4"
|
||||
data-size="lg"
|
||||
>
|
||||
<input
|
||||
aria-labelledby="_r_19_"
|
||||
name="layoutMode"
|
||||
aria-labelledby="_r_1a_"
|
||||
name="_r_19_"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
/>
|
||||
@@ -446,13 +446,15 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 5h14v8h-5a1 1 0 0 0-1 1v5H5zm10 14v-4h4v4zM5 21h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2"
|
||||
clip-rule="evenodd"
|
||||
d="M20 6H4v12h16zM4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby="_r_1e_"
|
||||
aria-labelledby="_r_1f_"
|
||||
checked=""
|
||||
name="layoutMode"
|
||||
name="_r_19_"
|
||||
type="radio"
|
||||
value="grid"
|
||||
/>
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="bar"
|
||||
class="_bar_221541"
|
||||
>
|
||||
<header>
|
||||
<button
|
||||
aria-labelledby="_r_36_"
|
||||
class="_icon-button_1215g_8 primaryButton"
|
||||
class="_icon-button_1215g_8 _primaryButton_221541"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
@@ -33,26 +33,27 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="secondaryButton"
|
||||
class="_secondaryButton_221541"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
<div
|
||||
class="inRoom"
|
||||
class="_inRoom_4e7ff8"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_f9ee84"
|
||||
>
|
||||
<div
|
||||
class="preview"
|
||||
class="_preview_dd2178"
|
||||
>
|
||||
<video
|
||||
disablepictureinpicture=""
|
||||
playsinline=""
|
||||
poster="/src/graphics/video-placeholder.gif"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<div
|
||||
class="avatarContainer"
|
||||
class="_avatarContainer_dd2178"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
@@ -68,11 +69,11 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="buttonBar"
|
||||
class="_buttonBar_dd2178"
|
||||
>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
class="_button_1nw83_8 join wait"
|
||||
class="_button_1nw83_8 _join_f9ee84 _wait_f9ee84"
|
||||
data-kind="primary"
|
||||
data-size="md"
|
||||
data-testid="lobby_joinCall"
|
||||
@@ -84,7 +85,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
class="_link_k9ljz_8"
|
||||
class="_link_13esb_8"
|
||||
data-kind="primary"
|
||||
data-size="md"
|
||||
href="/"
|
||||
@@ -94,15 +95,15 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="footer"
|
||||
class="_footer_20b7b4"
|
||||
data-testid="footer-container"
|
||||
>
|
||||
<div
|
||||
class="settingsLogoContainer"
|
||||
class="_settingsLogoContainer_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_3c_"
|
||||
class="_icon-button_1215g_8 settingsOnlyShowWide"
|
||||
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
|
||||
data-kind="secondary"
|
||||
data-testid="settings-bottom-left"
|
||||
role="button"
|
||||
@@ -129,11 +130,11 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="buttons"
|
||||
class="_buttons_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_3h_"
|
||||
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="settings-bottom-center"
|
||||
@@ -205,7 +206,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_40_"
|
||||
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_leave"
|
||||
@@ -234,12 +235,12 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
||||
exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="bar"
|
||||
class="_bar_221541"
|
||||
>
|
||||
<header>
|
||||
<button
|
||||
aria-labelledby="_r_4a_"
|
||||
class="_icon-button_1215g_8 primaryButton"
|
||||
class="_icon-button_1215g_8 _primaryButton_221541"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
@@ -264,26 +265,27 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="secondaryButton"
|
||||
class="_secondaryButton_221541"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
<div
|
||||
class="inRoom"
|
||||
class="_inRoom_4e7ff8"
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_f9ee84"
|
||||
>
|
||||
<div
|
||||
class="preview"
|
||||
class="_preview_dd2178"
|
||||
>
|
||||
<video
|
||||
disablepictureinpicture=""
|
||||
playsinline=""
|
||||
poster="/src/graphics/video-placeholder.gif"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<div
|
||||
class="avatarContainer"
|
||||
class="_avatarContainer_dd2178"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
@@ -299,11 +301,11 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="buttonBar"
|
||||
class="_buttonBar_dd2178"
|
||||
>
|
||||
<button
|
||||
aria-disabled="true"
|
||||
class="_button_1nw83_8 join wait"
|
||||
class="_button_1nw83_8 _join_f9ee84 _wait_f9ee84"
|
||||
data-kind="primary"
|
||||
data-size="md"
|
||||
data-testid="lobby_joinCall"
|
||||
@@ -315,7 +317,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
class="_link_k9ljz_8"
|
||||
class="_link_13esb_8"
|
||||
data-kind="primary"
|
||||
data-size="md"
|
||||
href="/"
|
||||
@@ -325,15 +327,15 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="footer"
|
||||
class="_footer_20b7b4"
|
||||
data-testid="footer-container"
|
||||
>
|
||||
<div
|
||||
class="settingsLogoContainer"
|
||||
class="_settingsLogoContainer_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_4g_"
|
||||
class="_icon-button_1215g_8 settingsOnlyShowWide"
|
||||
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
|
||||
data-kind="secondary"
|
||||
data-testid="settings-bottom-left"
|
||||
role="button"
|
||||
@@ -360,11 +362,11 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="buttons"
|
||||
class="_buttons_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_4l_"
|
||||
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="settings-bottom-center"
|
||||
@@ -436,7 +438,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_54_"
|
||||
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_leave"
|
||||
@@ -465,21 +467,21 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
||||
exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="inRoom"
|
||||
class="_inRoom_4e7ff8"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
class="_header_e4b327"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
class="_nav_e4b327 _leftNav_e4b327"
|
||||
>
|
||||
<div
|
||||
class="roomHeaderInfo"
|
||||
class="_roomHeaderInfo_e4b327"
|
||||
data-size="lg"
|
||||
>
|
||||
<span
|
||||
aria-label="!room:example.org"
|
||||
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
|
||||
class="_avatar_va14e_8 _roomAvatar_e4b327 _avatar-imageless_va14e_55"
|
||||
data-color="3"
|
||||
data-type="round"
|
||||
role="img"
|
||||
@@ -488,7 +490,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
T
|
||||
</span>
|
||||
<div
|
||||
class="nameLine"
|
||||
class="_nameLine_e4b327"
|
||||
>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
@@ -501,7 +503,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
>
|
||||
<svg
|
||||
aria-labelledby="_r_0_"
|
||||
class="lock"
|
||||
class="_lock_edc97d"
|
||||
data-encrypted="false"
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
@@ -516,7 +518,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="participantsLine"
|
||||
class="_participantsLine_e4b327"
|
||||
>
|
||||
<svg
|
||||
aria-label="Participants"
|
||||
@@ -546,22 +548,23 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
class="_nav_e4b327 _rightNav_e4b327"
|
||||
/>
|
||||
</header>
|
||||
<div
|
||||
class="content"
|
||||
class="_content_f9ee84"
|
||||
>
|
||||
<div
|
||||
class="preview"
|
||||
class="_preview_dd2178"
|
||||
>
|
||||
<video
|
||||
disablepictureinpicture=""
|
||||
playsinline=""
|
||||
poster="/src/graphics/video-placeholder.gif"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<div
|
||||
class="avatarContainer"
|
||||
class="_avatarContainer_dd2178"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
@@ -577,10 +580,10 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="buttonBar"
|
||||
class="_buttonBar_dd2178"
|
||||
>
|
||||
<button
|
||||
class="_button_1nw83_8 join"
|
||||
class="_button_1nw83_8 _join_f9ee84"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="lobby_joinCall"
|
||||
@@ -592,7 +595,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
class="_link_k9ljz_8"
|
||||
class="_link_13esb_8"
|
||||
data-kind="primary"
|
||||
data-size="md"
|
||||
href="/"
|
||||
@@ -602,15 +605,15 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="footer"
|
||||
class="_footer_20b7b4"
|
||||
data-testid="footer-container"
|
||||
>
|
||||
<div
|
||||
class="settingsLogoContainer"
|
||||
class="_settingsLogoContainer_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_6_"
|
||||
class="_icon-button_1215g_8 settingsOnlyShowWide"
|
||||
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
|
||||
data-kind="secondary"
|
||||
data-testid="settings-bottom-left"
|
||||
role="button"
|
||||
@@ -636,7 +639,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="logo"
|
||||
class="_logo_20b7b4"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -741,11 +744,11 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="buttons"
|
||||
class="_buttons_20b7b4"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_b_"
|
||||
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="settings-bottom-center"
|
||||
@@ -817,7 +820,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="_r_q_"
|
||||
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
data-testid="incall_leave"
|
||||
|
||||
@@ -18,10 +18,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
Device ID: DEVICE123
|
||||
</p>
|
||||
<div
|
||||
class="fieldRow"
|
||||
class="_fieldRow_1bd8c0"
|
||||
>
|
||||
<div
|
||||
class="field inputField"
|
||||
class="_field_1bd8c0 _inputField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_1_"
|
||||
@@ -38,10 +38,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fieldRow"
|
||||
class="_fieldRow_1bd8c0"
|
||||
>
|
||||
<div
|
||||
class="field checkboxField"
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_2_"
|
||||
@@ -52,7 +52,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
for="debugTileLayout"
|
||||
>
|
||||
<div
|
||||
class="checkbox"
|
||||
class="_checkbox_1bd8c0"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
@@ -75,10 +75,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fieldRow"
|
||||
class="_fieldRow_1bd8c0"
|
||||
>
|
||||
<div
|
||||
class="field checkboxField"
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_3_"
|
||||
@@ -89,7 +89,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
for="showConnectionStats"
|
||||
>
|
||||
<div
|
||||
class="checkbox"
|
||||
class="_checkbox_1bd8c0"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
@@ -112,10 +112,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fieldRow"
|
||||
class="_fieldRow_1bd8c0"
|
||||
>
|
||||
<div
|
||||
class="field checkboxField"
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_4_"
|
||||
@@ -126,7 +126,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
for="muteAllAudio"
|
||||
>
|
||||
<div
|
||||
class="checkbox"
|
||||
class="_checkbox_1bd8c0"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
@@ -150,10 +150,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="fieldRow"
|
||||
class="_fieldRow_1bd8c0"
|
||||
>
|
||||
<div
|
||||
class="field checkboxField"
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_5_"
|
||||
@@ -164,7 +164,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
for="alwaysShowIphoneEarpiece"
|
||||
>
|
||||
<div
|
||||
class="checkbox"
|
||||
class="_checkbox_1bd8c0"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
@@ -187,10 +187,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="fieldRow"
|
||||
class="_fieldRow_1bd8c0"
|
||||
>
|
||||
<div
|
||||
class="field checkboxField"
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_6_"
|
||||
@@ -201,7 +201,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
for="enableLivekitExtendedLogs"
|
||||
>
|
||||
<div
|
||||
class="checkbox"
|
||||
class="_checkbox_1bd8c0"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
@@ -386,7 +386,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
class="livekit_room_box"
|
||||
class="_livekit_room_box_2ddec4"
|
||||
>
|
||||
<h4>
|
||||
LiveKit SFU: wss://local-sfu.example.org
|
||||
@@ -427,7 +427,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
<ul />
|
||||
</div>
|
||||
<div
|
||||
class="livekit_room_box"
|
||||
class="_livekit_room_box_2ddec4"
|
||||
>
|
||||
<h4>
|
||||
LiveKit SFU: wss://remote-sfu.example.org
|
||||
|
||||
@@ -467,7 +467,7 @@ declare global {
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_initStoragePromise: Promise<void> | undefined;
|
||||
}
|
||||
|
||||
export let rageshakeLogger: Logger;
|
||||
/**
|
||||
* Configure rage shaking support for sending bug reports.
|
||||
* Modifies globals.
|
||||
@@ -477,7 +477,8 @@ export async function init(): Promise<void> {
|
||||
global.mx_rage_logger = new ConsoleLogger();
|
||||
|
||||
// configure loglevel based loggers:
|
||||
setLogExtension(logger, global.mx_rage_logger.log);
|
||||
rageshakeLogger = logger;
|
||||
setLogExtension(rageshakeLogger, global.mx_rage_logger.log);
|
||||
|
||||
// intercept console logging so that we can get matrix_sdk logs:
|
||||
// this is nasty, but no logging hooks are provided
|
||||
|
||||
@@ -39,8 +39,6 @@ import { type Behavior } from "../Behavior";
|
||||
import { type Epoch, type ObservableScope } from "../ObservableScope";
|
||||
import { type RoomMemberMap } from "./remoteMembers/MatrixMemberMetadata";
|
||||
|
||||
const logger = rootLogger.getChild("[CallNotificationLifecycle]");
|
||||
|
||||
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
|
||||
|
||||
export interface RingAttempt {
|
||||
@@ -114,6 +112,7 @@ export function createCallNotificationLifecycle$({
|
||||
*/
|
||||
autoLeave$: Observable<AutoLeaveReason>;
|
||||
} {
|
||||
const logger = rootLogger.getChild("[CallNotificationLifecycle]");
|
||||
let ringAttempts$: Observable<RingAttempt> = NEVER;
|
||||
if (options.waitForCallPickup)
|
||||
ringAttempts$ = sentCallNotification$.pipe(
|
||||
|
||||
@@ -144,13 +144,13 @@ export interface SpotlightExpandedLayoutSummary {
|
||||
}
|
||||
|
||||
export interface OneOnOneLandscapeLayoutSummary {
|
||||
type: "one-on-one-landscape";
|
||||
type: "one-on-one-desktop";
|
||||
spotlight: string;
|
||||
pip: string;
|
||||
}
|
||||
|
||||
export interface OneOnOnePortraitLayoutSummary {
|
||||
type: "one-on-one-portrait";
|
||||
type: "one-on-one-mobile";
|
||||
spotlight: string[];
|
||||
pip?: string;
|
||||
pipSize: "sm" | "lg";
|
||||
@@ -205,7 +205,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
pip: pip?.id,
|
||||
}),
|
||||
);
|
||||
case "one-on-one-landscape":
|
||||
case "one-on-one-desktop":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip.media$],
|
||||
(spotlight, pip) => ({
|
||||
@@ -214,7 +214,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
pip: pip.id,
|
||||
}),
|
||||
);
|
||||
case "one-on-one-portrait":
|
||||
case "one-on-one-mobile":
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight.media$,
|
||||
@@ -327,8 +327,8 @@ describe.each([
|
||||
},
|
||||
(vm) => {
|
||||
schedule(modeInputMarbles, {
|
||||
s: () => vm.setGridMode("spotlight"),
|
||||
g: () => vm.setGridMode("grid"),
|
||||
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
|
||||
g: () => vm.layoutSwitchVm$.value!.setLayout("grid"),
|
||||
});
|
||||
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
@@ -437,7 +437,7 @@ describe.each([
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "one-on-one-landscape",
|
||||
type: "one-on-one-desktop",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
@@ -453,7 +453,7 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
test("one-on-one portrait layout shows local tile when video is enabled", () => {
|
||||
test("one-on-one mobile layout shows local tile when video is enabled", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Local participant enables their video, then disables it
|
||||
const videoInputMarbles = " ny--n";
|
||||
@@ -480,19 +480,19 @@ describe.each([
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "one-on-one-portrait",
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one-portrait",
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: `${localId}:0`,
|
||||
pipSize: "lg",
|
||||
},
|
||||
c: {
|
||||
type: "one-on-one-portrait",
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: `${localId}:0`,
|
||||
pipSize: "sm",
|
||||
@@ -504,21 +504,21 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
test("one-on-one portrait layout shows name tags in room with 3 members", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
test("one-on-one mobile layout shows name tags in room with 3 members", () => {
|
||||
withTestScheduler(({ expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: constant([aliceParticipant]),
|
||||
// Both Alice and Bob are with us in the room
|
||||
roomMembers: [local, alice, bob],
|
||||
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||
windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait
|
||||
windowSize$: constant({ width: 380, height: 700 }), // Mobile phone
|
||||
},
|
||||
(vm) => {
|
||||
// Uses one-on-one portrait layout
|
||||
// Uses one-on-one mobile layout
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
||||
a: {
|
||||
type: "one-on-one-portrait",
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
@@ -532,6 +532,65 @@ describe.each([
|
||||
});
|
||||
});
|
||||
|
||||
test("landscape mobile layouts show screen shares and group call participants", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// Starts as a one-on-one call, then Alice shares her screen, then Bob
|
||||
// joins, and finally Alice stops sharing her screen
|
||||
const participantInputMarbles = " a--b";
|
||||
const aliceSharingInputMarbles = " ny-n";
|
||||
// Starts in one-on-one mobile layout, then goes to spotlight layout for
|
||||
// the screen sharing and group call cases
|
||||
const expectedLayoutMarbles = " ab-c";
|
||||
// Whether the layout switch is visible. It should be hidden while in
|
||||
// one-on-one layout.
|
||||
const expectedLayoutSwitchMarbles = "ny--";
|
||||
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: behavior(participantInputMarbles, {
|
||||
a: [aliceParticipant],
|
||||
b: [aliceParticipant, bobParticipant],
|
||||
}),
|
||||
roomMembers: [local, alice, bob],
|
||||
rtcMembers$: behavior(participantInputMarbles, {
|
||||
a: [localRtcMember, aliceRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember, bobRtcMember],
|
||||
}),
|
||||
sharingScreen: new Map([
|
||||
[aliceParticipant, behavior(aliceSharingInputMarbles, yesNo)],
|
||||
]),
|
||||
windowSize$: constant({ width: 700, height: 380 }), // Mobile phone in landscape
|
||||
},
|
||||
(vm) => {
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "sm",
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0:screen-share`],
|
||||
grid: [`${localId}:0`, `${aliceId}:0`],
|
||||
},
|
||||
c: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: [`${localId}:0`, `${bobId}:0`],
|
||||
},
|
||||
},
|
||||
);
|
||||
expectObservable(
|
||||
vm.layoutSwitchVm$.pipe(map((vm) => vm !== null)),
|
||||
).toBe(expectedLayoutSwitchMarbles, yesNo);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("participants stay in the same order unless to appear/disappear", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const visibilityInputMarbles = "a";
|
||||
@@ -706,14 +765,14 @@ describe.each([
|
||||
{
|
||||
a: {
|
||||
// This is the expected one-on-one layout for a narrow window
|
||||
type: "one-on-one-portrait",
|
||||
type: "one-on-one-mobile",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
pip: undefined,
|
||||
pipSize: "lg",
|
||||
},
|
||||
b: {
|
||||
// In a larger window, expect the normal one-on-one layout
|
||||
type: "one-on-one-landscape",
|
||||
// In a larger window, expect the one-on-one desktop layout
|
||||
type: "one-on-one-desktop",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
@@ -763,7 +822,9 @@ describe.each([
|
||||
]),
|
||||
},
|
||||
(vm) => {
|
||||
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
|
||||
schedule(modeInputMarbles, {
|
||||
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
|
||||
});
|
||||
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
@@ -969,7 +1030,7 @@ describe.each([
|
||||
},
|
||||
(vm) => {
|
||||
schedule(modeInputMarbles, {
|
||||
s: () => vm.setGridMode("spotlight"),
|
||||
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
|
||||
});
|
||||
schedule(expandInputMarbles, {
|
||||
a: () => vm.toggleSpotlightExpanded$.value!(),
|
||||
@@ -1039,7 +1100,7 @@ describe.each([
|
||||
},
|
||||
(vm) => {
|
||||
schedule(modeInputMarbles, {
|
||||
s: () => vm.setGridMode("spotlight"),
|
||||
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
|
||||
});
|
||||
schedule(expandInputMarbles, {
|
||||
a: () => vm.toggleSpotlightExpanded$.value!(),
|
||||
@@ -1066,7 +1127,7 @@ describe.each([
|
||||
});
|
||||
|
||||
test("expanded spotlight layout hides PiP tile in one-on-one voice call", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: constant([aliceParticipant]),
|
||||
@@ -1076,9 +1137,15 @@ describe.each([
|
||||
[localParticipant, constant(false)],
|
||||
[aliceParticipant, constant(false)],
|
||||
]),
|
||||
windowSize$: constant({ width: 700, height: 380 }), // Mobile phone in landscape
|
||||
},
|
||||
(vm) => {
|
||||
schedule("s", {
|
||||
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
|
||||
});
|
||||
schedule("a", {
|
||||
a: () => vm.toggleSpotlightExpanded$.value!(),
|
||||
});
|
||||
|
||||
// Layout should show remote tile only
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
||||
a: {
|
||||
@@ -1110,8 +1177,8 @@ describe.each([
|
||||
},
|
||||
(vm) => {
|
||||
schedule(modeInputMarbles, {
|
||||
s: () => vm.setGridMode("spotlight"),
|
||||
g: () => vm.setGridMode("grid"),
|
||||
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
|
||||
g: () => vm.layoutSwitchVm$.value!.setLayout("grid"),
|
||||
});
|
||||
schedule(expandInputMarbles, {
|
||||
a: () => vm.toggleSpotlightExpanded$.value!(),
|
||||
@@ -1177,7 +1244,7 @@ describe.each([
|
||||
]),
|
||||
},
|
||||
(vm) => {
|
||||
vm.setGridMode("grid");
|
||||
vm.layoutSwitchVm$.value!.setLayout("grid");
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
@@ -1187,7 +1254,7 @@ describe.each([
|
||||
grid: [`${localId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one-landscape",
|
||||
type: "one-on-one-desktop",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
@@ -1220,7 +1287,7 @@ describe.each([
|
||||
}),
|
||||
},
|
||||
(vm) => {
|
||||
vm.setGridMode("grid");
|
||||
vm.layoutSwitchVm$.value!.setLayout("grid");
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
@@ -1230,7 +1297,7 @@ describe.each([
|
||||
grid: [`${localId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one-landscape",
|
||||
type: "one-on-one-desktop",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
@@ -1240,7 +1307,7 @@ describe.each([
|
||||
grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "one-on-one-landscape",
|
||||
type: "one-on-one-desktop",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${daveId}:0`,
|
||||
},
|
||||
@@ -1464,7 +1531,7 @@ describe.each([
|
||||
// ringing the entire time (even once timed out)
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
|
||||
a: {
|
||||
type: "one-on-one-landscape",
|
||||
type: "one-on-one-desktop",
|
||||
spotlight: `${localId}:0`,
|
||||
pip: `ringing:${aliceUserId}`,
|
||||
},
|
||||
@@ -1510,12 +1577,12 @@ describe.each([
|
||||
// ringing the entire time
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {
|
||||
a: {
|
||||
type: "one-on-one-landscape",
|
||||
type: "one-on-one-desktop",
|
||||
spotlight: `${localId}:0`,
|
||||
pip: `ringing:${aliceUserId}`,
|
||||
},
|
||||
b: {
|
||||
type: "one-on-one-landscape",
|
||||
type: "one-on-one-desktop",
|
||||
spotlight: `${aliceId}:0`,
|
||||
pip: `${localId}:0`,
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
timer,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type LivekitTransportConfig,
|
||||
@@ -70,8 +70,8 @@ import { setPipEnabled$ } from "../../controls";
|
||||
import { TileStore } from "../TileStore";
|
||||
import { gridLikeLayout } from "../GridLikeLayout";
|
||||
import { spotlightExpandedLayout } from "../SpotlightExpandedLayout";
|
||||
import { oneOnOneLandscapeLayout } from "../OneOnOneLandscapeLayout";
|
||||
import { oneOnOnePortraitLayout } from "../OneOnOnePortraitLayout";
|
||||
import { oneOnOneDesktopLayout } from "../OneOnOneDesktopLayout";
|
||||
import { oneOnOneMobileLayout } from "../OneOnOneMobileLayout";
|
||||
import { pipLayout } from "../PipLayout";
|
||||
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement";
|
||||
import {
|
||||
@@ -93,8 +93,8 @@ import {
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
type OneOnOneLandscapeLayoutMedia,
|
||||
type OneOnOnePortraitLayoutMedia,
|
||||
type OneOnOneDesktopLayoutMedia,
|
||||
type OneOnOneMobileLayoutMedia,
|
||||
type SpotlightExpandedLayoutMedia,
|
||||
type SpotlightLandscapeLayoutMedia,
|
||||
type SpotlightPortraitLayoutMedia,
|
||||
@@ -141,7 +141,10 @@ import {
|
||||
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
||||
import { Publisher } from "./localMember/Publisher.ts";
|
||||
import { type Connection } from "./remoteMembers/Connection.ts";
|
||||
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
||||
import {
|
||||
type LayoutSwitchViewModel,
|
||||
createLayoutSwitchViewModel,
|
||||
} from "../LayoutSwitchViewModel.ts";
|
||||
import {
|
||||
createWrappedUserMedia,
|
||||
type WrappedUserMediaViewModel,
|
||||
@@ -157,7 +160,6 @@ import {
|
||||
} from "../media/RingingMediaViewModel.ts";
|
||||
import { type GridTileViewModel } from "../TileViewModel.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
//TODO
|
||||
// Larger rename
|
||||
// member,membership -> rtcMember
|
||||
@@ -202,12 +204,11 @@ const smallMobileCallThreshold = 3;
|
||||
// with the interface
|
||||
const showFooterMs = 4000;
|
||||
|
||||
export type GridMode = "grid" | "spotlight";
|
||||
|
||||
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||
|
||||
interface LayoutScanState {
|
||||
layout: Layout | null;
|
||||
overflowing: boolean;
|
||||
tiles: TileStore;
|
||||
}
|
||||
|
||||
@@ -349,8 +350,7 @@ export interface CallViewModel {
|
||||
showNameTags$: Behavior<boolean>;
|
||||
spotlightExpanded$: Behavior<boolean>;
|
||||
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
|
||||
gridMode$: Behavior<GridMode>;
|
||||
setGridMode: (value: GridMode) => void;
|
||||
layoutSwitchVm$: Behavior<LayoutSwitchViewModel | null>;
|
||||
|
||||
// header/footer visibility
|
||||
showHeader$: Behavior<boolean>;
|
||||
@@ -360,6 +360,10 @@ export interface CallViewModel {
|
||||
* and header as overlays.
|
||||
*/
|
||||
edgeToEdge$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether the call layout is overflowing the interface (causing it to scroll).
|
||||
*/
|
||||
overflowing$: Behavior<boolean>;
|
||||
|
||||
settingsOpen$: Behavior<boolean>;
|
||||
setSettingsOpen$: Behavior<(open: boolean) => void>;
|
||||
@@ -411,6 +415,7 @@ export function createCallViewModel$(
|
||||
reactionsSubject$: Observable<Record<string, ReactionInfo>>,
|
||||
trackProcessorState$: Behavior<ProcessorState>,
|
||||
): CallViewModel {
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
const client = matrixRoom.client;
|
||||
const userId = client.getUserId();
|
||||
const deviceId = client.getDeviceId();
|
||||
@@ -420,6 +425,7 @@ export function createCallViewModel$(
|
||||
const livekitKeyProvider = getE2eeKeyProvider(
|
||||
options.encryptionSystem,
|
||||
matrixRTCSession,
|
||||
logger,
|
||||
);
|
||||
// matrix_rtc_mode in config.json overrides the user's Developer Settings choice.
|
||||
// It is validated at config load (src/config/Config.ts) so the cast is safe.
|
||||
@@ -1050,7 +1056,7 @@ export function createCallViewModel$(
|
||||
spotlightExpandedToggle$,
|
||||
);
|
||||
|
||||
const { setGridMode, gridMode$ } = createLayoutModeSwitch(
|
||||
const layoutSwitchVm = createLayoutSwitchViewModel(
|
||||
scope,
|
||||
windowMode$,
|
||||
hasRemoteScreenShares$,
|
||||
@@ -1102,56 +1108,60 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const oneOnOneLayoutMedia$: Observable<{
|
||||
const oneOnOneLayoutMedia$: Behavior<{
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: UserMediaViewModel | RingingMediaViewModel;
|
||||
} | null> = combineLatest([userMedia$, screenShares$]).pipe(
|
||||
switchMap(([userMedia, screenShares]) => {
|
||||
// One-on-one layout only supports 2 user media, no screen shares
|
||||
if (userMedia.length <= 2 && screenShares.length === 0) {
|
||||
const local = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||
vm.type === "user" && vm.local,
|
||||
);
|
||||
|
||||
if (local !== undefined) {
|
||||
const remote = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
|
||||
vm.type === "user" && !vm.local,
|
||||
} | null> = scope.behavior(
|
||||
combineLatest([userMedia$, screenShares$]).pipe(
|
||||
switchMap(([userMedia, screenShares]) => {
|
||||
// One-on-one layout only supports 2 user media, no screen shares
|
||||
if (userMedia.length <= 2 && screenShares.length === 0) {
|
||||
const local = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||
vm.type === "user" && vm.local,
|
||||
);
|
||||
|
||||
if (remote !== undefined) return of({ local, remote });
|
||||
|
||||
// If there's no other user media in the call (could still happen in
|
||||
// this branch due to the duplicate tiles option), we could possibly
|
||||
// show ringing media instead
|
||||
if (userMedia.length === 1)
|
||||
return ringingMedia$.pipe(
|
||||
map(
|
||||
(ringingMedia) =>
|
||||
ringingMedia && { local, remote: ringingMedia },
|
||||
),
|
||||
if (local !== undefined) {
|
||||
const remote = userMedia.find(
|
||||
(
|
||||
vm,
|
||||
): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel =>
|
||||
vm.type === "user" && !vm.local,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return of(null);
|
||||
}),
|
||||
if (remote !== undefined) return of({ local, remote });
|
||||
|
||||
// If there's no other user media in the call (could still happen in
|
||||
// this branch due to the duplicate tiles option), we could possibly
|
||||
// show ringing media instead
|
||||
if (userMedia.length === 1)
|
||||
return ringingMedia$.pipe(
|
||||
map(
|
||||
(ringingMedia) =>
|
||||
ringingMedia && { local, remote: ringingMedia },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return of(null);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const oneOnOneLandscapeLayoutMedia$: Observable<OneOnOneLandscapeLayoutMedia | null> =
|
||||
const oneOnOneDesktopLayoutMedia$: Observable<OneOnOneDesktopLayoutMedia | null> =
|
||||
oneOnOneLayoutMedia$.pipe(
|
||||
map((media) => {
|
||||
if (media === null) return null;
|
||||
return media.remote.type === "ringing"
|
||||
? {
|
||||
type: "one-on-one-landscape" as const,
|
||||
type: "one-on-one-desktop" as const,
|
||||
edgeToEdge: false,
|
||||
spotlight: media.local,
|
||||
pip: media.remote,
|
||||
}
|
||||
: {
|
||||
type: "one-on-one-landscape" as const,
|
||||
type: "one-on-one-desktop" as const,
|
||||
edgeToEdge: false,
|
||||
spotlight: media.remote,
|
||||
pip: media.local,
|
||||
@@ -1159,13 +1169,13 @@ export function createCallViewModel$(
|
||||
}),
|
||||
);
|
||||
|
||||
const oneOnOnePortraitLayoutMedia$: Observable<OneOnOnePortraitLayoutMedia | null> =
|
||||
const oneOnOneMobileLayoutMedia$: Observable<OneOnOneMobileLayoutMedia | null> =
|
||||
oneOnOneLayoutMedia$.pipe(
|
||||
switchMap((media) => {
|
||||
if (media === null) return of(null);
|
||||
return media.local.videoEnabled$.pipe(
|
||||
map((videoEnabled) => ({
|
||||
type: "one-on-one-portrait" as const,
|
||||
type: "one-on-one-mobile" as const,
|
||||
edgeToEdge: true as const,
|
||||
spotlight: media.remote,
|
||||
pip: videoEnabled ? media.local : undefined,
|
||||
@@ -1217,11 +1227,11 @@ export function createCallViewModel$(
|
||||
switchMap((windowMode) => {
|
||||
switch (windowMode) {
|
||||
case "normal":
|
||||
return gridMode$.pipe(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
return layoutSwitchVm.layout$.pipe(
|
||||
switchMap((layout) => {
|
||||
switch (layout) {
|
||||
case "grid":
|
||||
return oneOnOneLandscapeLayoutMedia$.pipe(
|
||||
return oneOnOneDesktopLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
|
||||
),
|
||||
@@ -1238,7 +1248,7 @@ export function createCallViewModel$(
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return oneOnOnePortraitLayoutMedia$.pipe(
|
||||
return oneOnOneMobileLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
||||
@@ -1251,17 +1261,23 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
return gridMode$.pipe(
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return spotlightLandscapeLayoutMedia$(true);
|
||||
case "spotlight":
|
||||
return spotlightExpandedLayoutMedia$(true);
|
||||
}
|
||||
}),
|
||||
return oneOnOneMobileLayoutMedia$.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne === null
|
||||
? layoutSwitchVm.layout$.pipe(
|
||||
switchMap((layout) => {
|
||||
switch (layout) {
|
||||
case "grid":
|
||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
||||
// this window mode.
|
||||
return spotlightLandscapeLayoutMedia$(true);
|
||||
case "spotlight":
|
||||
return spotlightExpandedLayoutMedia$(true);
|
||||
}
|
||||
}),
|
||||
)
|
||||
: of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "pip":
|
||||
return pipLayoutMedia$;
|
||||
@@ -1289,8 +1305,8 @@ export function createCallViewModel$(
|
||||
// indicators. And in one-on-one layout there's no question as to who is
|
||||
// speaking.
|
||||
case "spotlight-expanded":
|
||||
case "one-on-one-landscape":
|
||||
case "one-on-one-portrait":
|
||||
case "one-on-one-desktop":
|
||||
case "one-on-one-mobile":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
@@ -1302,7 +1318,7 @@ export function createCallViewModel$(
|
||||
const showNameTags$ = scope.behavior<boolean>(
|
||||
layoutMedia$.pipe(
|
||||
switchMap((l) =>
|
||||
l.type === "pip" || l.type === "one-on-one-portrait"
|
||||
l.type === "pip" || l.type === "one-on-one-mobile"
|
||||
? matrixRoomMembers$.pipe(
|
||||
map(
|
||||
(members) =>
|
||||
@@ -1346,6 +1362,22 @@ export function createCallViewModel$(
|
||||
layoutMedia$.pipe(map(({ edgeToEdge }) => edgeToEdge)),
|
||||
);
|
||||
|
||||
// Only show the layout switch in cases where it has an effect on the layout
|
||||
const showLayoutSwitch$ = windowMode$.pipe(
|
||||
switchMap((windowMode) => {
|
||||
switch (windowMode) {
|
||||
case "normal":
|
||||
return of(true);
|
||||
case "flat":
|
||||
return oneOnOneLayoutMedia$.pipe(
|
||||
map((oneOnOne) => oneOnOne === null),
|
||||
);
|
||||
default:
|
||||
return of(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const screenTap$ = new Subject<void>();
|
||||
const controlsTap$ = new Subject<void>();
|
||||
const screenHover$ = new Subject<void>();
|
||||
@@ -1467,7 +1499,7 @@ export function createCallViewModel$(
|
||||
|
||||
// There is a cyclical dependency here: the layout algorithms want to know
|
||||
// which tiles are on screen, but to know which tiles are on screen we have to
|
||||
// first render a layout. To deal with this we assume initially that no tiles
|
||||
// first render a layout. To deal with this we assume initially that all tiles
|
||||
// are visible, and loop the data back into the layouts with a Subject.
|
||||
const visibleTiles$ = new Subject<number>();
|
||||
const setVisibleTiles = (value: number): void => visibleTiles$.next(value);
|
||||
@@ -1475,7 +1507,7 @@ export function createCallViewModel$(
|
||||
const layoutInternals$ = scope.behavior<LayoutScanState & { layout: Layout }>(
|
||||
combineLatest([
|
||||
layoutMedia$,
|
||||
visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
|
||||
visibleTiles$.pipe(startWith(Infinity), distinctUntilChanged()),
|
||||
]).pipe(
|
||||
scan<
|
||||
[LayoutMedia, number],
|
||||
@@ -1486,6 +1518,7 @@ export function createCallViewModel$(
|
||||
let layout: Layout;
|
||||
let newTiles: TileStore;
|
||||
let pip: GridTileViewModel | undefined;
|
||||
let overflowing = false;
|
||||
switch (media.type) {
|
||||
case "grid":
|
||||
case "spotlight-landscape":
|
||||
@@ -1497,6 +1530,7 @@ export function createCallViewModel$(
|
||||
setVisibleTiles,
|
||||
prevTiles,
|
||||
);
|
||||
overflowing = newTiles.gridTiles.length > visibleTiles;
|
||||
break;
|
||||
case "spotlight-expanded":
|
||||
[layout, newTiles] = spotlightExpandedLayout(
|
||||
@@ -1505,16 +1539,16 @@ export function createCallViewModel$(
|
||||
prevTiles,
|
||||
);
|
||||
break;
|
||||
case "one-on-one-landscape":
|
||||
[layout, newTiles] = oneOnOneLandscapeLayout(
|
||||
case "one-on-one-desktop":
|
||||
[layout, newTiles] = oneOnOneDesktopLayout(
|
||||
media,
|
||||
landscapePipAlignment$,
|
||||
prevTiles,
|
||||
);
|
||||
pip = layout.pip;
|
||||
break;
|
||||
case "one-on-one-portrait":
|
||||
[layout, newTiles] = oneOnOnePortraitLayout(
|
||||
case "one-on-one-mobile":
|
||||
[layout, newTiles] = oneOnOneMobileLayout(
|
||||
media,
|
||||
portraitPipSize$,
|
||||
portraitPipAlignment$,
|
||||
@@ -1531,9 +1565,9 @@ export function createCallViewModel$(
|
||||
tile.setShowOutline(tile === pip);
|
||||
}
|
||||
|
||||
return { layout, tiles: newTiles };
|
||||
return { layout, overflowing, tiles: newTiles };
|
||||
},
|
||||
{ layout: null, tiles: TileStore.empty() },
|
||||
{ layout: null, overflowing: false, tiles: TileStore.empty() },
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1545,6 +1579,10 @@ export function createCallViewModel$(
|
||||
layoutInternals$.pipe(map(({ layout }) => layout)),
|
||||
);
|
||||
|
||||
const overflowing$ = scope.behavior<boolean>(
|
||||
layoutInternals$.pipe(map(({ overflowing }) => overflowing)),
|
||||
);
|
||||
|
||||
/**
|
||||
* The current generation of the tile store, exposed for debugging purposes.
|
||||
*/
|
||||
@@ -1757,8 +1795,9 @@ export function createCallViewModel$(
|
||||
|
||||
spotlightExpanded$: spotlightExpanded$,
|
||||
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
|
||||
gridMode$: gridMode$,
|
||||
setGridMode: setGridMode,
|
||||
layoutSwitchVm$: scope.behavior(
|
||||
showLayoutSwitch$.pipe(map((show) => (show ? layoutSwitchVm : null))),
|
||||
),
|
||||
layout$: layout$,
|
||||
localMatrixLivekitMember$,
|
||||
remoteMatrixLivekitMembers$: scope.behavior(
|
||||
@@ -1786,6 +1825,7 @@ export function createCallViewModel$(
|
||||
settingsOpen$: settingsOpen$,
|
||||
setSettingsOpen$: setSettingsOpen$,
|
||||
edgeToEdge$,
|
||||
overflowing$,
|
||||
earpieceMode$: earpieceMode$,
|
||||
audioOutputSwitcher$: audioOutputSwitcher$,
|
||||
reconnecting$: localMembership.reconnecting$,
|
||||
@@ -1797,6 +1837,7 @@ export function createCallViewModel$(
|
||||
function getE2eeKeyProvider(
|
||||
e2eeSystem: EncryptionSystem,
|
||||
rtcSession: MatrixRTCSession,
|
||||
logger: Logger,
|
||||
): BaseKeyProvider | undefined {
|
||||
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
combineLatest,
|
||||
map,
|
||||
Subject,
|
||||
startWith,
|
||||
skipWhile,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { type GridMode, type WindowMode } from "./CallViewModel.ts";
|
||||
import { constant, type Behavior } from "../Behavior.ts";
|
||||
import { type ObservableScope } from "../ObservableScope.ts";
|
||||
|
||||
/**
|
||||
* Creates a layout mode switch that allows switching between grid and spotlight modes.
|
||||
* The actual layout mode might switch automatically to spotlight if there is a
|
||||
* remote screen share active or if the window mode is flat.
|
||||
*
|
||||
* @param scope - The observable scope to manage subscriptions.
|
||||
* @param windowMode$ - The current window mode.
|
||||
* @param hasRemoteScreenShares$ - A behavior indicating if there are remote screen shares active.
|
||||
*/
|
||||
export function createLayoutModeSwitch(
|
||||
scope: ObservableScope,
|
||||
windowMode$: Behavior<WindowMode>,
|
||||
hasRemoteScreenShares$: Behavior<boolean>,
|
||||
): {
|
||||
gridMode$: Behavior<GridMode>;
|
||||
setGridMode: (value: GridMode) => void;
|
||||
} {
|
||||
const userSelection$ = new Subject<GridMode>();
|
||||
// Callback to set the grid mode desired by the user.
|
||||
// Notice that this is only a preference, the actual grid mode can be overridden
|
||||
// if there is a remote screen share active.
|
||||
const setGridMode = (value: GridMode): void => userSelection$.next(value);
|
||||
|
||||
/**
|
||||
* The natural grid mode - the mode that the grid would prefer to be in,
|
||||
* not accounting for the user's manual selections.
|
||||
*/
|
||||
const naturalGridMode$ = scope.behavior<GridMode>(
|
||||
combineLatest(
|
||||
[hasRemoteScreenShares$, windowMode$],
|
||||
(hasRemoteScreenShares, windowMode) =>
|
||||
// When there are screen shares or the window is flat (as with a phone
|
||||
// in landscape orientation), spotlight is a better experience.
|
||||
// We want screen shares to be big and readable, and we want flipping
|
||||
// your phone into landscape to be a quick way of maximising the
|
||||
// spotlight tile.
|
||||
hasRemoteScreenShares || windowMode === "flat" ? "spotlight" : "grid",
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The layout mode of the media tile grid.
|
||||
*/
|
||||
const gridMode$ = scope.behavior<GridMode>(
|
||||
// Whenever the user makes a selection, we enter a new mode of behavior:
|
||||
userSelection$.pipe(
|
||||
map((selection) => {
|
||||
if (selection === "grid")
|
||||
// The user has selected grid mode. Start by respecting their choice,
|
||||
// but then follow the natural mode again as soon as it matches.
|
||||
return naturalGridMode$.pipe(
|
||||
skipWhile((naturalMode) => naturalMode !== selection),
|
||||
startWith(selection),
|
||||
);
|
||||
|
||||
// The user has selected spotlight mode. If this matches the natural
|
||||
// mode, then follow the natural mode going forward.
|
||||
return selection === naturalGridMode$.value
|
||||
? naturalGridMode$
|
||||
: constant(selection);
|
||||
}),
|
||||
// Initially the mode of behavior is to just follow the natural grid mode.
|
||||
startWith(naturalGridMode$),
|
||||
// Switch between each mode of behavior.
|
||||
switchMap((mode$) => mode$),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
gridMode$,
|
||||
setGridMode,
|
||||
};
|
||||
}
|
||||
@@ -31,11 +31,6 @@ import { type ObservableScope } from "../../ObservableScope";
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { type NodeStyleEventEmitter } from "../../../utils/test";
|
||||
|
||||
/**
|
||||
* Logger instance (scoped child) for homeserver connection updates.
|
||||
*/
|
||||
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||
|
||||
export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft";
|
||||
|
||||
export interface HomeserverConnected {
|
||||
@@ -70,6 +65,7 @@ export function createHomeserverConnected$(
|
||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||
gracePeriodMs?: number,
|
||||
): HomeserverConnected {
|
||||
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||
// Get grace period from parameter or config (default 10000ms)
|
||||
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
@@ -46,8 +46,6 @@ import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers
|
||||
import { customLivekitUrl } from "../../../settings/settings.ts";
|
||||
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[LocalTransport]");
|
||||
|
||||
/*
|
||||
* It figures out “which LiveKit focus URL/alias the local user should use,”
|
||||
* optionally aligning with the oldest member, and ensures the SFU path is primed
|
||||
@@ -140,9 +138,14 @@ export const createLocalTransport$ = ({
|
||||
forceJwtEndpoint,
|
||||
delayId$,
|
||||
}: Props): LocalTransport => {
|
||||
const logger = rootLogger.getChild("[LocalTransport]");
|
||||
// The LiveKit transport in use by the oldest RTC membership. `null` when the
|
||||
// oldest member has no such transport.
|
||||
const oldestMemberTransport$ = observerOldestMembership$(scope, memberships$);
|
||||
const oldestMemberTransport$ = observerOldestMembership$(
|
||||
scope,
|
||||
memberships$,
|
||||
logger,
|
||||
);
|
||||
|
||||
const transportDiscovery = new RtcTransportAutoDiscovery({
|
||||
client: client,
|
||||
@@ -190,6 +193,7 @@ export const createLocalTransport$ = ({
|
||||
roomId,
|
||||
client,
|
||||
delayId ?? undefined,
|
||||
logger,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
@@ -209,6 +213,7 @@ export const createLocalTransport$ = ({
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,6 +253,7 @@ export const createLocalTransport$ = ({
|
||||
function observerOldestMembership$(
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>,
|
||||
logger: Logger,
|
||||
): Behavior<LivekitTransportConfig | null> {
|
||||
return scope.behavior<LivekitTransportConfig | null>(
|
||||
memberships$.pipe(
|
||||
@@ -307,6 +313,7 @@ async function doOpenIdAndJWTFromUrl(
|
||||
> &
|
||||
OpenIDClientParts,
|
||||
delayId?: string,
|
||||
logger?: Logger,
|
||||
): Promise<LocalTransportWithSFUConfig> {
|
||||
const sfuConfig = await getSFUConfigWithOpenID(
|
||||
client,
|
||||
@@ -337,6 +344,7 @@ function observeLocalTransportForOldestMembership(
|
||||
OpenIDClientParts,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
roomId: string,
|
||||
logger: Logger,
|
||||
): LocalTransport {
|
||||
// Ensure we can authenticate with the SFU.
|
||||
const authenticatedOldestMemberTransport$ = oldestMemberTransport$.pipe(
|
||||
@@ -355,6 +363,7 @@ function observeLocalTransportForOldestMembership(
|
||||
roomId,
|
||||
client,
|
||||
undefined,
|
||||
logger,
|
||||
),
|
||||
).pipe(
|
||||
catchError((e: unknown) => {
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
} from "../../../utils/displayname";
|
||||
import { type Behavior } from "../../Behavior";
|
||||
|
||||
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
|
||||
|
||||
export type RoomMemberMap = Map<
|
||||
string,
|
||||
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
|
||||
@@ -67,6 +65,7 @@ export const memberDisplaynames$ = (
|
||||
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||
roomMembers$: Behavior<RoomMemberMap>,
|
||||
): Behavior<Map<string, string>> => {
|
||||
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
|
||||
// This map tracks userIds that at some point needed disambiguation.
|
||||
// This is a memory leak bound to the number of participants.
|
||||
// A call application will always increase the memory if there have been more members in a call.
|
||||
|
||||
@@ -7,31 +7,31 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { createLayoutModeSwitch } from "./LayoutSwitch";
|
||||
import { testScope, withTestScheduler } from "../../utils/test";
|
||||
import { createLayoutSwitchViewModel } from "./LayoutSwitchViewModel";
|
||||
import { testScope, withTestScheduler } from "../utils/test";
|
||||
|
||||
function testLayoutSwitch({
|
||||
windowMode = "n",
|
||||
hasScreenShares = "n",
|
||||
userSelection = "",
|
||||
expectedGridMode,
|
||||
expectedLayout,
|
||||
}: {
|
||||
windowMode?: string;
|
||||
hasScreenShares?: string;
|
||||
userSelection?: string;
|
||||
expectedGridMode: string;
|
||||
expectedLayout: string;
|
||||
}): void {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
||||
const { layout$, setLayout } = createLayoutSwitchViewModel(
|
||||
testScope(),
|
||||
behavior(windowMode, { n: "normal", N: "narrow", f: "flat" }),
|
||||
behavior(hasScreenShares, { y: true, n: false }),
|
||||
);
|
||||
schedule(userSelection, {
|
||||
g: () => setGridMode("grid"),
|
||||
s: () => setGridMode("spotlight"),
|
||||
g: () => setLayout("grid"),
|
||||
s: () => setLayout("spotlight"),
|
||||
});
|
||||
expectObservable(gridMode$).toBe(expectedGridMode, {
|
||||
expectObservable(layout$).toBe(expectedLayout, {
|
||||
g: "grid",
|
||||
s: "spotlight",
|
||||
});
|
||||
@@ -39,94 +39,88 @@ function testLayoutSwitch({
|
||||
}
|
||||
|
||||
describe("default mode", () => {
|
||||
test("uses grid layout by default", () =>
|
||||
test("uses grid layout in normal window", () =>
|
||||
testLayoutSwitch({
|
||||
expectedGridMode: "g",
|
||||
windowMode: " n",
|
||||
expectedLayout: "g",
|
||||
}));
|
||||
|
||||
test("uses spotlight mode when window mode is flat", () =>
|
||||
test("uses grid layout in flat window", () =>
|
||||
testLayoutSwitch({
|
||||
windowMode: " f",
|
||||
expectedGridMode: "s",
|
||||
windowMode: " f",
|
||||
expectedLayout: "g",
|
||||
}));
|
||||
});
|
||||
|
||||
test("allows switching modes manually", () =>
|
||||
testLayoutSwitch({
|
||||
userSelection: " --sgs",
|
||||
expectedGridMode: "g-sgs",
|
||||
userSelection: " --sgs",
|
||||
expectedLayout: "g-sgs",
|
||||
}));
|
||||
|
||||
test("switches to spotlight mode when there is a remote screen share", () =>
|
||||
testLayoutSwitch({
|
||||
hasScreenShares: " n--y",
|
||||
expectedGridMode: "g--s",
|
||||
hasScreenShares: "n--y",
|
||||
expectedLayout: " g--s",
|
||||
}));
|
||||
|
||||
test("can manually switch to grid when there is a screenshare", () =>
|
||||
testLayoutSwitch({
|
||||
hasScreenShares: " n-y",
|
||||
userSelection: " ---g",
|
||||
expectedGridMode: "g-sg",
|
||||
hasScreenShares: "n-y",
|
||||
userSelection: " ---g",
|
||||
expectedLayout: " g-sg",
|
||||
}));
|
||||
|
||||
test("auto-switches after manually selecting grid", () =>
|
||||
testLayoutSwitch({
|
||||
// Two screenshares will happen in sequence. There is a screen share that
|
||||
// forces spotlight, then the user manually switches back to grid.
|
||||
hasScreenShares: " n-y-ny",
|
||||
userSelection: " ---g",
|
||||
expectedGridMode: "g-sg-s",
|
||||
hasScreenShares: "n-y-ny",
|
||||
userSelection: " ---g",
|
||||
expectedLayout: " g-sg-s",
|
||||
// If we did want to respect manual selection, the expectation would be: g-sg
|
||||
}));
|
||||
|
||||
test("switches back to grid mode when the remote screen share ends", () =>
|
||||
testLayoutSwitch({
|
||||
hasScreenShares: " n--y--n",
|
||||
expectedGridMode: "g--s--g",
|
||||
hasScreenShares: "n--y--n",
|
||||
expectedLayout: " g--s--g",
|
||||
}));
|
||||
|
||||
test("auto-switches to spotlight again after first screen share ends", () =>
|
||||
testLayoutSwitch({
|
||||
hasScreenShares: " nyny",
|
||||
expectedGridMode: "gsgs",
|
||||
hasScreenShares: "nyny",
|
||||
expectedLayout: " gsgs",
|
||||
}));
|
||||
|
||||
test("switches manually to grid after screen share while manually in spotlight", () =>
|
||||
testLayoutSwitch({
|
||||
// Initially, no one is sharing. Then the user manually switches to spotlight.
|
||||
// After a screen share starts, the user manually switches to grid.
|
||||
hasScreenShares: " n-y",
|
||||
userSelection: " -s-g",
|
||||
expectedGridMode: "gs-g",
|
||||
}));
|
||||
|
||||
test("auto-switches to spotlight when in flat window mode", () =>
|
||||
testLayoutSwitch({
|
||||
// First normal, then narrow, then flat.
|
||||
windowMode: " nNf",
|
||||
expectedGridMode: "g-s",
|
||||
hasScreenShares: "n-y",
|
||||
userSelection: " -s-g",
|
||||
expectedLayout: " gs-g",
|
||||
}));
|
||||
|
||||
test("allows switching modes manually when in flat window mode", () =>
|
||||
testLayoutSwitch({
|
||||
// Window becomes flat, then user switches to grid and back.
|
||||
// Window becomes flat, then user switches to spotlight and back.
|
||||
// Finally the window returns to a normal shape.
|
||||
windowMode: " nf--n",
|
||||
userSelection: " --gs",
|
||||
expectedGridMode: "gsgsg",
|
||||
windowMode: " nf--n",
|
||||
userSelection: " --sg",
|
||||
expectedLayout: "g-sg",
|
||||
}));
|
||||
|
||||
test("stays in spotlight while there are screen shares even when window mode changes", () =>
|
||||
test("switches to grid when in flat window mode even when there are screen shares", () =>
|
||||
testLayoutSwitch({
|
||||
windowMode: " nfn",
|
||||
hasScreenShares: " y",
|
||||
expectedGridMode: "s",
|
||||
windowMode: " nf",
|
||||
hasScreenShares: "y",
|
||||
expectedLayout: " sg",
|
||||
}));
|
||||
|
||||
test("ignores end of screen share until window mode returns to normal", () =>
|
||||
test("ignores screen share until window mode returns to normal", () =>
|
||||
testLayoutSwitch({
|
||||
windowMode: " nf-n",
|
||||
hasScreenShares: " y-n",
|
||||
expectedGridMode: "s--g",
|
||||
windowMode: " f-n",
|
||||
hasScreenShares: "ny-n",
|
||||
expectedLayout: " g-sg",
|
||||
}));
|
||||
99
src/state/LayoutSwitchViewModel.ts
Normal file
99
src/state/LayoutSwitchViewModel.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
combineLatest,
|
||||
map,
|
||||
Subject,
|
||||
startWith,
|
||||
skipWhile,
|
||||
switchAll,
|
||||
} from "rxjs";
|
||||
|
||||
import { type WindowMode } from "./CallViewModel/CallViewModel.ts";
|
||||
import { constant, type Behavior } from "./Behavior.ts";
|
||||
import { type ObservableScope } from "./ObservableScope.ts";
|
||||
|
||||
export type LayoutMode = "spotlight" | "grid";
|
||||
|
||||
export interface LayoutSwitchViewModel {
|
||||
/**
|
||||
* The layout mode of the call's media tiles.
|
||||
*/
|
||||
layout$: Behavior<LayoutMode>;
|
||||
setLayout: (value: LayoutMode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a layout mode switch that allows switching between grid and spotlight layouts.
|
||||
* The actual layout mode might switch automatically to spotlight if there is a
|
||||
* remote screen share active or if the window mode is flat.
|
||||
*
|
||||
* @param scope - The observable scope to manage subscriptions.
|
||||
* @param windowMode$ - The current window mode.
|
||||
* @param hasRemoteScreenShares$ - A behavior indicating if there are remote screen shares active.
|
||||
*/
|
||||
export function createLayoutSwitchViewModel(
|
||||
scope: ObservableScope,
|
||||
windowMode$: Behavior<WindowMode>,
|
||||
hasRemoteScreenShares$: Behavior<boolean>,
|
||||
): LayoutSwitchViewModel {
|
||||
const userSelection$ = new Subject<LayoutMode>();
|
||||
// Callback to set the layout desired by the user.
|
||||
// Notice that this is only a preference, the actual layout can be overridden
|
||||
// if there is a remote screen share active.
|
||||
const setLayout = (value: LayoutMode): void => userSelection$.next(value);
|
||||
|
||||
/**
|
||||
* The natural layout - the layout that the interface would prefer to be in,
|
||||
* not accounting for the user's manual selections.
|
||||
*/
|
||||
const naturalLayout$ = scope.behavior<LayoutMode>(
|
||||
combineLatest(
|
||||
[hasRemoteScreenShares$, windowMode$],
|
||||
(hasRemoteScreenShares, windowMode) => {
|
||||
// When the window is flat (as with a phone in landscape orientation),
|
||||
// grid is preferable as there's usually more than enough horizontal
|
||||
// space to fit in some grid tiles on the side.
|
||||
if (windowMode === "flat") return "grid";
|
||||
// When there are screen shares, spotlight is a better experience. We
|
||||
// want them to be big and readable.
|
||||
return hasRemoteScreenShares ? "spotlight" : "grid";
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The layout mode of the call's media tiles.
|
||||
*/
|
||||
const layout$ = scope.behavior<LayoutMode>(
|
||||
// Whenever the user makes a selection, we enter a new mode of behavior:
|
||||
userSelection$.pipe(
|
||||
map((selection) => {
|
||||
if (selection === "grid")
|
||||
// The user has selected grid. Start by respecting their choice, but
|
||||
// then follow the natural mode again as soon as it matches.
|
||||
return naturalLayout$.pipe(
|
||||
skipWhile((naturalMode) => naturalMode !== selection),
|
||||
startWith(selection),
|
||||
);
|
||||
|
||||
// The user has selected spotlight. If this matches the natural layout,
|
||||
// then follow the natural layout going forward.
|
||||
return selection === naturalLayout$.value
|
||||
? naturalLayout$
|
||||
: constant(selection);
|
||||
}),
|
||||
// Initially the mode of behavior is to just follow the natural layout.
|
||||
startWith(naturalLayout$),
|
||||
// Switch between each mode of behavior.
|
||||
switchAll(),
|
||||
),
|
||||
);
|
||||
|
||||
return { layout$, setLayout };
|
||||
}
|
||||
@@ -10,19 +10,19 @@ import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type OneOnOneLandscapeLayout,
|
||||
type OneOnOneLandscapeLayoutMedia,
|
||||
type OneOnOneDesktopLayout,
|
||||
type OneOnOneDesktopLayoutMedia,
|
||||
} from "./layout-types";
|
||||
import { type TileStore } from "./TileStore";
|
||||
|
||||
/**
|
||||
* Produces a one-on-one landscape layout with the given media.
|
||||
* Produces a one-on-one desktop layout with the given media.
|
||||
*/
|
||||
export function oneOnOneLandscapeLayout(
|
||||
media: OneOnOneLandscapeLayoutMedia,
|
||||
export function oneOnOneDesktopLayout(
|
||||
media: OneOnOneDesktopLayoutMedia,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOneLandscapeLayout, TileStore] {
|
||||
): [OneOnOneDesktopLayout, TileStore] {
|
||||
const update = prevTiles.from(2);
|
||||
update.registerGridTile(media.pip);
|
||||
update.registerGridTile(media.spotlight);
|
||||
@@ -10,23 +10,23 @@ import { type BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
type Alignment,
|
||||
type OneOnOnePortraitLayout,
|
||||
type OneOnOnePortraitLayoutMedia,
|
||||
type OneOnOneMobileLayout,
|
||||
type OneOnOneMobileLayoutMedia,
|
||||
} from "./layout-types";
|
||||
import { type TileStore } from "./TileStore";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
/**
|
||||
* Produces a one-on-one portrait layout with the given media.
|
||||
* Produces a one-on-one mobile layout with the given media.
|
||||
*/
|
||||
export function oneOnOnePortraitLayout(
|
||||
media: OneOnOnePortraitLayoutMedia,
|
||||
export function oneOnOneMobileLayout(
|
||||
media: OneOnOneMobileLayoutMedia,
|
||||
pipSize$: Behavior<"sm" | "lg">,
|
||||
pipAlignment$: BehaviorSubject<Alignment>,
|
||||
prevTiles: TileStore,
|
||||
): [OneOnOnePortraitLayout, TileStore] {
|
||||
): [OneOnOneMobileLayout, TileStore] {
|
||||
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
|
||||
update.registerSpotlight([media.spotlight], true);
|
||||
update.registerSpotlight([media.spotlight], true, "transparent");
|
||||
if (media.pip !== undefined) update.registerGridTile(media.pip);
|
||||
const tiles = update.build();
|
||||
|
||||
@@ -21,6 +21,7 @@ export function pipLayout(
|
||||
update.registerSpotlight(
|
||||
media.spotlight,
|
||||
platform === "desktop" ? false : true,
|
||||
"transparent",
|
||||
);
|
||||
const tiles = update.build();
|
||||
return [
|
||||
|
||||
27
src/state/ServiceInterruptionsViewModel.ts
Normal file
27
src/state/ServiceInterruptionsViewModel.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
export type Service =
|
||||
| { name: "encryption-keys" }
|
||||
| { name: "reactions" }
|
||||
| { name: "screen-sharing" }
|
||||
| { name: "local-media" }
|
||||
| {
|
||||
name: "remote-media";
|
||||
/**
|
||||
* The number of users affected, for the purpose of pluralising the
|
||||
* strings.
|
||||
*/
|
||||
count: number;
|
||||
};
|
||||
|
||||
export interface ServiceInterruptionsViewModel {
|
||||
/**
|
||||
* A non-empty array of services which are temporarily unavailable.
|
||||
*/
|
||||
unavailable: Service[];
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export function spotlightExpandedLayout(
|
||||
prevTiles: TileStore,
|
||||
): [SpotlightExpandedLayout, TileStore] {
|
||||
const update = prevTiles.from(1);
|
||||
update.registerSpotlight(media.spotlight, true);
|
||||
update.registerSpotlight(media.spotlight, true, "transparent");
|
||||
if (media.pip !== undefined) update.registerPipTile(media.pip);
|
||||
const tiles = update.build();
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import { type MediaViewModel } from "./media/MediaViewModel";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
|
||||
|
||||
type SpotlightBackground = "solid" | "transparent";
|
||||
|
||||
function debugEntries(entries: GridTileData[]): string[] {
|
||||
return entries.map((e) => e.media.displayName$.value);
|
||||
}
|
||||
@@ -39,12 +41,29 @@ class SpotlightTileData {
|
||||
this.maximised$.next(value);
|
||||
}
|
||||
|
||||
private readonly background$: BehaviorSubject<SpotlightBackground>;
|
||||
public get background(): SpotlightBackground {
|
||||
return this.background$.value;
|
||||
}
|
||||
public set background(value: SpotlightBackground) {
|
||||
this.background$.next(value);
|
||||
}
|
||||
|
||||
public readonly vm: SpotlightTileViewModel;
|
||||
|
||||
public constructor(media: MediaViewModel[], maximised: boolean) {
|
||||
public constructor(
|
||||
media: MediaViewModel[],
|
||||
maximised: boolean,
|
||||
background: SpotlightBackground,
|
||||
) {
|
||||
this.media$ = new BehaviorSubject(media);
|
||||
this.maximised$ = new BehaviorSubject(maximised);
|
||||
this.vm = new SpotlightTileViewModel(this.media$, this.maximised$);
|
||||
this.background$ = new BehaviorSubject(background);
|
||||
this.vm = new SpotlightTileViewModel(
|
||||
this.media$,
|
||||
this.maximised$,
|
||||
this.background$,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +176,11 @@ export class TileStoreBuilder {
|
||||
* Sets the contents of the spotlight tile. If this is never called, there
|
||||
* will be no spotlight tile.
|
||||
*/
|
||||
public registerSpotlight(media: MediaViewModel[], maximised: boolean): void {
|
||||
public registerSpotlight(
|
||||
media: MediaViewModel[],
|
||||
maximised: boolean,
|
||||
background: SpotlightBackground = "solid",
|
||||
): void {
|
||||
if (DEBUG_ENABLED)
|
||||
logger.debug(
|
||||
`[TileStore, ${this.generation}] register spotlight: ${media.map((m) => m.displayName$.value)}`,
|
||||
@@ -169,11 +192,12 @@ export class TileStoreBuilder {
|
||||
|
||||
// Reuse the previous spotlight tile if it exists
|
||||
if (this.prevSpotlight === null) {
|
||||
this.spotlight = new SpotlightTileData(media, maximised);
|
||||
this.spotlight = new SpotlightTileData(media, maximised, background);
|
||||
} else {
|
||||
this.spotlight = this.prevSpotlight;
|
||||
this.spotlight.media = media;
|
||||
this.spotlight.maximised = maximised;
|
||||
this.spotlight.background = background;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export class SpotlightTileViewModel {
|
||||
public constructor(
|
||||
public readonly media$: Behavior<MediaViewModel[]>,
|
||||
public readonly maximised$: Behavior<boolean>,
|
||||
public readonly background$: Behavior<"solid" | "transparent">,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,15 +45,15 @@ export interface SpotlightExpandedLayoutMedia {
|
||||
pip?: UserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface OneOnOneLandscapeLayoutMedia {
|
||||
type: "one-on-one-landscape";
|
||||
export interface OneOnOneDesktopLayoutMedia {
|
||||
type: "one-on-one-desktop";
|
||||
edgeToEdge: false;
|
||||
spotlight: UserMediaViewModel;
|
||||
pip: LocalUserMediaViewModel | RingingMediaViewModel;
|
||||
}
|
||||
|
||||
export interface OneOnOnePortraitLayoutMedia {
|
||||
type: "one-on-one-portrait";
|
||||
export interface OneOnOneMobileLayoutMedia {
|
||||
type: "one-on-one-mobile";
|
||||
edgeToEdge: true;
|
||||
spotlight: UserMediaViewModel | RingingMediaViewModel;
|
||||
pip?: LocalUserMediaViewModel;
|
||||
@@ -70,8 +70,8 @@ export type LayoutMedia =
|
||||
| SpotlightLandscapeLayoutMedia
|
||||
| SpotlightPortraitLayoutMedia
|
||||
| SpotlightExpandedLayoutMedia
|
||||
| OneOnOneLandscapeLayoutMedia
|
||||
| OneOnOnePortraitLayoutMedia
|
||||
| OneOnOneDesktopLayoutMedia
|
||||
| OneOnOneMobileLayoutMedia
|
||||
| PipLayoutMedia;
|
||||
|
||||
export interface Alignment {
|
||||
@@ -108,15 +108,15 @@ export interface SpotlightExpandedLayout {
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface OneOnOneLandscapeLayout {
|
||||
type: "one-on-one-landscape";
|
||||
export interface OneOnOneDesktopLayout {
|
||||
type: "one-on-one-desktop";
|
||||
spotlight: GridTileViewModel;
|
||||
pip: GridTileViewModel;
|
||||
pipAlignment$: BehaviorSubject<Alignment>;
|
||||
}
|
||||
|
||||
export interface OneOnOnePortraitLayout {
|
||||
type: "one-on-one-portrait";
|
||||
export interface OneOnOneMobileLayout {
|
||||
type: "one-on-one-mobile";
|
||||
spotlight: SpotlightTileViewModel;
|
||||
pip?: GridTileViewModel;
|
||||
pipSize$: Behavior<"sm" | "lg">;
|
||||
@@ -137,6 +137,6 @@ export type Layout =
|
||||
| SpotlightLandscapeLayout
|
||||
| SpotlightPortraitLayout
|
||||
| SpotlightExpandedLayout
|
||||
| OneOnOneLandscapeLayout
|
||||
| OneOnOnePortraitLayout
|
||||
| OneOnOneDesktopLayout
|
||||
| OneOnOneMobileLayout
|
||||
| PipLayout;
|
||||
|
||||
124
src/tabs/Tabs.test.tsx
Normal file
124
src/tabs/Tabs.test.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright 2026 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, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { type Tab, TabContainer } from "./Tabs";
|
||||
|
||||
function renderTabs<K extends string>(tabs: Tab<K>[], tab: K): void {
|
||||
render(
|
||||
<TabContainer
|
||||
label="Settings"
|
||||
tab={tab}
|
||||
onTabChange={vi.fn()}
|
||||
tabs={tabs}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function expectTabPositions(expectedNames: string[]): void {
|
||||
const tabs = within(
|
||||
screen.getByRole("tablist", { name: "Settings" }),
|
||||
).getAllByRole("tab");
|
||||
|
||||
expect(tabs).toHaveLength(expectedNames.length);
|
||||
tabs.forEach((tab, index) => {
|
||||
expect(tab).toHaveTextContent(expectedNames[index]);
|
||||
expect(tab).toHaveAttribute(
|
||||
"aria-setsize",
|
||||
expectedNames.length.toString(),
|
||||
);
|
||||
expect(tab).toHaveAttribute("aria-posinset", (index + 1).toString());
|
||||
});
|
||||
}
|
||||
|
||||
it("sets tab collection size and positions for two tabs", () => {
|
||||
renderTabs(
|
||||
[
|
||||
{ key: "audio", name: "Audio", content: "Audio panel" },
|
||||
{ key: "video", name: "Video", content: "Video panel" },
|
||||
],
|
||||
"audio",
|
||||
);
|
||||
|
||||
expectTabPositions(["Audio", "Video"]);
|
||||
});
|
||||
|
||||
it("sets tab collection size and positions for four tabs", () => {
|
||||
renderTabs(
|
||||
[
|
||||
{ key: "audio", name: "Audio", content: "Audio panel" },
|
||||
{ key: "video", name: "Video", content: "Video panel" },
|
||||
{ key: "profile", name: "Profile", content: "Profile panel" },
|
||||
{
|
||||
key: "preferences",
|
||||
name: "Preferences",
|
||||
content: "Preferences panel",
|
||||
},
|
||||
],
|
||||
"profile",
|
||||
);
|
||||
|
||||
expectTabPositions(["Audio", "Video", "Profile", "Preferences"]);
|
||||
});
|
||||
|
||||
it("fires tab changes and keeps the active tab selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTabChange = vi.fn();
|
||||
const tabs = [
|
||||
{ key: "audio", name: "Audio", content: "Audio panel" },
|
||||
{ key: "video", name: "Video", content: "Video panel" },
|
||||
] satisfies Tab<string>[];
|
||||
|
||||
const { rerender } = render(
|
||||
<TabContainer
|
||||
label="Settings"
|
||||
tab="audio"
|
||||
onTabChange={onTabChange}
|
||||
tabs={tabs}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Audio" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Video" }));
|
||||
expect(onTabChange).toHaveBeenCalledWith("video");
|
||||
|
||||
rerender(
|
||||
<TabContainer
|
||||
label="Settings"
|
||||
tab="video"
|
||||
onTabChange={onTabChange}
|
||||
tabs={tabs}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Video" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("only shows the selected tab panel", () => {
|
||||
renderTabs(
|
||||
[
|
||||
{ key: "audio", name: "Audio", content: "Audio panel" },
|
||||
{ key: "video", name: "Video", content: "Video panel" },
|
||||
{ key: "profile", name: "Profile", content: "Profile panel" },
|
||||
],
|
||||
"video",
|
||||
);
|
||||
|
||||
expect(screen.getByText("Audio panel")).not.toBeVisible();
|
||||
expect(screen.getByText("Video panel")).toBeVisible();
|
||||
expect(screen.getByText("Profile panel")).not.toBeVisible();
|
||||
});
|
||||
@@ -34,10 +34,12 @@ export function TabContainer<K extends Key>({
|
||||
return (
|
||||
<div className={styles.tabContainer}>
|
||||
<NavBar role="tablist" aria-label={label} className={styles.tabList}>
|
||||
{tabs.map(({ key, name }) => (
|
||||
{tabs.map(({ key, name }, index) => (
|
||||
<NavItem
|
||||
key={key}
|
||||
aria-controls={`${idPrefix}[${key}]`}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={tabs.length}
|
||||
onClick={() => onTabChange(key)}
|
||||
active={key === tab}
|
||||
>
|
||||
|
||||
@@ -27,8 +27,15 @@ Please see LICENSE in the repository root for full details.
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.media[data-video-enabled="false"] video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media.mirror video {
|
||||
transform: scaleX(-1);
|
||||
/* In FF if you add a transform: scale/translate/matrix filter on an element,
|
||||
it'll ignore the parents' border-radius, so force back the radius to avoid UI glitch*/
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.media[data-video-fit="cover"] video {
|
||||
@@ -41,13 +48,64 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
.bg {
|
||||
grid-area: content;
|
||||
background-color: var(--video-tile-background);
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
border-radius: inherit;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.media[data-background="solid"] .bg {
|
||||
background-color: var(--video-tile-background);
|
||||
}
|
||||
|
||||
.waves {
|
||||
transition: opacity ease 0.3s;
|
||||
}
|
||||
|
||||
.waves[data-visible="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.waves[data-visible="false"] {
|
||||
opacity: 0;
|
||||
|
||||
@media not (prefers-reduced-motion) {
|
||||
.wave {
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wave {
|
||||
border: var(--cpd-border-width-1) solid var(--cpd-color-alpha-gray-300);
|
||||
transition: transform ease 0.2s;
|
||||
}
|
||||
|
||||
.wave,
|
||||
.speakingBorder {
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.speakingBorder {
|
||||
background:
|
||||
radial-gradient(#0467dd, #0bc491),
|
||||
linear-gradient(0deg, #0467dd 0%, #0bc491 100%);
|
||||
background-blend-mode: overlay, normal;
|
||||
outline: var(--cpd-border-width-4) solid var(--cpd-color-bg-canvas-default);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: var(--cpd-border-width-2);
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -70,6 +128,35 @@ unconditionally select the container so we can use cqmin units */
|
||||
inline-size: 50cqmin;
|
||||
block-size: 50cqmin;
|
||||
}
|
||||
|
||||
.waves + .avatar {
|
||||
/* Make the avatar slightly smaller to accommodate sound waves, if present */
|
||||
inline-size: 38cqmin;
|
||||
block-size: 38cqmin;
|
||||
}
|
||||
|
||||
.wave:nth-child(1) {
|
||||
inline-size: calc(38cqmin + var(--cpd-space-5x) + 3 * var(--cpd-space-10x));
|
||||
block-size: calc(38cqmin + var(--cpd-space-5x) + 3 * var(--cpd-space-10x));
|
||||
background: var(--cpd-color-alpha-gray-200);
|
||||
}
|
||||
|
||||
.wave:nth-child(2) {
|
||||
inline-size: calc(38cqmin + var(--cpd-space-5x) + 2 * var(--cpd-space-10x));
|
||||
block-size: calc(38cqmin + var(--cpd-space-5x) + 2 * var(--cpd-space-10x));
|
||||
background: var(--cpd-color-alpha-gray-300);
|
||||
}
|
||||
|
||||
.wave:nth-child(3) {
|
||||
inline-size: calc(38cqmin + var(--cpd-space-5x) + var(--cpd-space-10x));
|
||||
block-size: calc(38cqmin + var(--cpd-space-5x) + var(--cpd-space-10x));
|
||||
background: var(--cpd-color-alpha-gray-400);
|
||||
}
|
||||
|
||||
.speakingBorder {
|
||||
inline-size: calc(38cqmin + var(--cpd-space-3x));
|
||||
block-size: calc(38cqmin + var(--cpd-space-3x));
|
||||
}
|
||||
}
|
||||
|
||||
.avatar > img {
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
type TrackReference,
|
||||
type TrackReferencePlaceholder,
|
||||
} from "@livekit/components-core";
|
||||
import { LocalTrackPublication, Track } from "livekit-client";
|
||||
import { TrackInfo } from "@livekit/protocol";
|
||||
import { type LocalTrackPublication, Track } from "livekit-client";
|
||||
import { type ComponentProps } from "react";
|
||||
|
||||
import { MediaView } from "./MediaView";
|
||||
@@ -28,10 +27,7 @@ describe("MediaView", () => {
|
||||
};
|
||||
const trackReference: TrackReference = {
|
||||
...trackReferencePlaceholder,
|
||||
publication: new LocalTrackPublication(
|
||||
Track.Kind.Video,
|
||||
new TrackInfo({ sid: "id", name: "name" }),
|
||||
),
|
||||
publication: {} as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||
};
|
||||
|
||||
const baseProps: ComponentProps<typeof MediaView> = {
|
||||
@@ -129,30 +125,4 @@ describe("MediaView", () => {
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoEnabled", () => {
|
||||
test("just video is visible", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MediaView {...baseProps} videoEnabled={true} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("video")).toBeVisible();
|
||||
expect(screen.queryAllByRole("img", { name: "some name" }).length).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("just avatar is visible", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MediaView {...baseProps} videoEnabled={false} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "@alice:example.com" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByTestId("video")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { type ReactionOption } from "../reactions";
|
||||
import { ReactionIndicator } from "../reactions/ReactionIndicator";
|
||||
import { RTCConnectionStats } from "../RTCConnectionStats";
|
||||
import videoPlaceholder from "../graphics/video-placeholder.gif";
|
||||
|
||||
interface Props extends ComponentProps<typeof animated.div> {
|
||||
className?: string;
|
||||
@@ -34,6 +35,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
video: TrackReferenceOrPlaceholder | undefined;
|
||||
videoFit: "cover" | "contain";
|
||||
mirror: boolean;
|
||||
soundWaves?: boolean;
|
||||
userId: string;
|
||||
videoEnabled: boolean;
|
||||
unencryptedWarning: boolean;
|
||||
@@ -43,6 +45,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
displayName: string;
|
||||
mxcAvatarUrl: string | undefined;
|
||||
avatarStyle?: "solid" | "translucent";
|
||||
background?: "solid" | "transparent";
|
||||
focusable: boolean;
|
||||
primaryButton?: ReactNode;
|
||||
raisedHandTime?: Date;
|
||||
@@ -65,6 +68,7 @@ export const MediaView: FC<Props> = ({
|
||||
video,
|
||||
videoFit,
|
||||
mirror,
|
||||
soundWaves,
|
||||
userId,
|
||||
videoEnabled,
|
||||
unencryptedWarning,
|
||||
@@ -73,6 +77,7 @@ export const MediaView: FC<Props> = ({
|
||||
displayName,
|
||||
mxcAvatarUrl,
|
||||
avatarStyle = "solid",
|
||||
background = "solid",
|
||||
focusable,
|
||||
primaryButton,
|
||||
status,
|
||||
@@ -90,7 +95,10 @@ export const MediaView: FC<Props> = ({
|
||||
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
|
||||
const [showConnectionStats] = useSetting(showConnectionStatsSetting);
|
||||
|
||||
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
|
||||
const avatarSize = Math.round(
|
||||
Math.min(targetWidth, targetHeight) *
|
||||
(soundWaves === undefined ? 0.5 : 0.38),
|
||||
);
|
||||
|
||||
const warnings = unencryptedWarning && (
|
||||
<Tooltip
|
||||
@@ -117,10 +125,20 @@ export const MediaView: FC<Props> = ({
|
||||
style={style}
|
||||
ref={ref}
|
||||
data-testid="videoTile"
|
||||
data-video-enabled={video && videoEnabled}
|
||||
data-video-fit={videoFit}
|
||||
data-background={background}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
{soundWaves !== undefined && (
|
||||
<div className={styles.waves} data-visible={soundWaves}>
|
||||
<div className={styles.wave} />
|
||||
<div className={styles.wave} />
|
||||
<div className={styles.wave} />
|
||||
<div className={styles.speakingBorder} />
|
||||
</div>
|
||||
)}
|
||||
<Avatar
|
||||
id={userId}
|
||||
name={displayName}
|
||||
@@ -136,8 +154,10 @@ export const MediaView: FC<Props> = ({
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
style={{ display: video && videoEnabled ? "block" : "none" }}
|
||||
data-testid="video"
|
||||
// Set the placeholder to a small transparent image. (On Android web
|
||||
// views the default poster image is particularly ugly.)
|
||||
poster={videoPlaceholder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ Please see LICENSE in the repository root for full details.
|
||||
scroll-behavior: smooth; */
|
||||
}
|
||||
|
||||
.tile.maximised .contents {
|
||||
.tile[data-maximised="true"] .contents {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ Please see LICENSE in the repository root for full details.
|
||||
--media-view-fg-inset: 10px;
|
||||
}
|
||||
|
||||
.maximised .item {
|
||||
.tile[data-maximised="true"] .item {
|
||||
/* Ensure that foreground elements lie within the safe area */
|
||||
--media-view-fg-inset: calc(var(--call-view-safe-area-inset-top, 0px) + 10px)
|
||||
calc(env(safe-area-inset-right) + 10px)
|
||||
@@ -191,7 +191,7 @@ Please see LICENSE in the repository root for full details.
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maximised .indicators {
|
||||
.tile[data-maximised="true"] .indicators {
|
||||
inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,13 @@ test("SpotlightTile is accessible", async () => {
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm1, vm2]), constant(false))}
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm1, vm2]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
@@ -101,7 +107,13 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
@@ -132,7 +144,13 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
@@ -168,7 +186,13 @@ test("SpotlightTile displays ringing media", async () => {
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
|
||||
@@ -65,6 +65,7 @@ interface SpotlightItemBaseProps {
|
||||
displayName: string;
|
||||
mxcAvatarUrl: string | undefined;
|
||||
showNameTags: boolean;
|
||||
background: "solid" | "transparent";
|
||||
focusable: boolean;
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
@@ -78,6 +79,7 @@ interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps {
|
||||
videoFit: "contain" | "cover";
|
||||
videoEnabled: boolean;
|
||||
soundWaves: boolean | undefined;
|
||||
}
|
||||
|
||||
interface SpotlightLocalUserMediaItemProps extends SpotlightUserMediaItemBaseProps {
|
||||
@@ -120,6 +122,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
}) => {
|
||||
const videoFit = useBehavior(vm.videoFit$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const speaking = useBehavior(vm.speaking$);
|
||||
|
||||
// Whenever target bounds change, inform the viewModel
|
||||
useEffect(() => {
|
||||
@@ -132,6 +135,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
RefAttributes<HTMLDivElement> = {
|
||||
videoFit,
|
||||
videoEnabled,
|
||||
soundWaves: props.background === "transparent" ? speaking : undefined,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
...props,
|
||||
@@ -243,12 +247,14 @@ interface SpotlightItemProps {
|
||||
targetHeight: number;
|
||||
showNameTags: boolean;
|
||||
showRingingStatus: boolean;
|
||||
background: "solid" | "transparent";
|
||||
focusable: boolean;
|
||||
intersectionObserver$: Observable<IntersectionObserver>;
|
||||
/**
|
||||
* Whether this item should act as a scroll snapping point.
|
||||
*/
|
||||
snap: boolean;
|
||||
className?: string;
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
|
||||
@@ -259,9 +265,11 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
targetHeight,
|
||||
showNameTags,
|
||||
showRingingStatus,
|
||||
background,
|
||||
focusable,
|
||||
intersectionObserver$,
|
||||
snap,
|
||||
className,
|
||||
"aria-hidden": ariaHidden,
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -288,13 +296,14 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
"data-id": vm.id,
|
||||
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||
className: classNames(className, styles.item, { [styles.snap]: snap }),
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
userId: vm.userId,
|
||||
displayName,
|
||||
mxcAvatarUrl,
|
||||
showNameTags,
|
||||
background,
|
||||
focusable,
|
||||
"aria-hidden": ariaHidden,
|
||||
};
|
||||
@@ -391,6 +400,10 @@ interface Props {
|
||||
showRingingStatus: boolean;
|
||||
focusable: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* CSS class of the individual spotlight items.
|
||||
*/
|
||||
itemClassName?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
}
|
||||
|
||||
@@ -406,12 +419,14 @@ export const SpotlightTile: FC<Props> = ({
|
||||
showRingingStatus,
|
||||
focusable = true,
|
||||
className,
|
||||
itemClassName,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const maximised = useBehavior(vm.maximised$);
|
||||
const background = useBehavior(vm.background$);
|
||||
const media = useBehavior(vm.media$);
|
||||
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
|
||||
const latestMedia = useLatest(media);
|
||||
@@ -492,9 +507,8 @@ export const SpotlightTile: FC<Props> = ({
|
||||
return (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
className={classNames(className, styles.tile)}
|
||||
data-maximised={maximised}
|
||||
style={style}
|
||||
>
|
||||
{canGoBack && (
|
||||
@@ -516,6 +530,7 @@ export const SpotlightTile: FC<Props> = ({
|
||||
targetHeight={targetHeight}
|
||||
showRingingStatus={showRingingStatus}
|
||||
showNameTags={showNameTags}
|
||||
background={background}
|
||||
focusable={focusable}
|
||||
intersectionObserver$={intersectionObserver$}
|
||||
// This is how we get the container to scroll to the right media
|
||||
@@ -523,6 +538,7 @@ export const SpotlightTile: FC<Props> = ({
|
||||
// remove all scroll snap points except for just the one media
|
||||
// that we want to bring into view
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
className={itemClassName}
|
||||
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -6,22 +6,22 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, describe, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { TileAvatar } from "./TileAvatar";
|
||||
|
||||
describe("TileAvatar", () => {
|
||||
it("should show loading spinner when loading", () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<TileAvatar id="@a:example.org" name="Alice" size={96} loading={true} />,
|
||||
);
|
||||
expect(container.querySelector(".loading")).toBeInTheDocument();
|
||||
screen.getByLabelText("Loading…");
|
||||
});
|
||||
|
||||
it("should not show loading spinner when not loading", () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<TileAvatar id="@a:example.org" name="Alice" size={96} loading={false} />,
|
||||
);
|
||||
expect(container.querySelector(".loading")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Loading…")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type FC } from "react";
|
||||
import { InlineSpinner } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./TileAvatar.module.css";
|
||||
import { Avatar, type Props as AvatarProps } from "../Avatar";
|
||||
@@ -17,11 +18,12 @@ interface Props extends AvatarProps {
|
||||
}
|
||||
|
||||
export const TileAvatar: FC<Props> = ({ size, loading, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{loading && (
|
||||
<div className={styles.loading}>
|
||||
<InlineSpinner size={size / 3} />
|
||||
<InlineSpinner size={size / 3} aria-label={t("common.loading")} />
|
||||
</div>
|
||||
)}
|
||||
<Avatar size={size} {...props} />
|
||||
|
||||
@@ -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 "global-jsdom/register";
|
||||
import "@formatjs/intl-durationformat/polyfill.js";
|
||||
import "@formatjs/intl-segmenter/polyfill";
|
||||
import i18n from "i18next";
|
||||
|
||||
@@ -18,12 +18,7 @@ export default defineConfig((configEnv) =>
|
||||
extends: true,
|
||||
test: {
|
||||
name: "unit",
|
||||
css: {
|
||||
include: /.+/,
|
||||
modules: {
|
||||
classNameStrategy: "non-scoped",
|
||||
},
|
||||
},
|
||||
css: { include: /.+/ },
|
||||
setupFiles: ["src/vitest.setup.ts"],
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
|
||||
Reference in New Issue
Block a user