Compare commits

..
Author SHA1 Message Date
Johannes Marbach 321340a192 Switch JS SDK to a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01 2026-11-03 16:31:49 +01:00
Johannes MarbachandTimo 517edc12ac Extend comment
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2026-11-03 11:44:04 +01:00
Johannes Marbach 94ee13c639 Add support for m.livekit 2026-10-29 15:40:28 +01:00
Johannes Marbach 26b09adc64 Merge pull request #4366 from junkerderprovinz/junkerderprovinz/firefox-websocket-prime
Retry the SFU connection once in Firefox when the WebSocket fails
2026-10-29 08:27:45 +01:00
Johannes Marbach b7655417d7 Move comment inline 2026-10-29 08:05:07 +01:00
renovate[bot] 2dd000bab0 Update ghcr.io/element-hq/element-web:develop Docker digest to f4e3c42 (#4385)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 03:19:17 +00:00
junkerderprovinz d6e3fefe82 Retry the join in Firefox instead of priming the WebSocket
The second connection only happens when the first one actually failed
on the WebSocket. connect() rejects after its socket has failed and the
engine is closed, so the retry never overlaps the failing socket.
2026-10-28 23:44:19 +01:00
Johannes Marbach b7bc218b19 Merge pull request #4367 from RaphaelFakhri/RaphaelFakhri/shared-key-storage-key
Read and write the room shared key under its single-prefixed storage key
2026-10-28 16:21:11 +01:00
Raphael Fakhri 85424f76b1 Ignore the URL password when the parameters are for another room 2026-10-28 14:33:07 +00:00
renovate[bot] 6793a4b46e Update ghcr.io/element-hq/element-web:develop Docker digest to ee8ce55 (#4380)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-28 02:32:09 +00:00
Robin 619f5ac3a2 Merge pull request #4375 from element-hq/robin/stats-crash
Fix crash when joining call with connection stats enabled
2026-10-27 18:08:31 +01:00
Robin 5780597b83 Fix crash when joining call with connection stats enabled
As demonstrated by the test. This crashed with "Behavior failed to synchronously emit an initial value."
2026-10-27 16:17:58 +01:00
Robin 76da59be40 Merge pull request #4301 from element-hq/floating-tile
Make drag & drop of floating tiles less janky
2026-10-27 12:16:12 +01:00
RaphaelFakhri dc1459e45e Prefer the stored shared key when the parameters are for another room
keyForRoom returned the password from the parameters even when they were for a different room, and useRoomSharedKey then saved it for this room. Use the stored key first in that case, so another room's password can't replace it.
2026-10-26 21:13:23 +00:00
Junker der Provinz dfff8be38e Wait for the primed WebSocket to close before connecting 2026-10-26 18:21:19 +01:00
Robin ed6a0e1341 Merge pull request #4371 from element-hq/ellipsize-title
Ellipsise app bar text on mobile
2026-10-26 13:45:06 +01:00
renovate[bot]andJohannes Marbach 90ccf62d5c Update ghcr.io/element-hq/element-web:develop Docker digest to c9ffe3e (#4356)
* Update ghcr.io/element-hq/element-web:develop Docker digest to 79e47d8

* Adapt to new recovery popup button title

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-10-26 12:44:39 +00:00
Robin dde07f6d54 Ellipsise app bar text on mobile
We already had text-overflow: ellipsis set on the title and subtitle elements of the app bar, but due to an annoying quirk of place-items: center, this had no effect. place-items: center stretch seems to give the text elements the right size constraints.
2026-10-26 13:25:13 +01:00
Raphael Fakhri bda7b86c39 Read and write the room shared key under its single-prefixed storage key 2026-10-22 12:24:39 +00:00
Junker der ProvinzandMarc ee507ab3ac Prime the SFU WebSocket in Firefox before connecting
Behind a proxy, Firefox fails the first WebSocket it opens to a host, so
the first join fails. The livekit-client in the report turned this into an
insufficient capacity error; current versions report a generic connection
error. Opening a throwaway WebSocket first lets the real connection
through.

Co-authored-by: Marc <10048869+MarcWadai@users.noreply.github.com>
2026-10-22 11:28:11 +02:00
22 changed files with 333 additions and 128 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ services:
- ./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:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
image: ghcr.io/element-hq/element-web:develop@sha256:f4e3c426965bbc96b7b43c2eff9f1dd0e96467508e5aff43b85d2a2cc1226870
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:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
image: ghcr.io/element-hq/element-web:develop@sha256:f4e3c426965bbc96b7b43c2eff9f1dd0e96467508e5aff43b85d2a2cc1226870
+1 -1
View File
@@ -107,7 +107,7 @@
"livekit-client": "^2.18.1",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
"matrix-widget-api": "^1.18.0",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
+6 -2
View File
@@ -159,7 +159,7 @@ export class TestHelpers {
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Dismiss" },
{ title: "Back up your chats", button: "Not now" },
{ title: "Turn on key storage", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
@@ -178,7 +178,11 @@ export class TestHelpers {
);
if (toastConfig) {
await toast.getByRole("button", { name: toastConfig.button }).click();
// Bounded so that a renamed button leaves the toast in place and the
// next step reports it, instead of hanging until the test timeout.
await toast
.getByRole("button", { name: toastConfig.button })
.click({ timeout: 5000 });
} else {
// Unknown toast. We don't want to act on unknown toasts
break;
+33 -56
View File
@@ -66,7 +66,7 @@ importers:
version: 10.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@rolldown/plugin-babel':
specifier: ^0.2.3
version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
'@sentry/react':
specifier: ^8.0.0
version: 8.55.2(react@19.2.8)
@@ -138,7 +138,7 @@ importers:
version: 10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
'@vitest/browser-playwright':
specifier: ^4.1.5
version: 4.1.11(playwright@1.62.1)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11)
@@ -191,8 +191,8 @@ importers:
specifier: ^1.9.1
version: 1.9.2
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
matrix-widget-api:
specifier: ^1.18.0
version: 1.20.0
@@ -397,8 +397,8 @@ packages:
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/runtime@8.0.0':
resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==}
'@babel/runtime@8.0.8':
resolution: {integrity: sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==}
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
@@ -408,10 +408,6 @@ packages:
resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.29.8':
resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.11':
resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==}
engines: {node: '>=6.9.0'}
@@ -3421,11 +3417,6 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.18.0:
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.19.0:
resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
engines: {node: '>=0.4.0'}
@@ -4759,9 +4750,9 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
resolution: {gitHosted: true, integrity: sha512-Ly8eS7fNNKDo9KmPvTkE49KvD8iMUmrVWJMNERNuxc8UksMLMG36NLz3fs8HytdwrB7AKPfyVo392VZsxILDIg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076}
version: 44.0.0
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
version: 45.0.0
engines: {node: '>=22.0.0'}
matrix-widget-api@1.20.0:
@@ -6357,10 +6348,10 @@ snapshots:
'@babel/helper-compilation-targets': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.8
'@babel/parser': 7.29.11
'@babel/template': 7.29.7
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
'@babel/traverse': 7.29.10(supports-color@7.2.0)
'@babel/types': 7.29.11
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@7.2.0)
@@ -6372,8 +6363,8 @@ snapshots:
'@babel/generator@7.29.8':
dependencies:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
'@babel/parser': 7.29.11
'@babel/types': 7.29.11
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
@@ -6390,8 +6381,8 @@ snapshots:
'@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
'@babel/traverse': 7.29.10(supports-color@7.2.0)
'@babel/types': 7.29.11
transitivePeerDependencies:
- supports-color
@@ -6400,7 +6391,7 @@ snapshots:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/traverse': 7.29.10(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -6417,7 +6408,7 @@ snapshots:
'@babel/helpers@7.29.7':
dependencies:
'@babel/template': 7.29.7
'@babel/types': 7.29.8
'@babel/types': 7.29.11
'@babel/parser@7.29.11':
dependencies:
@@ -6433,13 +6424,13 @@ snapshots:
'@babel/runtime@7.29.7': {}
'@babel/runtime@8.0.0': {}
'@babel/runtime@8.0.8': {}
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
'@babel/parser': 7.29.11
'@babel/types': 7.29.11
'@babel/traverse@7.29.10(supports-color@7.2.0)':
dependencies:
@@ -6448,19 +6439,7 @@ snapshots:
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.11
'@babel/template': 7.29.7
'@babel/types': 7.29.8
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.8(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
'@babel/types': 7.29.8
'@babel/types': 7.29.11
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -8167,13 +8146,13 @@ snapshots:
'@rolldown/binding-win32-x64-msvc@1.2.15':
optional: true
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
picomatch: 4.0.4
rolldown: 1.2.15
optionalDependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 8.0.8
vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)
'@rolldown/pluginutils@1.0.1': {}
@@ -8925,12 +8904,12 @@ snapshots:
- '@types/react-dom'
- react-dom
'@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
'@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)
optionalDependencies:
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
babel-plugin-react-compiler: 1.0.0
'@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11)':
@@ -9055,14 +9034,12 @@ snapshots:
'@webcontainer/env@1.1.1': {}
acorn-jsx@5.3.2(acorn@8.18.0):
acorn-jsx@5.3.2(acorn@8.19.0):
dependencies:
acorn: 8.18.0
acorn: 8.19.0
acorn@8.16.0: {}
acorn@8.18.0: {}
acorn@8.19.0: {}
agent-base@6.0.2(supports-color@7.2.0):
@@ -9733,8 +9710,8 @@ snapshots:
espree@9.6.1:
dependencies:
acorn: 8.18.0
acorn-jsx: 5.3.2(acorn@8.18.0)
acorn: 8.19.0
acorn-jsx: 5.3.2(acorn@8.19.0)
eslint-visitor-keys: 3.4.3
esprima@4.0.1: {}
@@ -10468,9 +10445,9 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 8.0.8
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
another-json: 0.2.0
bs58: 6.0.0
@@ -11701,7 +11678,7 @@ snapshots:
terser@5.46.1:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.18.0
acorn: 8.19.0
commander: 2.20.3
source-map-support: 0.5.21
+1 -1
View File
@@ -538,7 +538,7 @@ backend is handed the whole session's view and gives back media:
type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
export interface MediaBackend {
readonly transportType: string; // "livekit"
readonly transportTypes: readonly string[]; // ["m.livekit", "livekit"]
/** Authenticate with the transport the client found; the probe for delegation runs here. */
prepareLocalTransport(
transport: Transport,
+1 -1
View File
@@ -164,7 +164,7 @@ function setup(
context: MediaBackendContext,
): MediaBackend => {
const backend: MediaBackend = {
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
prepareLocalTransport: async () => fake.prepared.promise,
delegateDelayedLeave: fake.delegateDelayedLeave,
local: {
+3 -2
View File
@@ -136,7 +136,8 @@ export function createRTCParticipation(
// fallback: another member's SFU may let us publish (MSC4195). LiveKit only,
// so it stays out of the backend interface
const transportFromBackends = async (): Promise<Transport | null> =>
backend.transportType === "livekit"
backend.transportTypes.includes("m.livekit") ||
backend.transportTypes.includes("livekit")
? findRemotePublishLivekitTransport({
client,
// A snapshot: if nobody with a usable SFU is here yet, the user has
@@ -152,7 +153,7 @@ export function createRTCParticipation(
const preparedTransport$ = from(
discoverLocalTransport(
client,
backend.transportType,
backend.transportTypes,
options,
transportFromBackends,
logger,
+1 -1
View File
@@ -230,7 +230,7 @@ export interface DataMessage {
* backend-specific developer panel and for connection diagnostics.
*/
export interface TransportMetadata {
/** `"livekit"` today. */
/** `"m.livekit"` or `"livekit"` today. */
type: string;
/** Stable key, unique per transport in the slot: the raw transport, serialised with sorted keys. */
id: string;
+5 -5
View File
@@ -48,7 +48,7 @@ describe("discoverLocalTransport", () => {
await expect(
discoverLocalTransport(
c,
"livekit",
["m.livekit", "livekit"],
{ transport: hostTransport },
fromBackends(),
logger,
@@ -61,7 +61,7 @@ describe("discoverLocalTransport", () => {
await expect(
discoverLocalTransport(
client([homeserverTransport]),
"livekit",
["m.livekit", "livekit"],
{ fallbackTransport: hostTransport },
fromBackends(),
logger,
@@ -73,7 +73,7 @@ describe("discoverLocalTransport", () => {
await expect(
discoverLocalTransport(
client([{ type: "not_livekit" }]),
"livekit",
["m.livekit", "livekit"],
{ fallbackTransport: hostTransport },
fromBackends(),
logger,
@@ -87,7 +87,7 @@ describe("discoverLocalTransport", () => {
await expect(
discoverLocalTransport(
client([homeserverTransport]),
"livekit",
["m.livekit", "livekit"],
{},
backends,
logger,
@@ -99,7 +99,7 @@ describe("discoverLocalTransport", () => {
it("fails naming the homeserver where there is no transport at all", async () => {
const promise = discoverLocalTransport(
client([]),
"livekit",
["m.livekit", "livekit"],
{},
fromBackends(),
logger,
+5 -4
View File
@@ -15,14 +15,15 @@ import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
/**
* The transport the local member advertises: the one the host names, else the
* homeserver's first of the type the backend serves, else the host's fallback,
* else the first the backends can provide, which are asked only then.
* homeserver's first of the most preferred type the backend serves, else the
* host's fallback, else the first the backends can provide, which are asked
* only then.
*
* @throws {MatrixRTCTransportMissingError} where none of these exists.
*/
export async function discoverLocalTransport(
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
transportType: string,
transportTypes: readonly string[],
{
transport: override,
fallbackTransport,
@@ -34,7 +35,7 @@ export async function discoverLocalTransport(
override ??
(await new TransportAutoDiscovery({
client,
transportType,
transportTypes,
fallbackTransport,
logger,
}).discoverPreferredTransport()) ??
@@ -61,7 +61,7 @@ describe("TransportAutoDiscovery", () => {
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
fallbackTransport: configTransport,
logger: rootLogger,
});
@@ -75,6 +75,29 @@ describe("TransportAutoDiscovery", () => {
},
);
it("prefers a transport of an earlier type over one listed before it", async () => {
const mLivekit: Transport = {
type: "m.livekit",
url: "wss://sfu.example.org",
};
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue([
backendTransport,
mLivekit,
]);
const discovery = new TransportAutoDiscovery({
client,
transportTypes: ["m.livekit", "livekit"],
fallbackTransport: configTransport,
logger: rootLogger,
});
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
mLivekit,
);
});
it("Retries limit_exceeded backend transport", async () => {
const client = makeClient();
client._unstable_getRTCTransports
@@ -92,7 +115,7 @@ describe("TransportAutoDiscovery", () => {
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
fallbackTransport: configTransport,
logger: rootLogger,
});
@@ -116,7 +139,7 @@ describe("TransportAutoDiscovery", () => {
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
fallbackTransport: configTransport,
logger: rootLogger,
});
@@ -133,7 +156,7 @@ describe("TransportAutoDiscovery", () => {
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
fallbackTransport: undefined,
logger: rootLogger,
});
+15 -16
View File
@@ -17,27 +17,27 @@ type TransportDiscoveryClient = Pick<
export interface TransportAutoDiscoveryProps {
client: TransportDiscoveryClient;
/** The transport type the media backend serves. */
transportType: string;
/** A transport to use when the homeserver advertises none of the type. */
/** The transport types we want to discover. Those are the ones, the local media backend supports. most preferred first. */
transportTypes: readonly string[];
/** A transport to use when the homeserver advertises none of the types. */
fallbackTransport?: Transport;
logger: Logger;
}
export class TransportAutoDiscovery {
private readonly client: TransportDiscoveryClient;
private readonly transportType: string;
private readonly transportTypes: readonly string[];
private readonly fallbackTransport: Transport | undefined;
private readonly logger: Logger;
public constructor({
client,
transportType,
transportTypes,
fallbackTransport,
logger,
}: TransportAutoDiscoveryProps) {
this.client = client;
this.transportType = transportType;
this.transportTypes = transportTypes;
this.fallbackTransport = fallbackTransport;
this.logger = logger.getChild("[TransportAutoDiscovery]");
}
@@ -64,7 +64,7 @@ export class TransportAutoDiscovery {
}
/**
* Fetches the first transport of the backend's type from the homeserver.
* Fetches the first transport of the backend's most preferred type from the homeserver.
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
* @private
*/
@@ -76,17 +76,16 @@ export class TransportAutoDiscovery {
const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(),
);
const first = transportList.find(
(transport) => transport.type === this.transportType,
);
if (first) {
return first;
} else {
this.logger.info(
`No ${this.transportType} transport found in getRTCTransports end point`,
transportList,
for (const type of this.transportTypes) {
const first = transportList.find(
(transport) => transport.type === type,
);
if (first) return first;
}
this.logger.info(
`No ${this.transportTypes.join(" or ")} transport found in getRTCTransports end point`,
transportList,
);
} catch (ex) {
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
}
+5 -2
View File
@@ -113,8 +113,11 @@ export interface DataPacket {
}
export interface MediaBackend {
/** The membership transport type this backend serves: "livekit". */
readonly transportType: string;
/**
* The membership transport types this backend serves, ordered decreasingly
* by preference.
*/
readonly transportTypes: readonly string[];
/**
* Makes `transport` usable as the one the local member publishes on. The
@@ -16,6 +16,7 @@ import {
vi,
} from "vitest";
import {
ConnectionError,
type LocalParticipant,
type RemoteParticipant,
type RemoteTrack,
@@ -41,7 +42,7 @@ import {
} from "./Connection.ts";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
import { MatrixRTCError } from "../../errors";
import { LivekitConnectionError, MatrixRTCError } from "../../errors";
import { testJWTToken } from "../../utils/test-fixtures";
import { ownMemberMock } from "../../utils/test";
import { mockRemoteParticipant } from "./test";
@@ -57,6 +58,10 @@ let fakeLocalParticipant: MockedObject<LocalParticipant>;
const ROOM_ID = "!roomID:example.org";
const SLOT_ID = "m.call#ROOM";
const FIREFOX_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0";
const CHROME_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
const transport: UnstableLivekitTransport = {
type: "livekit",
@@ -147,6 +152,13 @@ function setupRemoteConnection(): Connection {
return new Connection(opts, logger);
}
function mockUserAgent(userAgent: string): void {
const spy = vi
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(userAgent);
onTestFinished(() => spy.mockRestore());
}
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -375,6 +387,89 @@ describe("Start connection states", () => {
expect(connection.livekitAlias).toBe("!example_room_id");
});
it("retries a join that failed on the WebSocket in Firefox", async () => {
setupTest();
mockUserAgent(FIREFOX_USER_AGENT);
const connection = setupRemoteConnection();
const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => {
capturedStates.push(value);
});
onTestFinished(() => s.unsubscribe());
const firstJoin = Promise.withResolvers<void>();
fakeLivekitRoom.connect.mockReturnValueOnce(firstJoin.promise);
const started = connection.start();
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
firstJoin.reject(ConnectionError.websocket("WS closed unexpectedly"));
await started;
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(2);
expect(capturedStates.at(-1)).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
});
expect(capturedStates.some((st) => st.state === "error")).toBe(false);
});
it("retries a join in Firefox only once", async () => {
setupTest();
mockUserAgent(FIREFOX_USER_AGENT);
const connection = setupRemoteConnection();
fakeLivekitRoom.connect.mockRejectedValue(
ConnectionError.websocket("WS closed unexpectedly"),
);
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(2);
});
it("does not retry a join that failed for another reason in Firefox", async () => {
setupTest();
mockUserAgent(FIREFOX_USER_AGENT);
const connection = setupRemoteConnection();
fakeLivekitRoom.connect.mockRejectedValueOnce(
ConnectionError.serverUnreachable("server was not reachable"),
);
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
});
it("does not retry a join that failed on the WebSocket in other browsers", async () => {
setupTest();
mockUserAgent(CHROME_USER_AGENT);
const connection = setupRemoteConnection();
fakeLivekitRoom.connect.mockRejectedValueOnce(
ConnectionError.websocket("WS closed unexpectedly"),
);
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
});
it("does not retry a join in Firefox once stopped", async () => {
setupTest();
mockUserAgent(FIREFOX_USER_AGENT);
const connection = setupRemoteConnection();
const firstJoin = Promise.withResolvers<void>();
fakeLivekitRoom.connect.mockReturnValueOnce(firstJoin.promise);
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
const started = connection.start();
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
const stopping = connection.stop();
firstJoin.reject(ConnectionError.websocket("WS closed unexpectedly"));
await stopping;
await expect(started).resolves.toBeUndefined();
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
});
it("stopping while connecting does not report an error", async () => {
setupTest();
const connection = setupRemoteConnection();
+19 -1
View File
@@ -14,6 +14,7 @@ import {
ConnectionError,
ConnectionErrorReason,
type ConnectionState as LivekitConnectionState,
getBrowser,
type Participant,
type RemoteParticipant,
type RemoteTrackPublication,
@@ -322,7 +323,7 @@ export class Connection {
try {
this.logger.info(`livekitRoom.connect ${url}`);
await this.livekitRoom.connect(url, jwt);
await this.connectLivekitRoom(url, jwt);
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
} catch (e) {
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
@@ -382,6 +383,23 @@ export class Connection {
}
}
private async connectLivekitRoom(url: string, jwt: string): Promise<void> {
try {
await this.livekitRoom.connect(url, jwt);
} catch (e) {
// Behind a proxy, Firefox fails the first WebSocket to a host after trying
// it over HTTP/2, so a join that fails on the WebSocket gets a second try.
// https://bugzilla.mozilla.org/show_bug.cgi?id=2015411
const failedOnFirefoxWebSocket =
getBrowser()?.name === "Firefox" &&
e instanceof ConnectionError &&
e.reason === ConnectionErrorReason.WebSocket;
if (this.stopped || !failedOnFirefoxWebSocket) throw e;
this.logger.info(`livekitRoom.connect RETRY ${url}`, e);
await this.livekitRoom.connect(url, jwt);
}
}
protected async getSFUConfig(): Promise<SFUConfig> {
return await getSFUConfig({
client: this.client,
@@ -271,7 +271,7 @@ export function createLivekitBackend(
});
return {
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
prepareLocalTransport,
delegateDelayedLeave: delegateLeave,
local: localMedia_,
@@ -13,6 +13,7 @@ import {
} from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { serverNameOfTransport } from "../../utils/serverName";
import { isCsApiUnavailable } from "./auth";
import * as csApi from "./auth/csApi";
import { decodeSFUJWTPayload } from "./auth/types";
@@ -59,7 +60,7 @@ export async function findRemotePublishLivekitTransport({
if (!isUnstableLivekitTransport(transport) || !("url" in transport))
continue;
const { url } = transport;
const serverName = membership.getTransportServerName(transport);
const serverName = serverNameOfTransport(transport, membership.userId);
const key = JSON.stringify([url, serverName]);
if (tried.has(key)) continue;
tried.add(key);
+2 -1
View File
@@ -7,6 +7,7 @@
padding-top: env(safe-area-inset-top);
opacity: 1;
transition: opacity 0.15s;
text-align: center;
}
/* Pseudo-element for the gradient background */
@@ -52,7 +53,7 @@
". . ."
"primaryButton title secondaryButton"
". . .";
place-items: center;
place-items: center stretch;
column-gap: var(--cpd-space-2x);
}
+69 -2
View File
@@ -5,11 +5,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { E2eeType } from "@element-hq/matrixrtc-sdk";
import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement";
import {
getKeyForRoom,
saveKeyForRoom,
useRoomEncryptionSystem,
} from "./sharedKeyManagement";
vi.mock("../ClientContext", () => {
const room = { hasEncryptionStateEvent: (): boolean => false };
const client = { getRoom: (): typeof room => room };
return { useClient: (): { client: typeof client } => ({ client }) };
});
const roomId = "!room:example.org";
const otherRoomId = "!other:example.org";
describe("getKeyForRoom", () => {
afterEach(() => {
@@ -33,4 +46,58 @@ describe("getKeyForRoom", () => {
test("has no key to offer for a room it has never seen", () => {
expect(getKeyForRoom(roomId)).toBeNull();
});
test("prefers the stored key when the parameters are for another room", () => {
saveKeyForRoom(roomId, "stored");
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
expect(getKeyForRoom(roomId)).toBe("stored");
});
test("ignores the password in the parameters for another room", () => {
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
expect(getKeyForRoom(roomId)).toBeNull();
});
});
describe("useRoomEncryptionSystem", () => {
afterEach(() => {
window.location.hash = "#";
localStorage.clear();
});
test("picks up a key saved for the room after it rendered", () => {
const { result } = renderHook(() => useRoomEncryptionSystem(roomId));
expect(result.current).toEqual({ kind: E2eeType.NONE });
act(() => saveKeyForRoom(roomId, "stored"));
expect(result.current).toEqual({
kind: E2eeType.SHARED_KEY,
secret: "stored",
});
});
test("stores a key from the parameters under the room's key only", () => {
window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`;
renderHook(() => useRoomEncryptionSystem(roomId));
expect(Object.keys(localStorage)).toEqual([`room-shared-key-${roomId}`]);
expect(getKeyForRoom(roomId)).toBe("from-the-link");
});
test("keeps the stored key when the parameters are for another room", () => {
saveKeyForRoom(roomId, "stored");
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
const { result } = renderHook(() => useRoomEncryptionSystem(roomId));
expect(result.current).toEqual({
kind: E2eeType.SHARED_KEY,
secret: "stored",
});
expect(localStorage.getItem(`room-shared-key-${roomId}`)).toBe("stored");
});
});
+11 -10
View File
@@ -59,22 +59,27 @@ const useRoomSharedKey = (
/**
* The shared key for a room, preferring one supplied in the parameters Element
* Call was started with over whatever is in local storage.
* Call was started with over whatever is in local storage. If the parameters
* are for another room, only the stored key is used, because the password in
* the parameters belongs to that other room.
*/
function keyForRoom(
roomId: string,
paramsRoomId: string | null,
password: string | null,
): string | null {
if (roomId !== paramsRoomId)
const storedKey = localStorage.getItem(
getRoomSharedKeyLocalStorageKey(roomId),
);
if (roomId !== paramsRoomId) {
logger.warn(
"requested key for a roomId which is not the current call room id (from the URL)",
roomId,
paramsRoomId,
);
return (
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
);
return storedKey;
}
return password ?? storedKey;
}
export function getKeyForRoom(roomId: string): string | null {
@@ -89,11 +94,7 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { roomId: paramsRoomId, password } = useUrlParams();
const [storedPassword] = useRoomSharedKey(
// TODO: this passes an already-prefixed key where a room ID is expected, so
// the local storage key ends up prefixed twice and never matches what
// saveKeyForRoom writes. Preserved as-is here to keep this commit a pure
// refactor; the reactive read is effectively dead until it is fixed.
getRoomSharedKeyLocalStorageKey(roomId),
roomId,
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
);
+24 -11
View File
@@ -235,22 +235,35 @@ test("audio and video follow the tracks' mute state", () => {
test("user media polls stream stats only while the setting is on", () => {
onTestFinished(() => showConnectionStats.setValue(false));
withTestScheduler(({ cold, expectObservable, schedule }) => {
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
const vm = mockRemoteMedia(rtcMembership, {}, [
mockAudioTrack({
stats$: () => cold("-s", { s: stats }),
}),
mockAudioTrack({ stats$: () => cold("-s", { s: { ...stats } }) }),
]);
schedule("-a-b", {
a() {
showConnectionStats.setValue(true);
},
b() {
showConnectionStats.setValue(false);
},
schedule("-t-f-t", {
t: () => showConnectionStats.setValue(true),
f: () => showConnectionStats.setValue(false),
});
expectObservable(vm.audioStreamStats$).toBe("u-su", {
expectObservable(vm.audioStreamStats$).toBe("u-s---s", {
u: undefined,
s: stats,
});
});
});
test("stream stats can be enabled before creating media", () => {
showConnectionStats.setValue(true);
onTestFinished(() => showConnectionStats.setValue(false));
withTestScheduler(({ cold, expectObservable }) => {
const stats = { type: "inbound-rtp" } as RTCInboundRtpStreamStats;
const vm = mockRemoteMedia(rtcMembership, {}, [
mockAudioTrack({ stats$: () => cold("-s", { s: stats }) }),
]);
expectObservable(vm.audioStreamStats$).toBe("us", {
u: undefined,
s: stats,
});
+4 -3
View File
@@ -16,7 +16,9 @@ import {
BehaviorSubject,
combineLatest,
map,
NEVER,
of,
startWith,
Subject,
switchMap,
} from "rxjs";
@@ -105,10 +107,9 @@ export function createBaseUserMedia(
showConnectionStats.value$,
]).pipe(
switchMap(([track, show]) =>
track !== undefined && show
? track.stats$(statsIntervalMs)
: of(undefined),
track !== undefined && show ? track.stats$(statsIntervalMs) : NEVER,
),
startWith<MediaStreamStats>(undefined),
),
);