Compare commits

..

101 Commits

Author SHA1 Message Date
Robin
d33dedd038 Hide the browser's default placeholder image for videos
Source: https://stackoverflow.com/a/79058682
2026-07-15 17:26:02 +02:00
Robin
927cf68110 Merge pull request #4109 from element-hq/service-interruptions-vm
Create service interruptions view model
2026-07-14 12:21:18 +02:00
Robin
cd72ad0008 Create service interruptions view model 2026-07-14 12:02:36 +02:00
Robin
2d2e85e340 Merge pull request #4107 from element-hq/spotlight-icon
Update spotlight icon
2026-07-14 09:34:50 +02:00
Johannes Marbach
4da1b9a02a Merge pull request #4082 from element-hq/johannes/docker-compose-renovate
Make renovate track docker-compose files and pin synapse / element-web for playwright tests
2026-07-14 07:55:17 +02:00
Robin
73758f7f77 Merge pull request #4108 from element-hq/renovate/compound
Update Compound
2026-07-13 18:37:15 +02:00
Robin
c433c36687 Update snapshots 2026-07-13 18:20:56 +02:00
Robin
3e9d7b7cf7 Exclude packages developed in-house from release age requirements 2026-07-13 17:00:02 +02:00
renovate[bot]
2a56d2afb8 Update Compound 2026-07-13 14:35:37 +00:00
Robin
bff41ad66a Update spotlight icon 2026-07-13 12:22:44 +02:00
Robin
1d088911e1 Merge pull request #4105 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v9.7.1
2026-07-13 11:10:21 +02:00
Timo
f23eb96555 Merge pull request #4025 from mvanhorn/fix/3915-settings-tabbar-announce-position
fix: announce tab count and position in settings tab bar
2026-07-13 10:16:52 +02:00
renovate[bot]
0d4e7defb6 Update dependency @vector-im/compound-web to v9.7.1 2026-07-13 00:42:33 +00:00
Timo K.
023aec22ca Update docker-compose-playwright.yml 2026-07-10 17:45:53 +02:00
Timo K.
bf4cee9898 update to latest EW
(this fixes the playwright tests)
2026-07-10 17:39:36 +02:00
Timo K.
147ff131f4 Merge branch 'livekit' into johannes/docker-compose-renovate 2026-07-10 12:59:06 +02:00
Robin
3e5edd03a6 Merge pull request #4100 from element-hq/remove-stopgap-styles
Stop using colors from previous design system
2026-07-09 16:37:02 +02:00
Robin
0afefa4889 Merge branch 'livekit' into remove-stopgap-styles 2026-07-09 16:21:06 +02:00
Timo K.
49a762832b Merge branch 'livekit' into johannes/docker-compose-renovate 2026-07-09 14:03:18 +02:00
Timo K.
3cf5cab1d2 Add additional renovate packageGroup for element-web 2026-07-09 13:20:57 +02:00
Robin
5968554e4c Merge pull request #4067 from element-hq/mobile-gradient
Improve the look of voice calls and group calls on mobile
2026-07-09 13:17:12 +02:00
Robin
8d826b2f31 Avoid showing gradients in especially small windows 2026-07-09 12:59:29 +02:00
Robin
32e30dd4f5 Merge branch 'livekit' into mobile-gradient 2026-07-09 12:56:28 +02:00
Robin
bb06905aa2 Merge pull request #4099 from element-hq/stable-class-names
Use vitest's default for scoped class names
2026-07-09 12:54:19 +02:00
Robin
2684fa1d62 Remove unused class for end call button 2026-07-09 12:46:45 +02:00
Robin
de75a69cad Remove stopgap color variables from previous design system
They have been largely unused for a long time. This changes the colors of checkboxes and the 'end call' button to align with current designs as well.
2026-07-09 12:28:39 +02:00
Timo
f2c42c314c Merge pull request #4088 from element-hq/toger5/logger-getChild-fix
fix getChild before rageshake setup
2026-07-08 18:19:04 +02:00
Robin
4b50a191ba Fix end-to-end tests 2026-07-08 17:13:15 +02:00
Robin
11fadebb87 Fix test failures due to scoped class names 2026-07-08 17:06:25 +02:00
Robin
943ce5b853 Update test snapshots to use scoped class names 2026-07-08 17:06:09 +02:00
Timo K.
137093350b Merge branch 'livekit' into toger5/logger-getChild-fix 2026-07-08 17:04:42 +02:00
Robin
000ee66cfe Use vitest's default for scoped class names
It reduces confusion when reading snapshots if class names are still scoped to their respective CSS modules. It also discourages the use of class names in tests, which is a good thing. (https://testing-library.com/docs/guiding-principles)

Thanks to Johannes for the suggestion.
2026-07-08 17:03:51 +02:00
Johannes Marbach
58a8b7316e Merge pull request #4077 from element-hq/renovate/github-actions
Update GitHub Actions
2026-07-08 16:20:07 +02:00
renovate[bot]
1e6d3d3357 Update GitHub Actions 2026-07-08 14:02:37 +00:00
Robin
9f681b87a0 Merge branch 'livekit' into mobile-gradient 2026-07-08 15:47:59 +02:00
Timo
dc21233767 Merge pull request #4097 from element-hq/toger5/fix-playwright-related-to-voice-in-pip
Consider voice calls starting in pip for playwright tests
2026-07-08 15:42:50 +02:00
Robin
27315a7090 Rename bgStyle to 'background' 2026-07-08 14:01:38 +02:00
Robin
bebe79d109 Create type alias for background style 2026-07-08 13:56:10 +02:00
Robin
4ca0270115 Fix broken gradient SVG 2026-07-08 13:52:24 +02:00
Timo K.
374f56651c Consider voice calls starting in pip for playwright tests 2026-07-08 13:19:23 +02:00
Timo K.
eefa6f843d fmt 2026-07-07 19:52:50 +02:00
Timo K.
2b84c587f2 lint 2026-07-07 19:45:22 +02:00
Timo K.
706cddafb0 Add lint rule to make sure future child loggers are setup correctly 2026-07-07 19:34:03 +02:00
Timo K.
a6921e2bfb all the other logs 2026-07-07 19:12:11 +02:00
Timo K.
3cc396f42c fix getChild before rageshake setup 2026-07-07 19:05:10 +02:00
Timo
59999b9b37 Merge pull request #4059 from element-hq/toger5/remove-blur-on-mobile
Remove blur on mobile
2026-07-07 15:30:36 +08:00
Robin
e68321e16c Merge branch 'livekit' into mobile-gradient 2026-07-06 12:17:04 +02:00
Johannes Marbach
ad76969ce2 Merge pull request #4084 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v9.7.0
2026-07-06 09:03:10 +02:00
Johannes Marbach
c170aad364 Merge pull request #4085 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.20.0
2026-07-06 09:01:51 +02:00
renovate[bot]
9e8bebc3bf Update dependency livekit-client to v2.20.0 2026-07-06 02:54:08 +00:00
renovate[bot]
d5cdbfd4d1 Update dependency @vector-im/compound-web to v9.7.0 2026-07-06 02:53:46 +00:00
Johannes Marbach
1811b36205 Make renovate track docker-compose files and pin synapse / element-web for playwright tests 2026-07-03 08:48:28 +02:00
Robin
7840f4bccb Merge branch 'livekit' into mobile-gradient 2026-07-02 13:00:38 +02:00
Johannes Marbach
9199dcb583 Merge pull request #4076 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-07-02 11:37:07 +02:00
Johannes Marbach
cbc4b77a4f Dismiss Element Web release announcements 2026-07-02 11:19:16 +02:00
Johannes Marbach
ffb881436a Eliminate @livekit/protocol 2026-07-02 08:47:07 +02:00
Johannes Marbach
9cf87efc6b Remove global-jsdom 2026-07-02 08:35:59 +02:00
Johannes Marbach
b71454d63a Bump mcr.microsoft.com/playwright 2026-07-02 08:35:40 +02:00
renovate[bot]
b0feb33ec4 Update all non-major dependencies 2026-07-01 00:31:28 +00:00
Robin
900d0abd27 Let page background shine through the footer
By visually separating it from other page content with an additional gradient background only when necessary.
2026-06-30 16:29:20 +02:00
Robin
a3d3a1e951 Add "sound waves" to transparent spotlight tiles
To indicate when the user is speaking.
2026-06-30 16:29:20 +02:00
Robin
8fa3f33f37 Let page background shine through spotlight tiles
By giving spotlight tiles a transparent background in certain layouts.
2026-06-30 16:29:20 +02:00
Robin
360a4ff026 Add mobile gradient background 2026-06-30 16:29:20 +02:00
Robin
8c6a16f3d7 Add URL param to control background style 2026-06-30 16:29:20 +02:00
Robin
9d21581259 Dismiss sections release announcement in end-to-end tests 2026-06-30 16:29:20 +02:00
Robin
3837576a5f Merge pull request #4073 from element-hq/refine-scope-leak-lint
Fix some false positives flagged by ObservableScope leak lint rule
2026-06-29 14:57:50 +02:00
Johannes Marbach
eb5a097999 Merge pull request #4072 from element-hq/johannes/jest
Switch to jest-style assertions to please oxlint
2026-06-29 14:36:18 +02:00
Robin
db2d20ac56 Fix some false positives flagged by ObservableScope leak lint rule
The rule should only care about enclosing function/class scopes. For example if an ObservableScope is received as a parameter to a function and then simply used inside an 'if' block (technically a different scope), that's not a problem.
2026-06-29 14:33:23 +02:00
Johannes Marbach
f276c51772 Switch to jest-style assertions to please oxlint
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-29 14:32:49 +02:00
Johannes Marbach
ac2214ed77 Merge commit from fork
Restrain logging of URL properties
2026-06-29 14:21:05 +02:00
Timo
8fe7bb5329 Merge pull request #4066 from element-hq/toger5/matrixRTCMode-compatibility
Default to compatibilty mode (multi sfu) - non breaking, as multi-sfu is compatible with all versions younger 6 month
2026-06-29 19:41:23 +08:00
Timo K
8028a6aa24 Update sfu-reconnect-bug.spec.ts 2026-06-29 13:11:26 +02:00
Timo K
5b4e8865e5 Update sfu-reconnect-bug.spec.ts 2026-06-29 13:04:22 +02:00
Timo K
356d526116 Update sfu-reconnect-bug.spec.ts 2026-06-29 13:02:26 +02:00
Johannes Marbach
cca69b8108 Merge pull request #4069 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v9.5.0
2026-06-29 08:00:57 +02:00
renovate[bot]
75235a7853 Update dependency @vector-im/compound-web to v9.5.0 2026-06-29 00:34:12 +00:00
Timo K
0421783ebe Update config.sample.json 2026-06-26 13:30:19 +02:00
Robin
a1be4ff0f9 Merge pull request #4068 from element-hq/landscape-voice-call
Hide local tile from mobile voice calls in landscape orientation
2026-06-26 11:22:25 +02:00
Robin
8ab7f89e36 Hide local tile from mobile voice calls in landscape orientation
To match the designs. Just like in portrait, it wouldn't contribute anything meaningful.
2026-06-25 20:29:24 +02:00
Valere Fedronic
ddcb0f9981 Merge pull request #4064 from element-hq/ios/default_voice_call_to_earpiece
fix(voice): Default to earpiece for voice only call
2026-06-25 16:32:59 +02:00
Timo K
6ca5ddda73 fix snapshot 2026-06-25 16:05:58 +02:00
Valere
41d0933d4d playwright: New toast 2026-06-25 15:55:08 +02:00
Johannes Marbach
5ad62c7097 Merge pull request #4053 from element-hq/johannes/oxlint
Switch from eslint to oxlint
2026-06-25 15:46:05 +02:00
Johannes Marbach
76714ac3ad Use the correct company name
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-25 15:29:00 +02:00
Timo K
15b40835b8 Default to compatibilty mode 2026-06-25 13:31:02 +02:00
Valere
dc5b541b21 fix(voice): Default to earpiece for voice only call 2026-06-25 13:10:27 +02:00
Johannes Marbach
f376ffb96b Don't redact null or undefined 2026-06-24 13:35:44 +02:00
Johannes Marbach
d5ce3a955a Port over copyright rule 2026-06-24 11:08:29 +02:00
Johannes Marbach
d2cb7e334e Remove duplicate paths config 2026-06-24 10:53:14 +02:00
Johannes Marbach
9d61ca13cb Use regex to test node name 2026-06-24 10:49:18 +02:00
Johannes Marbach
51bd17d335 Restrain logging of URL properties 2026-06-24 08:22:02 +02:00
Johannes Marbach
1cd2764eb2 Switch from eslint to oxlint 2026-06-23 15:13:46 +02:00
Johannes Marbach
3fb81032e0 Merge pull request #4054 from element-hq/johannes/pip-border
Add PiP border in 1on1 layouts
2026-06-23 12:38:24 +02:00
Johannes Marbach
da942ea900 Fix formatting 2026-06-23 12:19:40 +02:00
Johannes Marbach
d534c617d2 Eliminate noOutline constant 2026-06-23 11:42:14 +02:00
Robin
ded3d0ab81 Merge pull request #4061 from element-hq/subtitle-only
Show the subtitle of the app bar on small screens
2026-06-23 10:01:55 +02:00
Robin
7d54487354 Merge pull request #4060 from element-hq/speaker-color
Invert the colors of the speaker toggle button
2026-06-23 09:27:39 +02:00
Johannes Marbach
93fae5dd67 Add PiP border in 1on1 layouts
Fixes: #4008
2026-06-23 08:56:41 +02:00
Robin
99e95658df Invert the colors of the speaker toggle button 2026-06-22 16:10:45 +02:00
Timo K
c4fe0223ab remove blur on mobile 2026-06-16 16:42:24 +02:00
Matt Van Horn
9a2fc883b6 fix: announce tab count and position in settings tab bar 2026-06-08 00:49:43 -07:00
101 changed files with 3544 additions and 4654 deletions

View File

@@ -1,120 +0,0 @@
const COPYRIGHT_HEADER = `/*
Copyright %%CURRENT_YEAR%% New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
`;
module.exports = {
plugins: ["matrix-org", "rxjs", "jsdoc", "element-call"],
extends: [
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"plugin:matrix-org/typescript",
"prettier",
"plugin:rxjs/recommended",
"plugin:storybook/recommended",
],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: ["./tsconfig.json"],
},
env: {
browser: true,
node: true,
},
rules: {
"element-call/no-observablescope-leak": "error",
"jsdoc/no-types": "error",
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/check-values": "error",
"jsdoc/check-param-names": "warn",
// "jsdoc/require-param": "warn",
"jsdoc/require-param-description": "warn",
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
"jsx-a11y/media-has-caption": "off",
"react/display-name": "error",
// Encourage proper usage of Promises:
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/require-await": "error",
"@typescript-eslint/await-thenable": "error",
// To help ensure that we get proper vite/rollup lazy loading (e.g. for matrix-js-sdk):
"@typescript-eslint/consistent-type-imports": [
"error",
{ fixStyle: "inline-type-imports" },
],
// To encourage good usage of RxJS:
"rxjs/no-exposed-subjects": "error",
"rxjs/finnish": ["error", { names: { "^this$": false } }],
"no-restricted-imports": [
"error",
{
paths: ["matrix-widget-api", "matrix-js-sdk"].flatMap((lib) =>
["src", "src/", "src/index", "lib", "lib/", "lib/index"]
.map((path) => `${lib}/${path}`)
.map((name) => ({ name, message: `Please use ${lib} instead` })),
),
patterns: [
...["matrix-widget-api"].map((lib) => ({
group: ["src", "src/", "src/**", "lib", "lib/", "lib/**"].map(
(path) => `${lib}/${path}`,
),
message: `Please use ${lib} instead`,
})),
// XXX: We use /lib in lots of places, so allow for now.
...["matrix-js-sdk"].map((lib) => ({
group: ["src", "src/", "src/**"].map((path) => `${lib}/${path}`),
message: `Please use ${lib} instead`,
})),
],
},
],
},
overrides: [
{
files: ["src/*/**"],
rules: {
// In application code we should use the js-sdk logger, never console directly.
"no-console": ["error"],
},
},
{
files: [
"**/*.test.ts",
"**/*.test.tsx",
"**/test.ts",
"**/test.tsx",
"**/test-**",
],
rules: {
// Tests often initialize an ObservableScope in an outer scope in
// beforeEach, which is not actually a problem
"element-call/no-observablescope-leak": "off",
"jsdoc/no-types": "off",
"jsdoc/empty-tags": "off",
"jsdoc/check-property-names": "off",
"jsdoc/check-values": "off",
"jsdoc/check-param-names": "off",
"jsdoc/require-param-description": "off",
},
},
{
files: ["playwright/**"],
rules: {
// Playwright as a `use` function that has nothing to do with React hooks.
"react-hooks/rules-of-hooks": "off",
},
},
],
settings: {
react: {
version: "detect",
},
},
};

View File

@@ -24,8 +24,8 @@ jobs:
run: "pnpm run format:check"
- name: i18n
run: "pnpm run i18n:check"
- name: ESLint
run: "pnpm run lint:eslint"
- name: Lint
run: "pnpm run lint:oxlint"
- name: Type check
run: "pnpm run lint:types"
- name: Dead code analysis

View File

@@ -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:

View File

@@ -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

154
.oxlintrc.json Normal file
View File

@@ -0,0 +1,154 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"eslint",
"import",
"jsdoc",
"jsx-a11y",
"promise",
"react",
"typescript",
"unicorn",
"vitest"
],
"jsPlugins": [
"eslint-plugin-storybook",
"eslint-plugin-element-call"
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "eslint-plugin-rxjs"
],
"categories": {
"correctness": "error",
"perf": "error"
},
"options": {
"denyWarnings": true,
"typeAware": true
},
"env": {
"builtin": true
},
"rules": {
"element-call/copyright-header": [
"error",
"/*\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",
"react/display-name": "error",
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "rxjs/no-exposed-subjects": "error",
// "rxjs/finnish": [
// "error",
// {
// "names": {
// "^this$": false
// }
// }
// ],
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"matrix-widget-api/src",
"matrix-widget-api/src/",
"matrix-widget-api/src/**",
"matrix-widget-api/lib",
"matrix-widget-api/lib/",
"matrix-widget-api/lib/**"
],
"message": "Please use matrix-widget-api instead"
},
{
"group": [
"matrix-js-sdk/src",
"matrix-js-sdk/src/",
"matrix-js-sdk/src/**",
"matrix-js-sdk/lib",
"matrix-js-sdk/lib/",
"matrix-js-sdk/lib/index"
],
"message": "Please use matrix-js-sdk instead"
}
]
}
],
"typescript/no-floating-promises": "error",
"typescript/no-misused-promises": "error",
"typescript/promise-function-async": "error",
"typescript/require-await": "error",
"typescript/await-thenable": "error",
// To help ensure that we get proper vite/rollup lazy loading (e.g. for matrix-js-sdk).
"typescript/consistent-type-imports": [
"error",
{
"fixStyle": "inline-type-imports"
}
],
// TODO: These had to be disabled in the eslint -> oxlint migration. Would be nice to
// enable them in future or at least document why we're disabling them.
"eslint/no-await-in-loop": "off",
"eslint/no-unused-vars": ["error", { "args": "none" }],
"import/default": "off",
"jsdoc/check-tag-names": "off",
"jsx-a11y/prefer-tag-over-role": "off",
"promise/no-callback-in-promise": "off",
"react/jsx-key": "off",
"react/jsx-no-constructed-context-values": "off",
"react/no-array-index-key": "off",
"react/no-children-prop": "off",
"react/no-object-type-as-default-prop": "off",
"typescript/no-misused-spread": "off",
"typescript/no-useless-default-assignment": "off",
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"vitest/expect-expect": "off",
"vitest/no-conditional-expect": "off",
"vitest/no-disabled-tests": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/require-to-throw-message": "off"
},
"overrides": [
{
"files": ["src/*/**"],
"rules": {
// In application code we should use the js-sdk logger, never console directly.
"no-console": "error"
}
},
{
"files": [
"**/*.test.ts",
"**/*.test.tsx",
"**/test.ts",
"**/test.tsx",
"**/test-**"
],
"rules": {
// Tests often initialize an ObservableScope in an outer scope in
// beforeEach, which is not actually a problem
"element-call/no-observablescope-leak": "off",
"jsdoc/empty-tags": "off",
"jsdoc/check-property-names": "off",
"jsdoc/require-param-description": "off",
"jsx-a11y/media-has-caption": "off"
// TODO: Enable once oxlint supports them.
// "jsdoc/check-values": "off",
// "jsdoc/check-param-names": "off",
// "jsdoc/no-types": "off",
}
},
{
"files": ["playwright/**"],
"rules": {
// Playwright as a `use` function that has nothing to do with React hooks.
"react-hooks/rules-of-hooks": "off"
}
}
]
}

View File

@@ -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]

View File

@@ -12,7 +12,7 @@
"feature_use_device_session_member_events": true
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_mode": "legacy",
"matrix_rtc_mode": "compatibility",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,

View File

@@ -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

View 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:365eca844f0782676f2406e5107e2f74fae2ecdb1725cc42060a91681aeee4c4
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:365eca844f0782676f2406e5107e2f74fae2ecdb1725cc42060a91681aeee4c4

View File

@@ -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 users 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. |

66
eslint/CopyrightHeader.js Normal file
View File

@@ -0,0 +1,66 @@
/*
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";
const rule = ESLintUtils.RuleCreator(
() => "https://github.com/element-hq/element-call",
)({
name: "copyright-header",
meta: {
type: "problem",
fixable: "code",
docs: {
description: "Require a copyright header in files.",
},
messages: {
noHeader: "Copyright header is required.",
},
schema: [{ type: "string" }],
},
create(context) {
const code = context.getSourceCode();
return {
Program(node) {
const firstToken = code.getFirstToken(node, { includeComments: false });
if (!firstToken) {
return;
}
const headComments = code.getCommentsBefore(firstToken);
const hasSomeCopyrightHeader = headComments?.some((comment) =>
comment?.value?.includes("Copyright"),
);
if (hasSomeCopyrightHeader) {
return;
}
const headerTemplate = context.options[0];
const fix = headerTemplate
? function (fixer) {
return fixer.insertTextBefore(
firstToken,
headerTemplate.replace(
/%%CURRENT_YEAR%%/g,
new Date().getFullYear(),
),
);
}
: undefined;
context.report({
messageId: "noHeader",
node,
fix,
});
},
};
},
});
export default rule;

View File

@@ -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,27 +46,29 @@ 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)
) {
// Verify that the variable is actually of type ObservableScope
// (expensive, so we check this last)
const services = ESLintUtils.getParserServices(context);
const type = services.getTypeAtLocation(node);
if (type.symbol?.name === "ObservableScope")
// This ObservableScope method call may be causing resource leaks.
context.report({
messageId: "scopeLeak",
loc: node.loc,
node,
});
// TODO: Once oxlint supports lint rules that rely on TypeScript type-awareness,
// Verify that the variable is actually of type ObservableScope rather than just
// checking its name. This is expensive so we should do this last.
//
// const services = ESLintUtils.getParserServices(context);
// const type = services.getTypeAtLocation(node);
// if (type.symbol?.name === "ObservableScope") { ... }
// This ObservableScope method call may be causing resource leaks.
context.report({
messageId: "scopeLeak",
loc: node.loc,
node,
});
}
},
};

View 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;

View File

@@ -1,5 +1,8 @@
module.exports = {
rules: {
"copyright-header": require("./CopyrightHeader").default,
"no-observablescope-leak": require("./NoObservableScopeLeak").default,
"no-top-level-logger-get-child": require("./NoTopLevelLoggerGetChild")
.default,
},
};

10
knip.ts
View File

@@ -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",
@@ -32,6 +37,9 @@ export default {
// https://github.com/webpro-nl/knip/issues/766
"@vector-im/compound-web",
"matrix-widget-api",
// Used by oxlint
"eslint-plugin-element-call",
"eslint-plugin-storybook",
],
ignoreExportsUsedInFile: true,
} satisfies KnipConfig;

View File

@@ -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",

View File

@@ -19,9 +19,9 @@
"serve": "vite preview",
"format": "oxfmt",
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
"lint": "pnpm lint:types && pnpm lint:eslint && pnpm lint:knip",
"lint:eslint": "eslint --max-warnings 0 src playwright",
"lint:eslint-fix": "eslint --max-warnings 0 src playwright --fix",
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip",
"lint:oxlint": "oxlint src playwright",
"lint:oxlint-fix": "oxlint --fix src playwright",
"lint:knip": "knip",
"lint:types": "tsc",
"i18n": "npx i18next-cli extract",
@@ -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",
@@ -60,7 +59,6 @@
"@storybook/addon-docs": "^10.3.6",
"@storybook/addon-vitest": "^10.3.6",
"@storybook/react-vite": "^10.3.6",
"@stylistic/eslint-plugin": "^3.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.0",
@@ -75,8 +73,6 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@typescript-eslint/utils": "^8.61.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0",
@@ -84,25 +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": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-deprecate": "^0.9.0",
"eslint-plugin-element-call": "link:eslint",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "2.1.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-storybook": "^10.3.6",
"eslint-plugin-unicorn": "^56.0.0",
"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",
@@ -116,7 +99,9 @@
"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",
"postcss": "^8.4.41",
"postcss-preset-env": "^10.0.0",
@@ -131,7 +116,6 @@
"sass": "^1.42.1",
"storybook": "^10.3.6",
"typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"uuid": "^14.0.0",
"vaul": "^1.0.0",

View File

@@ -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

View File

@@ -1,2 +0,0 @@
include:
- dev-backend-docker-compose.yml

View File

@@ -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" })

View File

@@ -88,15 +88,18 @@ test("When creator left, avoid reconnect to the same SFU", async ({
await guestCPage.getByRole("radio", { name: "Spotlight" }).check();
await guestCPage.waitForTimeout(1000);
if (wsConnectionCount === 2) {
console.warn("wsConnectionCount is 2, expecting 1 after join");
}
const wsConnectionCountBeforeLeave = wsConnectionCount;
// ========
// the creator leaves the call
await creatorPage.getByTestId("incall_leave").click();
// https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1);
expect(wsConnectionCount).toBe(wsConnectionCountBeforeLeave);
// Wait a bit to be sure that if there was a reconnect, it would have happened by now
await guestCPage.waitForTimeout(6000);
expect(wsConnectionCount).toBe(1);
expect(wsConnectionCount).toBe(wsConnectionCountBeforeLeave);
});

View File

@@ -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 = {

View File

@@ -160,6 +160,7 @@ export class TestHelpers {
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Dismiss" },
{ title: "Turn on key storage", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
@@ -189,11 +190,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" })
@@ -368,25 +385,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 },
);
}
}

View File

@@ -31,12 +31,10 @@ 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();
@@ -83,23 +81,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();
},
);

5712
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -61,10 +61,19 @@
{
"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",
"ignoreDeps": ["eslint-plugin-matrix-org"],
"ignoreDeps": [],
"vulnerabilityAlerts": {
"schedule": ["at any time"],
"prHourlyLimit": 0,

View File

@@ -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)

View File

@@ -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

View File

@@ -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;

View File

@@ -47,7 +47,7 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
};
interface ErrorPageProps {
error: Error | unknown;
error: unknown;
widget: WidgetHelpers | null;
}

View File

@@ -19,6 +19,7 @@ import { Config } from "./config/Config";
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType";
import { platform } from "./Platform";
import { redact } from "./utils/redact";
interface RoomIdentifier {
roomAlias: string | null;
@@ -44,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
@@ -144,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;
}
/**
@@ -451,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"),
@@ -494,7 +507,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
"intent:",
intent,
"\nproperties:",
properties,
redact(properties, "password"),
"configuration:",
configuration,
);

View File

@@ -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>

View File

@@ -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.

View File

@@ -2,7 +2,7 @@
exports[`QrCode > renders 1`] = `
<div
class="qrCode bar"
class="_qrCode_458264 bar"
>
<img
alt="QR Code"

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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>
);
};
@@ -173,7 +164,7 @@ export const LoudspeakerButton: FC<LoudspeakerButtonProps> = ({
iconOnly
Icon={loudspeakerModeEnabled ? VolumeOnSolidIcon : VolumeOffSolidIcon}
{...props}
kind={loudspeakerModeEnabled ? "primary" : "secondary"}
kind={loudspeakerModeEnabled ? "secondary" : "primary"}
aria-checked={loudspeakerModeEnabled}
/>
</Tooltip>

View File

@@ -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"

View File

@@ -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 {

View File

@@ -8,7 +8,7 @@ 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,
SpotlightViewIcon,
GridIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Switch } from "@vector-im/compound-web";
@@ -112,11 +112,17 @@ 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$);
@@ -292,7 +298,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,
})}
@@ -317,7 +323,7 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
aria-label={t("layout_switch_label")}
leftLabel={t("layout_spotlight_label")}
leftValue="spotlight"
leftIcon={SpotlightIcon}
leftIcon={SpotlightViewIcon}
rightLabel={t("layout_grid_label")}
rightValue="grid"
rightIcon={GridIcon}

View File

@@ -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);
});
});

View File

@@ -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>
);

View File

@@ -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"

View File

@@ -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,
);

View File

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 938 B

View File

@@ -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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 B

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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`,

View File

@@ -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);

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -74,6 +74,7 @@ import {
useAppBarSubtitle,
} from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { constant } from "../state/Behavior.ts";
import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css";
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
@@ -93,8 +94,6 @@ declare module "react" {
}
}
const logger = rootLogger.getChild("[InCallView]");
export interface ActiveCallProps extends Omit<
InCallViewProps,
"vm" | "livekitRoom" | "connState" | "footerVm"
@@ -115,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 } =
@@ -217,6 +216,7 @@ export const InCallView: FC<InCallViewProps> = ({
muteStates,
onShareClick,
}) => {
const logger = rootLogger.getChild("[InCallView]");
const { t } = useTranslation();
const { sendReaction, toggleRaisedHand } = useReactionsSender();
@@ -258,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$);
@@ -433,6 +434,11 @@ export const InCallView: FC<InCallViewProps> = ({
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
const showNameTags = useBehavior(vm.showNameTags$);
const showRingingStatus = vm.ringingStatusLocation === "tile";
const showOutline = useBehavior(
model instanceof GridTileViewModel
? model.showOutline$
: constant(false),
);
return model instanceof GridTileViewModel ? (
<GridTile
@@ -446,6 +452,7 @@ export const InCallView: FC<InCallViewProps> = ({
showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
showOutline={showOutline}
focusable={!contentObscured}
/>
) : (
@@ -461,6 +468,7 @@ export const InCallView: FC<InCallViewProps> = ({
showRingingStatus={showRingingStatus}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
itemClassName={styles.spotlightItem}
style={style}
/>
);
@@ -485,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}
@@ -577,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$);
@@ -586,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}

View File

@@ -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();

View File

@@ -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) && (
<>

View File

@@ -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"
>

View File

@@ -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_b00c5f"
>
<div
class="container slot"
class="_container_b00c5f _slot_b0d1cd"
data-id="1"
>
<div
class="slot local slot"
class="_slot_b00c5f _local_b00c5f _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,7 +428,7 @@ 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
@@ -446,7 +446,9 @@ 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

View File

@@ -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"

View File

@@ -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"
@@ -274,7 +274,6 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<input
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_9_"
name="_r_0_"
@@ -315,6 +314,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<input
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_b_"
name="_r_0_"
@@ -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

View File

@@ -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

View File

@@ -137,7 +137,7 @@ export const enableExtendedLivekitLogs = new Setting<boolean>(
export const matrixRTCMode = new Setting<MatrixRTCMode>(
"matrix-rtc-mode",
MatrixRTCMode.Legacy,
MatrixRTCMode.Compatibility,
);
export const customLivekitUrl = new Setting<string | null>(

View File

@@ -253,7 +253,7 @@ describe("Test mappings", () => {
});
describe("Test select a device", () => {
it(`Switch to correct device `, () => {
it(`Switch to correct device`, () => {
withTestScheduler(({ cold, schedule, expectObservable, flush }) => {
const controlledAudioOutput = new AndroidControlledAudioOutput(
cold("a", { a: FULL_DEVICE_LIST }),

View File

@@ -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(

View File

@@ -22,6 +22,7 @@ import { SyncState } from "matrix-js-sdk";
import {
ConnectionState,
type LocalTrackPublication,
type Participant,
type RemoteParticipant,
} from "livekit-client";
import * as ComponentsCore from "@livekit/components-core";
@@ -1031,6 +1032,10 @@ describe.each([
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}),
videoEnabled: new Map<Participant, Behavior<boolean>>([
[localParticipant, constant(true)],
[aliceParticipant, constant(true)],
]),
},
(vm) => {
schedule(modeInputMarbles, {
@@ -1060,6 +1065,33 @@ describe.each([
});
});
test("expanded spotlight layout hides PiP tile in one-on-one voice call", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
withCallViewModel(
{
remoteParticipants$: constant([aliceParticipant]),
roomMembers: [local, alice],
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
videoEnabled: new Map<Participant, Behavior<boolean>>([
[localParticipant, constant(false)],
[aliceParticipant, constant(false)],
]),
windowSize$: constant({ width: 700, height: 380 }), // Mobile phone in landscape
},
(vm) => {
// Layout should show remote tile only
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
a: {
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: undefined,
},
});
},
);
});
});
test("spotlight remembers whether it's expanded", () => {
withTestScheduler(({ schedule, expectObservable }) => {
// Start in spotlight mode, then switch to grid and back to spotlight a
@@ -1096,7 +1128,7 @@ describe.each([
b: {
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: `${localId}:0`,
pip: undefined,
},
c: {
type: "grid",

View File

@@ -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,
@@ -155,8 +155,8 @@ import {
createRingingMedia,
type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
const logger = rootLogger.getChild("[CallViewModel]");
//TODO
// Larger rename
// member,membership -> rtcMember
@@ -207,6 +207,7 @@ export type WindowMode = "normal" | "narrow" | "flat" | "pip";
interface LayoutScanState {
layout: Layout | null;
overflowing: boolean;
tiles: TileStore;
}
@@ -359,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>;
@@ -410,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();
@@ -419,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.
@@ -941,8 +948,8 @@ export function createCallViewModel$(
);
/**
* Local user media suitable for displaying in a PiP (undefined if not found
* or if user prefers to not see themselves).
* Local user media suitable for displaying in a PiP (undefined if not found,
* video is muted, or if user prefers to not see themselves).
*/
const localUserMediaForPip$ = scope.behavior<
LocalUserMediaViewModel | undefined
@@ -954,8 +961,10 @@ export function createCallViewModel$(
m.type === "user" && m.local,
);
if (!localUserMedia) return of(undefined);
return localUserMedia.alwaysShow$.pipe(
map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)),
return combineLatest(
[localUserMedia.videoEnabled$, localUserMedia.alwaysShow$],
(videoEnabled, alwaysShow) =>
videoEnabled && alwaysShow ? localUserMedia : undefined,
);
}),
),
@@ -1464,7 +1473,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);
@@ -1472,7 +1481,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],
@@ -1482,6 +1491,8 @@ export function createCallViewModel$(
({ tiles: prevTiles }, [media, visibleTiles]) => {
let layout: Layout;
let newTiles: TileStore;
let pip: GridTileViewModel | undefined;
let overflowing = false;
switch (media.type) {
case "grid":
case "spotlight-landscape":
@@ -1493,6 +1504,7 @@ export function createCallViewModel$(
setVisibleTiles,
prevTiles,
);
overflowing = newTiles.gridTiles.length > visibleTiles;
break;
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(
@@ -1507,6 +1519,7 @@ export function createCallViewModel$(
landscapePipAlignment$,
prevTiles,
);
pip = layout.pip;
break;
case "one-on-one-portrait":
[layout, newTiles] = oneOnOnePortraitLayout(
@@ -1515,15 +1528,20 @@ export function createCallViewModel$(
portraitPipAlignment$,
prevTiles,
);
pip = layout.pip;
break;
case "pip":
[layout, newTiles] = pipLayout(media, prevTiles);
break;
}
return { layout, tiles: newTiles };
for (const tile of newTiles.gridTiles) {
tile.setShowOutline(tile === pip);
}
return { layout, overflowing, tiles: newTiles };
},
{ layout: null, tiles: TileStore.empty() },
{ layout: null, overflowing: false, tiles: TileStore.empty() },
),
),
);
@@ -1535,6 +1553,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.
*/
@@ -1776,6 +1798,7 @@ export function createCallViewModel$(
settingsOpen$: settingsOpen$,
setSettingsOpen$: setSettingsOpen$,
edgeToEdge$,
overflowing$,
earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: localMembership.reconnecting$,
@@ -1787,6 +1810,7 @@ export function createCallViewModel$(
function getE2eeKeyProvider(
e2eeSystem: EncryptionSystem,
rtcSession: MatrixRTCSession,
logger: Logger,
): BaseKeyProvider | undefined {
if (e2eeSystem.kind === E2eeType.NONE) return undefined;

View File

@@ -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;

View File

@@ -170,7 +170,7 @@ export const createLocalMembership$ = ({
logger: parentLogger,
muteStates,
matrixRTCSession,
roomId: roomId,
roomId,
}: Props): {
/**
* This request to start audio and video tracks.

View File

@@ -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) => {

View File

@@ -102,7 +102,7 @@ export class RtcTransportAutoDiscovery {
const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(),
);
const first = transportList.filter(isLivekitTransportConfig)[0];
const first = transportList.find(isLivekitTransportConfig);
if (first) {
return first;
} else {

View File

@@ -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.

View File

@@ -0,0 +1,162 @@
/*
Copyright 2026 Element Corp.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type Observable, of } from "rxjs";
import { ObservableScope } from "./ObservableScope";
import { constant } from "./Behavior";
import { type SelectedAudioOutputDevice } from "./MediaDevices";
import {
availableOutputDevices$,
type Controls,
type OutputDevice,
outputDevice$,
} from "../controls";
import {
EARPIECE_CONFIG_ID,
IOSControlledAudioOutput,
} from "./IOSControlledAudioOutput";
// `vi.mock` calls are hoisted above all imports, so the static imports below
// already see these mocks. Force the iOS platform so that the virtual earpiece
// is available, and stub the livekit device observer (only subscribed for its
// side effects).
vi.mock("../Platform", () => ({ platform: "ios" }));
vi.mock("@livekit/components-core", () => ({
createMediaDeviceObserver: (): Observable<MediaDeviceInfo[]> => of([]),
}));
// On iOS the host reports a single device for the current route. When output is
// on the loudspeaker it is flagged `forEarpiece`, which makes the controller
// expose a virtual earpiece device.
const SPEAKER: OutputDevice = {
id: "speaker",
name: "Speaker",
isSpeaker: true,
forEarpiece: true,
};
// A connected headset (e.g. Bluetooth) is reported as a plain named device,
// with neither the speaker nor earpiece flag set.
const HEADSET: OutputDevice = {
id: "bt",
name: "AirPods",
};
let testScope: ObservableScope;
beforeEach(() => {
testScope = new ObservableScope();
window.controls = {
onAudioDeviceSelect: vi.fn(),
onOutputDeviceSelect: vi.fn(),
} as unknown as Controls;
});
afterEach(() => {
testScope.end();
});
/**
* Subscribe to the controller's `selected$` and return a getter for the latest
* emitted value.
*/
function latestSelection(
output: InstanceType<typeof IOSControlledAudioOutput>,
): () => SelectedAudioOutputDevice | undefined {
let latest: SelectedAudioOutputDevice | undefined;
output.selected$.subscribe((s) => {
latest = s;
});
return () => latest;
}
describe("Default selection", () => {
it("defaults to the earpiece for voice (audio) calls", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
expect(selected()).toEqual({
id: EARPIECE_CONFIG_ID,
virtualEarpiece: true,
});
expect(window.controls.onAudioDeviceSelect).toHaveBeenLastCalledWith(
EARPIECE_CONFIG_ID,
);
});
it("defaults to the speaker for video calls", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"video",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
});
it("keeps a headset for voice calls instead of forcing the earpiece", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
// The host proposes the headset as the route (listed first), even though a
// forEarpiece device is also present so the virtual earpiece exists.
availableOutputDevices$.next([HEADSET, SPEAKER]);
expect(selected()).toEqual({ id: HEADSET.id, virtualEarpiece: false });
});
});
describe("Explicit selection", () => {
it("an explicit user selection overrides the earpiece default", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
// Earpiece by default for a voice call...
expect(selected()).toEqual({
id: EARPIECE_CONFIG_ID,
virtualEarpiece: true,
});
// ...until the user explicitly picks the speaker.
output.select(SPEAKER.id);
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
});
it("a host selection overrides the earpiece default", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
outputDevice$.next(SPEAKER.id);
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
});
});

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import {
availableOutputDevices$ as controlledAvailableOutputDevices$,
outputDevice$ as controlledOutputSelection$,
@@ -24,7 +25,7 @@ import {
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
const EARPIECE_CONFIG_ID = "earpiece-id";
export const EARPIECE_CONFIG_ID = "earpiece-id";
/**
* A special implementation of audio output that allows the hosting application
@@ -94,7 +95,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
),
],
(available, preferredId) => {
const id = preferredId ?? available.keys().next().value;
const id = preferredId ?? this.chooseDefaultId(available);
return id === undefined
? undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
@@ -106,9 +107,41 @@ export class IOSControlledAudioOutput implements MediaDevice<
),
);
/**
* Chooses the default output device when no explicit selection (from the user
* or the hosting application) has been made yet.
*
* For voice calls (`initialIntent === "audio"`) we want to start on the
* earpiece rather than the speaker, like a regular phone call. We only
* override when the device that would otherwise be the default is the
* speaker: if the host already routed to a headset (e.g. Bluetooth) — which
* is reported as a plain named device, not "speaker"/"earpiece" — we keep it.
* This mirrors the Android behaviour in {@link AndroidControlledAudioOutput}.
*/
private chooseDefaultId(
available: Map<string, AudioOutputDeviceLabel>,
): string | undefined {
const firstId = available.keys().next().value;
if (this.initialIntent === "audio") {
const firstLabel =
firstId !== undefined ? available.get(firstId) : undefined;
if (firstLabel?.type === "speaker") {
for (const [id, label] of available)
if (label.type === "earpiece") {
this.logger.info(
`IOS routing: default to earpiece ${id} instead of speaker for voice call`,
);
return id;
}
}
}
return firstId;
}
public constructor(
private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope,
private readonly initialIntent: RTCCallIntent | undefined = undefined,
) {
this.selected$.subscribe((device) => {
// Let the hosting application know which output device has been selected.

View File

@@ -376,7 +376,11 @@ export class MediaDevices {
getUrlParams().callIntent,
window.controls,
)
: new IOSControlledAudioOutput(this.usingNames$, this.scope)
: new IOSControlledAudioOutput(
this.usingNames$,
this.scope,
getUrlParams().callIntent,
)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =

View File

@@ -26,7 +26,7 @@ export function oneOnOnePortraitLayout(
prevTiles: TileStore,
): [OneOnOnePortraitLayout, 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();

View File

@@ -21,6 +21,7 @@ export function pipLayout(
update.registerSpotlight(
media.spotlight,
platform === "desktop" ? false : true,
"transparent",
);
const tiles = update.build();
return [

View 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[];
}

View File

@@ -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();

View File

@@ -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$,
);
}
}
@@ -131,9 +150,9 @@ export class TileStoreBuilder {
private numGridEntries = 0;
// A sparse array of grid entries which should be kept in the same spots as
// which they appeared in the previous grid
private readonly stationaryGridEntries: GridTileData[] = new Array(
this.prevGrid.length,
);
private readonly stationaryGridEntries: GridTileData[] = Array.from({
length: this.prevGrid.length,
});
// Grid entries which should now enter the visible section of the grid
private readonly visibleGridEntries: GridTileData[] = [];
// Grid entries which should now enter the invisible section of the grid
@@ -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;
}
}

View File

@@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject } from "rxjs";
import { type Behavior } from "./Behavior";
import { type MediaViewModel } from "./media/MediaViewModel";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
@@ -17,18 +19,25 @@ function createId(): string {
export class GridTileViewModel {
public readonly id = createId();
private readonly _showOutline$ = new BehaviorSubject(false);
public readonly showOutline$: Behavior<boolean> = this._showOutline$;
public constructor(
public readonly media$: Behavior<
UserMediaViewModel | RingingMediaViewModel
>,
) {}
public setShowOutline(value: boolean): void {
this._showOutline$.next(value);
}
}
export class SpotlightTileViewModel {
public constructor(
public readonly media$: Behavior<MediaViewModel[]>,
public readonly maximised$: Behavior<boolean>,
public readonly background$: Behavior<"solid" | "transparent">,
) {}
}

124
src/tabs/Tabs.test.tsx Normal file
View 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();
});

View File

@@ -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}
>

View File

@@ -66,6 +66,11 @@ borders don't support gradients */
opacity: 1;
}
.tile.outline {
outline: var(--cpd-border-width-1) solid
var(--cpd-color-border-interactive-secondary);
}
@media (hover: hover) {
.tile:hover {
outline: var(--cpd-border-width-2) solid

View File

@@ -78,6 +78,7 @@ test("GridTile is accessible", async () => {
showSpeakingIndicators
showNameTags
showRingingStatus
showOutline
focusable
/>
</ReactionsSenderProvider>,
@@ -110,6 +111,7 @@ test("GridTile displays ringing media", async () => {
showSpeakingIndicators
showNameTags
showRingingStatus
showOutline
focusable
/>
</ReactionsSenderProvider>,

View File

@@ -398,6 +398,7 @@ interface GridTileProps {
showSpeakingIndicators: boolean;
showNameTags: boolean;
showRingingStatus: boolean;
showOutline: boolean;
focusable: boolean;
}
@@ -406,7 +407,9 @@ export const GridTile: FC<GridTileProps> = ({
vm,
showSpeakingIndicators,
showRingingStatus,
showOutline,
onOpenProfile,
className,
...props
}) => {
const ourRef = useRef<HTMLDivElement | null>(null);
@@ -423,6 +426,7 @@ export const GridTile: FC<GridTileProps> = ({
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
showStatus={showRingingStatus}
className={classNames(className, { [styles.outline]: showOutline })}
{...props}
/>
);
@@ -435,6 +439,7 @@ export const GridTile: FC<GridTileProps> = ({
onOpenProfile={onOpenProfile}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
className={classNames(className, { [styles.outline]: showOutline })}
{...props}
/>
);
@@ -446,6 +451,7 @@ export const GridTile: FC<GridTileProps> = ({
showSpeakingIndicators={showSpeakingIndicators}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
className={classNames(className, { [styles.outline]: showOutline })}
{...props}
/>
);

View File

@@ -27,6 +27,10 @@ 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);
}
@@ -41,13 +45,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 +125,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 {

View File

@@ -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();
});
});
});

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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}
/>
))}

View File

@@ -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);
});
});

View File

@@ -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} />

58
src/utils/redact.test.ts Normal file
View File

@@ -0,0 +1,58 @@
/*
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, test } from "vitest";
import { redact } from "./redact";
test("empty object", () => {
expect(redact({})).toEqual({});
});
test("no keys", () => {
expect(redact({ foo: "bar" })).toEqual({ foo: "bar" });
});
test("redact one key", () => {
expect(redact({ foo: "bar" }, "foo")).toEqual({ foo: "<redacted>" });
});
test("redact two keys", () => {
expect(redact({ foo: "bar", bar: "foo" }, "foo", "bar")).toEqual({
foo: "<redacted>",
bar: "<redacted>",
});
});
test("no redaction of unrelated keys", () => {
expect(redact({ foo: "bar", bar: "foo" }, "foo")).toEqual({
foo: "<redacted>",
bar: "foo",
});
});
test("no redaction of missing keys", () => {
expect(
redact({ foo: "bar" } as { foo: string; bar: string | undefined }, "bar"),
).toEqual({
foo: "bar",
});
});
test("no redaction of null values", () => {
expect(redact({ foo: "bar", bar: null }, "bar")).toEqual({
foo: "bar",
bar: null,
});
});
test("no redaction of undefined values", () => {
expect(redact({ foo: "bar", bar: undefined }, "bar")).toEqual({
foo: "bar",
bar: undefined,
});
});

25
src/utils/redact.ts Normal file
View File

@@ -0,0 +1,25 @@
/*
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.
*/
/**
* Redacts properties in the supplied object by replacing them with
* a constant value.
* @param obj Object in which to perform redaction
* @param keys Keys to be redacted in the object
* @returns A new object with the specified properties redacted
*/
export function redact<T extends object>(
obj: T,
...keys: (keyof T)[]
): Record<keyof T, unknown> {
const result: Record<keyof T, unknown> = { ...obj };
for (const key of keys)
if (key in result && result[key] != null) {
result[key] = "<redacted>";
}
return result;
}

View File

@@ -128,8 +128,8 @@ export function getBasicRTCSession(
/**
* Construct a basic CallViewModel to test components that make use of it.
* @param members
* @param initialRtcMemberships
* @param members - Room members to include in the call.
* @param initialRtcMemberships - RTC memberships to start with.
* @returns
*/
export function getBasicCallViewModelEnvironment(

View File

@@ -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";

View File

@@ -38,7 +38,7 @@
"livekit-client/dist/src/proto/livekit_models_pb": [
"./node_modules/@livekit/protocol/src/gen/livekit_models_pb.d.ts"
]
},
}
// TODO: Enable the following options later.
// "forceConsistentCasingInFileNames": true,
@@ -48,8 +48,6 @@
// "noPropertyAccessFromIndexSignature": true,
// "noUncheckedIndexedAccess": true,
// "noUnusedParameters": true,
"plugins": [{ "name": "typescript-eslint-language-service" }]
},
"include": [
"./src/**/*.ts",

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