From d3a4234f4a33e8d0c26b95b9c8aeb7c529c15c7f Mon Sep 17 00:00:00 2001
From: fkwp
Date: Fri, 6 Feb 2026 11:54:22 +0100
Subject: [PATCH 01/44] setup webhooks for SFUs
---
backend/dev_livekit-othersite.yaml | 4 ++++
backend/dev_livekit.yaml | 4 ++++
2 files changed, 8 insertions(+)
diff --git a/backend/dev_livekit-othersite.yaml b/backend/dev_livekit-othersite.yaml
index 0ae98c24..53fc9ce9 100644
--- a/backend/dev_livekit-othersite.yaml
+++ b/backend/dev_livekit-othersite.yaml
@@ -18,3 +18,7 @@ keys:
devkey: secret
room:
auto_create: false
+webhook:
+ api_key: devkey
+ urls:
+ - https://matrix-rtc.othersite.m.localhost/livekit/jwt/sfu_webhook
diff --git a/backend/dev_livekit.yaml b/backend/dev_livekit.yaml
index 157e4d04..6cef4241 100644
--- a/backend/dev_livekit.yaml
+++ b/backend/dev_livekit.yaml
@@ -18,3 +18,7 @@ keys:
devkey: secret
room:
auto_create: false
+webhook:
+ api_key: devkey
+ urls:
+ - https://matrix-rtc.m.localhost/livekit/jwt/sfu_webhook
From 7b1eb03e2a8ebd4ffa81338ee9ad7a6628205b5e Mon Sep 17 00:00:00 2001
From: fkwp
Date: Fri, 6 Feb 2026 11:54:39 +0100
Subject: [PATCH 02/44] add ca certs to SFU containers such that webhooks are
accepted
---
dev-backend-docker-compose.yml | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/dev-backend-docker-compose.yml b/dev-backend-docker-compose.yml
index 28682a33..31694c01 100644
--- a/dev-backend-docker-compose.yml
+++ b/dev-backend-docker-compose.yml
@@ -62,7 +62,10 @@ services:
- 7882:7882/tcp
- 50100-50200:50100-50200/udp
volumes:
+ - ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
- ./backend/dev_livekit.yaml:/etc/livekit.yaml:Z
+ environment:
+ - SSL_CERT_FILE=/local_cert.pem
networks:
- ecbackend
@@ -82,7 +85,10 @@ services:
- 17882:17882/tcp
- 50300-50400:50300-50400/udp
volumes:
+ - ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
- ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
+ environment:
+ - SSL_CERT_FILE=/local_cert.pem
networks:
- ecbackend
@@ -164,6 +170,8 @@ services:
- "8448:8448"
extra_hosts:
- "host.docker.internal:host-gateway"
+ - "auth-server:127.0.0.1"
+ - "auth-server-1:127.0.0.1"
depends_on:
- synapse
networks:
From 9e2eef09d413cd4a6f5f6d34e6f6f9a9cd9ec5c7 Mon Sep 17 00:00:00 2001
From: fkwp
Date: Wed, 22 Apr 2026 15:18:50 +0200
Subject: [PATCH 03/44] Add support for a grace period for /sync (aka
homeserver disconnected) interruptions
---
config/config.sample.json | 1 +
src/config/ConfigOptions.ts | 8 ++++++++
src/state/CallViewModel/CallViewModel.ts | 2 ++
.../localMember/HomeserverConnected.test.ts | 19 ++++++++++---------
.../localMember/HomeserverConnected.ts | 14 ++++++++++++--
5 files changed, 33 insertions(+), 11 deletions(-)
diff --git a/config/config.sample.json b/config/config.sample.json
index 126d7626..5fc63667 100644
--- a/config/config.sample.json
+++ b/config/config.sample.json
@@ -12,6 +12,7 @@
"feature_use_device_session_member_events": true
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
+ "sync_disconnect_grace_period_ms": 60000,
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts
index 1b120546..d5839fb3 100644
--- a/src/config/ConfigOptions.ts
+++ b/src/config/ConfigOptions.ts
@@ -97,6 +97,13 @@ export interface ConfigOptions {
enable_video?: boolean;
};
+ /**
+ * Grace period in milliseconds to wait before reporting the sync loop as disconnected.
+ * This allows brief sync interruptions without triggering a reconnection message.
+ * Default is 60000ms (60 seconds). Set to 0 to disable the grace period.
+ */
+ sync_disconnect_grace_period_ms?: number;
+
/**
* These are low level options that are used to configure the MatrixRTC session.
* Take care when changing these options.
@@ -168,5 +175,6 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
features: {
feature_use_device_session_member_events: true,
},
+ sync_disconnect_grace_period_ms: 60000,
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
};
diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts
index e298bcfd..8677f2d0 100644
--- a/src/state/CallViewModel/CallViewModel.ts
+++ b/src/state/CallViewModel/CallViewModel.ts
@@ -83,6 +83,7 @@ import { E2eeType } from "../../e2ee/e2eeType";
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
import { type MuteStates } from "../MuteStates";
import { getUrlParams, HeaderStyle } from "../../UrlParams";
+import { Config } from "../../config/Config";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../../widget";
import {
@@ -536,6 +537,7 @@ export function createCallViewModel$(
scope,
client,
matrixRTCSession,
+ Config.get().sync_disconnect_grace_period_ms,
),
muteStates,
joinMatrixRTC: (transport: LivekitTransportConfig) => {
diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts
index 87ca35d0..e8861641 100644
--- a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts
+++ b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts
@@ -96,19 +96,20 @@ describe("createHomeserverConnected$", () => {
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
// easy enough to read them so I think they can stay.
+ // Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
it("is false when sync state is not Syncing", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session);
+ const hsConnected = createHomeserverConnected$(scope, client, session, 0);
expect(hsConnected.combined$.value).toBe(false);
});
it("remains false while membership status is not Connected even if sync is Syncing", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session);
+ const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
});
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session);
+ const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Make sync loop OK
client.setSyncState(SyncState.Syncing);
// Indicate probable leave before connection
@@ -118,7 +119,7 @@ describe("createHomeserverConnected$", () => {
});
it("becomes true only when all three conditions are satisfied", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session);
+ const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// 1. Sync loop connected
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
@@ -128,7 +129,7 @@ describe("createHomeserverConnected$", () => {
});
it("drops back to false when sync loop leaves Syncing", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session);
+ const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Reach connected state
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
@@ -140,7 +141,7 @@ describe("createHomeserverConnected$", () => {
});
it("drops back to false when membership status becomes disconnected", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session);
+ const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true);
@@ -150,7 +151,7 @@ describe("createHomeserverConnected$", () => {
});
it("drops to false when ProbablyLeft is emitted after being true", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session);
+ const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true);
@@ -160,7 +161,7 @@ describe("createHomeserverConnected$", () => {
});
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session);
+ const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true);
@@ -174,7 +175,7 @@ describe("createHomeserverConnected$", () => {
});
it("composite sequence reflects each individual failure reason", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session);
+ const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Initially false (sync error + disconnected + not probably left)
expect(hsConnected.combined$.value).toBe(false);
diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.ts b/src/state/CallViewModel/localMember/HomeserverConnected.ts
index c8bcd021..84da6780 100644
--- a/src/state/CallViewModel/localMember/HomeserverConnected.ts
+++ b/src/state/CallViewModel/localMember/HomeserverConnected.ts
@@ -12,9 +12,11 @@ import {
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
-import { fromEvent, startWith, map, tap, type Observable } from "rxjs";
+import { fromEvent, startWith, map, tap, type Observable, debounceTime } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
+import { Config } from "../../../config/Config";
+
import { type ObservableScope } from "../../ObservableScope";
import { type Behavior } from "../../Behavior";
import { and$ } from "../../../utils/observable";
@@ -35,21 +37,29 @@ export interface HomeserverConnected {
* for the purposes of a MatrixRTC session.
*
* Becomes FALSE if ANY sub-condition is fulfilled:
- * 1. Sync loop is not in SyncState.Syncing
+ * 1. Sync loop is not in SyncState.Syncing (after grace period)
* 2. membershipStatus !== Status.Connected
* 3. probablyLeft === true
+ *
+ * @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
+ * If not provided, uses the config value (default 60000ms).
*/
export function createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick,
matrixRTCSession: NodeStyleEventEmitter &
Pick,
+ gracePeriodMs?: number,
): HomeserverConnected {
+ // Get grace period from parameter or config (default 60000ms)
+ const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms ?? 60000;
+
const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe(
startWith([client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
+ debounceTime(graceMs),
);
const rtsSession$ = scope.behavior(
From 9fccd49d7ddbf776b68bb72980ba0bb2b2026801 Mon Sep 17 00:00:00 2001
From: fkwp
Date: Wed, 22 Apr 2026 21:08:31 +0200
Subject: [PATCH 04/44] Improve the handling of the grace period for the home
server connection to support both immediate and delayed emissions.
---
.../localMember/HomeserverConnected.ts | 30 ++++++++++++++++---
1 file changed, 26 insertions(+), 4 deletions(-)
diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.ts b/src/state/CallViewModel/localMember/HomeserverConnected.ts
index 84da6780..cad987c8 100644
--- a/src/state/CallViewModel/localMember/HomeserverConnected.ts
+++ b/src/state/CallViewModel/localMember/HomeserverConnected.ts
@@ -12,11 +12,20 @@ import {
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
-import { fromEvent, startWith, map, tap, type Observable, debounceTime } from "rxjs";
+import {
+ fromEvent,
+ startWith,
+ map,
+ tap,
+ type Observable,
+ distinctUntilChanged,
+ switchMap,
+ of,
+ delay,
+} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { Config } from "../../../config/Config";
-
import { type ObservableScope } from "../../ObservableScope";
import { type Behavior } from "../../Behavior";
import { and$ } from "../../../utils/observable";
@@ -41,6 +50,9 @@ export interface HomeserverConnected {
* 2. membershipStatus !== Status.Connected
* 3. probablyLeft === true
*
+ * @param scope - The observable scope for lifecycle management.
+ * @param client - The Matrix client to monitor sync state.
+ * @param matrixRTCSession - The RTC session to monitor membership.
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
* If not provided, uses the config value (default 60000ms).
*/
@@ -52,14 +64,24 @@ export function createHomeserverConnected$(
gracePeriodMs?: number,
): HomeserverConnected {
// Get grace period from parameter or config (default 60000ms)
- const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms ?? 60000;
+ const graceMs =
+ gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms ?? 60000;
const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe(
startWith([client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
- debounceTime(graceMs),
+ distinctUntilChanged(),
+ switchMap((isSyncing) =>
+{
+ if (isSyncing || graceMs <= 0) {
+ return of(isSyncing); // Sofortige Emission (Synchron)
+ }
+ return of(false).pipe(delay(graceMs)); // Verzögertes false
+ } ),
+ startWith(client.getSyncState() === SyncState.Syncing),
+ distinctUntilChanged(),
);
const rtsSession$ = scope.behavior(
From 032ec662e92b0cbc1f3ab89af5a47334af1119d3 Mon Sep 17 00:00:00 2001
From: fkwp
Date: Wed, 22 Apr 2026 21:08:49 +0200
Subject: [PATCH 05/44] bump lk-jwt-service
---
dev-backend-docker-compose.yml | 350 ++++++++++++++++-----------------
1 file changed, 175 insertions(+), 175 deletions(-)
diff --git a/dev-backend-docker-compose.yml b/dev-backend-docker-compose.yml
index 36fc7f44..0a42dad5 100644
--- a/dev-backend-docker-compose.yml
+++ b/dev-backend-docker-compose.yml
@@ -1,175 +1,175 @@
-networks:
- ecbackend:
-
-services:
- auth-service:
- image: ghcr.io/element-hq/lk-jwt-service:0.4.4
- pull_policy: always
- hostname: auth-server
- environment:
- - LIVEKIT_JWT_PORT=6080
- - LIVEKIT_URL=wss://matrix-rtc.m.localhost/livekit/sfu
- - LIVEKIT_KEY=devkey
- - LIVEKIT_SECRET=secret
- # If the configured homeserver runs on localhost, it'll probably be using
- # a self-signed certificate
- - LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- - LIVEKIT_FULL_ACCESS_HOMESERVERS=*
- deploy:
- restart_policy:
- condition: on-failure
- ports:
- # HOST_PORT:CONTAINER_PORT
- - 6080:6080
- networks:
- - ecbackend
-
- auth-service-1:
- image: ghcr.io/element-hq/lk-jwt-service:0.4.4
- pull_policy: always
- hostname: auth-server-1
- environment:
- - LIVEKIT_JWT_PORT=16080
- - LIVEKIT_URL=wss://matrix-rtc.othersite.m.localhost/livekit/sfu
- - LIVEKIT_KEY=devkey
- - LIVEKIT_SECRET=secret
- # If the configured homeserver runs on localhost, it'll probably be using
- # a self-signed certificate
- - LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- - LIVEKIT_FULL_ACCESS_HOMESERVERS=*
- deploy:
- restart_policy:
- condition: on-failure
- ports:
- # HOST_PORT:CONTAINER_PORT
- - 16080:16080
- networks:
- - ecbackend
-
- livekit:
- image: livekit/livekit-server:v1.10.1
- pull_policy: always
- hostname: livekit-sfu
- command: --dev --config /etc/livekit.yaml
- restart: unless-stopped
- # The SFU seems to work far more reliably when we let it share the host
- # network rather than opening specific ports (but why?? we're not missing
- # any…)
- ports:
- # HOST_PORT:CONTAINER_PORT
- - 7880:7880/tcp
- - 7881:7881/tcp
- - 7882:7882/tcp
- - 50100-50200:50100-50200/udp
- volumes:
- - ./backend/dev_livekit.yaml:/etc/livekit.yaml:Z
- networks:
- - ecbackend
-
- livekit-1:
- image: livekit/livekit-server:v1.10.1
- pull_policy: always
- hostname: livekit-sfu-1
- command: --dev --config /etc/livekit.yaml
- restart: unless-stopped
- # The SFU seems to work far more reliably when we let it share the host
- # network rather than opening specific ports (but why?? we're not missing
- # any…)
- ports:
- # HOST_PORT:CONTAINER_PORT
- - 17880:17880/tcp
- - 17881:17881/tcp
- - 17882:17882/tcp
- - 50300-50400:50300-50400/udp
- volumes:
- - ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
- networks:
- - ecbackend
-
- synapse:
- hostname: homeserver
- image: ghcr.io/element-hq/synapse:latest
- pull_policy: always
- environment:
- - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
- # Needed for rootless podman-compose such that the uid/gid mapping does
- # fit local user uid. If the container runs as root (uid 0) it is fine as
- # it actually maps to your non-root user on the host (e.g. 1000).
- # Otherwise uid mapping will not match your non-root user.
- - UID=0
- - GID=0
- volumes:
- - ./backend/synapse_tmp:/data:Z
- - ./backend/dev_homeserver.yaml:/data/cfg/homeserver.yaml:Z
- networks:
- - ecbackend
-
- synapse-1:
- hostname: homeserver-1
- image: ghcr.io/element-hq/synapse:latest
- pull_policy: always
- environment:
- - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
- # Needed for rootless podman-compose such that the uid/gid mapping does
- # fit local user uid. If the container runs as root (uid 0) it is fine as
- # it actually maps to your non-root user on the host (e.g. 1000).
- # Otherwise uid mapping will not match your non-root user.
- - UID=0
- - GID=0
- volumes:
- - ./backend/synapse_tmp_othersite:/data:Z
- - ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
- networks:
- - ecbackend
-
- element-web:
- image: ghcr.io/element-hq/element-web:develop
- pull_policy: always
- volumes:
- - ./backend/ew.test.config.json:/app/config.json:Z
- environment:
- ELEMENT_WEB_PORT: 8081
- ports:
- - "8081:8081"
- networks:
- - ecbackend
-
- element-web-1:
- image: ghcr.io/element-hq/element-web:develop
- pull_policy: always
- volumes:
- - ./backend/ew.test.othersite.config.json:/app/config.json:Z
- environment:
- ELEMENT_WEB_PORT: 18081
- ports:
- # HOST_PORT:CONTAINER_PORT
- - "18081:18081"
- networks:
- - ecbackend
-
- nginx:
- # see backend/dev_tls_setup for how to generate the tls certs
- hostname: synapse.m.localhost
- image: nginx:latest
- pull_policy: always
- volumes:
- - ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z
- - ./backend/dev_tls_m.localhost.key:/root/ssl/key.pem:Z
- - ./backend/dev_tls_m.localhost.crt:/root/ssl/cert.pem:Z
- ports:
- # HOST_PORT:CONTAINER_PORT
- - "443:443"
- - "8008:80"
- - "4443:443"
- - "8448:8448"
- extra_hosts:
- - "host.docker.internal:host-gateway"
- depends_on:
- - synapse
- networks:
- ecbackend:
- aliases:
- - synapse.m.localhost
- - synapse.othersite.m.localhost
- - matrix-rtc.m.localhost
- - matrix-rtc.othersite.m.localhost
+networks:
+ ecbackend:
+
+services:
+ auth-service:
+ image: ghcr.io/element-hq/lk-jwt-service:pr_171
+ pull_policy: always
+ hostname: auth-server
+ environment:
+ - LIVEKIT_JWT_PORT=6080
+ - LIVEKIT_URL=wss://matrix-rtc.m.localhost/livekit/sfu
+ - LIVEKIT_KEY=devkey
+ - LIVEKIT_SECRET=secret
+ # If the configured homeserver runs on localhost, it'll probably be using
+ # a self-signed certificate
+ - LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
+ - LIVEKIT_FULL_ACCESS_HOMESERVERS=*
+ deploy:
+ restart_policy:
+ condition: on-failure
+ ports:
+ # HOST_PORT:CONTAINER_PORT
+ - 6080:6080
+ networks:
+ - ecbackend
+
+ auth-service-1:
+ image: ghcr.io/element-hq/lk-jwt-service:pr_171
+ pull_policy: always
+ hostname: auth-server-1
+ environment:
+ - LIVEKIT_JWT_PORT=16080
+ - LIVEKIT_URL=wss://matrix-rtc.othersite.m.localhost/livekit/sfu
+ - LIVEKIT_KEY=devkey
+ - LIVEKIT_SECRET=secret
+ # If the configured homeserver runs on localhost, it'll probably be using
+ # a self-signed certificate
+ - LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
+ - LIVEKIT_FULL_ACCESS_HOMESERVERS=*
+ deploy:
+ restart_policy:
+ condition: on-failure
+ ports:
+ # HOST_PORT:CONTAINER_PORT
+ - 16080:16080
+ networks:
+ - ecbackend
+
+ livekit:
+ image: livekit/livekit-server:v1.10.1
+ pull_policy: always
+ hostname: livekit-sfu
+ command: --dev --config /etc/livekit.yaml
+ restart: unless-stopped
+ # The SFU seems to work far more reliably when we let it share the host
+ # network rather than opening specific ports (but why?? we're not missing
+ # any…)
+ ports:
+ # HOST_PORT:CONTAINER_PORT
+ - 7880:7880/tcp
+ - 7881:7881/tcp
+ - 7882:7882/tcp
+ - 50100-50200:50100-50200/udp
+ volumes:
+ - ./backend/dev_livekit.yaml:/etc/livekit.yaml:Z
+ networks:
+ - ecbackend
+
+ livekit-1:
+ image: livekit/livekit-server:v1.10.1
+ pull_policy: always
+ hostname: livekit-sfu-1
+ command: --dev --config /etc/livekit.yaml
+ restart: unless-stopped
+ # The SFU seems to work far more reliably when we let it share the host
+ # network rather than opening specific ports (but why?? we're not missing
+ # any…)
+ ports:
+ # HOST_PORT:CONTAINER_PORT
+ - 17880:17880/tcp
+ - 17881:17881/tcp
+ - 17882:17882/tcp
+ - 50300-50400:50300-50400/udp
+ volumes:
+ - ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
+ networks:
+ - ecbackend
+
+ synapse:
+ hostname: homeserver
+ image: ghcr.io/element-hq/synapse:latest
+ pull_policy: always
+ environment:
+ - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
+ # Needed for rootless podman-compose such that the uid/gid mapping does
+ # fit local user uid. If the container runs as root (uid 0) it is fine as
+ # it actually maps to your non-root user on the host (e.g. 1000).
+ # Otherwise uid mapping will not match your non-root user.
+ - UID=0
+ - GID=0
+ volumes:
+ - ./backend/synapse_tmp:/data:Z
+ - ./backend/dev_homeserver.yaml:/data/cfg/homeserver.yaml:Z
+ networks:
+ - ecbackend
+
+ synapse-1:
+ hostname: homeserver-1
+ image: ghcr.io/element-hq/synapse:latest
+ pull_policy: always
+ environment:
+ - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
+ # Needed for rootless podman-compose such that the uid/gid mapping does
+ # fit local user uid. If the container runs as root (uid 0) it is fine as
+ # it actually maps to your non-root user on the host (e.g. 1000).
+ # Otherwise uid mapping will not match your non-root user.
+ - UID=0
+ - GID=0
+ volumes:
+ - ./backend/synapse_tmp_othersite:/data:Z
+ - ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
+ networks:
+ - ecbackend
+
+ element-web:
+ image: ghcr.io/element-hq/element-web:develop
+ pull_policy: always
+ volumes:
+ - ./backend/ew.test.config.json:/app/config.json:Z
+ environment:
+ ELEMENT_WEB_PORT: 8081
+ ports:
+ - "8081:8081"
+ networks:
+ - ecbackend
+
+ element-web-1:
+ image: ghcr.io/element-hq/element-web:develop
+ pull_policy: always
+ volumes:
+ - ./backend/ew.test.othersite.config.json:/app/config.json:Z
+ environment:
+ ELEMENT_WEB_PORT: 18081
+ ports:
+ # HOST_PORT:CONTAINER_PORT
+ - "18081:18081"
+ networks:
+ - ecbackend
+
+ nginx:
+ # see backend/dev_tls_setup for how to generate the tls certs
+ hostname: synapse.m.localhost
+ image: nginx:latest
+ pull_policy: always
+ volumes:
+ - ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z
+ - ./backend/dev_tls_m.localhost.key:/root/ssl/key.pem:Z
+ - ./backend/dev_tls_m.localhost.crt:/root/ssl/cert.pem:Z
+ ports:
+ # HOST_PORT:CONTAINER_PORT
+ - "443:443"
+ - "8008:80"
+ - "4443:443"
+ - "8448:8448"
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ depends_on:
+ - synapse
+ networks:
+ ecbackend:
+ aliases:
+ - synapse.m.localhost
+ - synapse.othersite.m.localhost
+ - matrix-rtc.m.localhost
+ - matrix-rtc.othersite.m.localhost
From 283c606b9d35ad6c04c6c22442e922cb556f6140 Mon Sep 17 00:00:00 2001
From: fkwp
Date: Wed, 22 Apr 2026 21:46:25 +0200
Subject: [PATCH 06/44] Add tests for grace period handling in
createHomeserverConnected$
---
.../localMember/HomeserverConnected.test.ts | 87 ++++++++++++++++++-
1 file changed, 86 insertions(+), 1 deletion(-)
diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts
index e8861641..5b759cd1 100644
--- a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts
+++ b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { EventEmitter } from "events";
import { ClientEvent, SyncState } from "matrix-js-sdk";
import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
@@ -201,3 +201,88 @@ describe("createHomeserverConnected$", () => {
expect(hsConnected.combined$.value).toBe(false);
});
});
+
+describe("createHomeserverConnected$ - Grace Period", () => {
+ let scope: ObservableScope;
+ let client: MockMatrixClient;
+ let session: MockMatrixRTCSession;
+ const GRACE_PERIOD = 5000;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ scope = new ObservableScope();
+ // Initialize with values that satisfy the "Connected" condition
+ client = new MockMatrixClient(SyncState.Syncing);
+ session = new MockMatrixRTCSession({
+ membershipStatus: Status.Connected,
+ probablyLeft: false,
+ });
+ });
+
+ afterEach(() => {
+ scope.end();
+ vi.useRealTimers();
+ });
+
+ it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
+ const hsConnected = createHomeserverConnected$(scope, client, session, GRACE_PERIOD);
+
+ session.setMembershipStatus(Status.Connected);
+ session.setProbablyLeft(false);
+
+ // Initial state: Everything is connected
+ expect(hsConnected.combined$.value).toBe(true);
+
+ // 1. Sync loses connection -> should remain TRUE due to grace period
+ client.setSyncState(SyncState.Error);
+ expect(hsConnected.combined$.value).toBe(true);
+
+ // 2. Fast forward time (just before expiration)
+ vi.advanceTimersByTime(GRACE_PERIOD - 1);
+ expect(hsConnected.combined$.value).toBe(true);
+
+ // 3. Fast forward time (expiration)
+ vi.advanceTimersByTime(1);
+ expect(hsConnected.combined$.value).toBe(false);
+ });
+
+ it("recovers immediately if sync returns during grace period", () => {
+ const hsConnected = createHomeserverConnected$(scope, client, session, GRACE_PERIOD);
+
+ session.setMembershipStatus(Status.Connected);
+ session.setProbablyLeft(false);
+
+ // Initial state: Connected
+ expect(hsConnected.combined$.value).toBe(true);
+
+ // 1. Sync error occurs
+ client.setSyncState(SyncState.Error);
+ vi.advanceTimersByTime(GRACE_PERIOD / 2);
+ expect(hsConnected.combined$.value).toBe(true);
+
+ // 2. Sync recovers BEFORE the grace period expires
+ client.setSyncState(SyncState.Syncing);
+ expect(hsConnected.combined$.value).toBe(true);
+
+ // 3. Fast forward the remaining time -> should stay TRUE
+ vi.advanceTimersByTime(GRACE_PERIOD);
+ expect(hsConnected.combined$.value).toBe(true);
+ });
+
+ it("flips to true IMMEDIATELY even if a grace period was pending", () => {
+ const hsConnected = createHomeserverConnected$(scope, client, session, GRACE_PERIOD);
+
+ session.setMembershipStatus(Status.Connected);
+ session.setProbablyLeft(false);
+
+ // 1. Initial error: wait until it flips to false
+ client.setSyncState(SyncState.Error);
+ expect(hsConnected.combined$.value).toBe(true);
+ vi.advanceTimersByTime(GRACE_PERIOD + 1);
+ expect(hsConnected.combined$.value).toBe(false);
+
+ // 2. Back to Syncing -> Must be TRUE immediately (synchronously)
+ client.setSyncState(SyncState.Syncing);
+ expect(hsConnected.combined$.value).toBe(true);
+ });
+});
\ No newline at end of file
From c1f821ca0f1fe002deea4f7efc23e0e09da739fb Mon Sep 17 00:00:00 2001
From: fkwp
Date: Thu, 23 Apr 2026 15:33:00 +0200
Subject: [PATCH 07/44] Update default sync disconnect grace period to 10000ms
in configuration and related functions
---
src/config/ConfigOptions.ts | 4 ++--
src/state/CallViewModel/CallViewModel.ts | 2 --
src/state/CallViewModel/localMember/HomeserverConnected.ts | 6 +++---
3 files changed, 5 insertions(+), 7 deletions(-)
diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts
index d5839fb3..1781dc97 100644
--- a/src/config/ConfigOptions.ts
+++ b/src/config/ConfigOptions.ts
@@ -100,7 +100,7 @@ export interface ConfigOptions {
/**
* Grace period in milliseconds to wait before reporting the sync loop as disconnected.
* This allows brief sync interruptions without triggering a reconnection message.
- * Default is 60000ms (60 seconds). Set to 0 to disable the grace period.
+ * Default is 10000ms (10 seconds). Set to 0 to disable the grace period.
*/
sync_disconnect_grace_period_ms?: number;
@@ -175,6 +175,6 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
features: {
feature_use_device_session_member_events: true,
},
- sync_disconnect_grace_period_ms: 60000,
+ sync_disconnect_grace_period_ms: 10000,
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
};
diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts
index 8677f2d0..e298bcfd 100644
--- a/src/state/CallViewModel/CallViewModel.ts
+++ b/src/state/CallViewModel/CallViewModel.ts
@@ -83,7 +83,6 @@ import { E2eeType } from "../../e2ee/e2eeType";
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
import { type MuteStates } from "../MuteStates";
import { getUrlParams, HeaderStyle } from "../../UrlParams";
-import { Config } from "../../config/Config";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../../widget";
import {
@@ -537,7 +536,6 @@ export function createCallViewModel$(
scope,
client,
matrixRTCSession,
- Config.get().sync_disconnect_grace_period_ms,
),
muteStates,
joinMatrixRTC: (transport: LivekitTransportConfig) => {
diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.ts b/src/state/CallViewModel/localMember/HomeserverConnected.ts
index cad987c8..d66d8657 100644
--- a/src/state/CallViewModel/localMember/HomeserverConnected.ts
+++ b/src/state/CallViewModel/localMember/HomeserverConnected.ts
@@ -54,7 +54,7 @@ export interface HomeserverConnected {
* @param client - The Matrix client to monitor sync state.
* @param matrixRTCSession - The RTC session to monitor membership.
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
- * If not provided, uses the config value (default 60000ms).
+ * If not provided, uses the config value (default 10000ms).
*/
export function createHomeserverConnected$(
scope: ObservableScope,
@@ -63,9 +63,9 @@ export function createHomeserverConnected$(
Pick,
gracePeriodMs?: number,
): HomeserverConnected {
- // Get grace period from parameter or config (default 60000ms)
+ // Get grace period from parameter or config (default 10000ms)
const graceMs =
- gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms ?? 60000;
+ gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms ?? 10000;
const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
From 0abc32b80ee5055fc4106f2baf9d48025116ede1 Mon Sep 17 00:00:00 2001
From: fkwp
Date: Thu, 23 Apr 2026 15:47:40 +0200
Subject: [PATCH 08/44] prettier
---
.../localMember/HomeserverConnected.test.ts | 23 +++++++++++++++----
.../localMember/HomeserverConnected.ts | 13 +++++------
2 files changed, 25 insertions(+), 11 deletions(-)
diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts
index 5b759cd1..96feeeb1 100644
--- a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts
+++ b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts
@@ -225,7 +225,12 @@ describe("createHomeserverConnected$ - Grace Period", () => {
});
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session, GRACE_PERIOD);
+ const hsConnected = createHomeserverConnected$(
+ scope,
+ client,
+ session,
+ GRACE_PERIOD,
+ );
session.setMembershipStatus(Status.Connected);
session.setProbablyLeft(false);
@@ -247,7 +252,12 @@ describe("createHomeserverConnected$ - Grace Period", () => {
});
it("recovers immediately if sync returns during grace period", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session, GRACE_PERIOD);
+ const hsConnected = createHomeserverConnected$(
+ scope,
+ client,
+ session,
+ GRACE_PERIOD,
+ );
session.setMembershipStatus(Status.Connected);
session.setProbablyLeft(false);
@@ -270,7 +280,12 @@ describe("createHomeserverConnected$ - Grace Period", () => {
});
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
- const hsConnected = createHomeserverConnected$(scope, client, session, GRACE_PERIOD);
+ const hsConnected = createHomeserverConnected$(
+ scope,
+ client,
+ session,
+ GRACE_PERIOD,
+ );
session.setMembershipStatus(Status.Connected);
session.setProbablyLeft(false);
@@ -285,4 +300,4 @@ describe("createHomeserverConnected$ - Grace Period", () => {
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(true);
});
-});
\ No newline at end of file
+});
diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.ts b/src/state/CallViewModel/localMember/HomeserverConnected.ts
index d66d8657..ff25a250 100644
--- a/src/state/CallViewModel/localMember/HomeserverConnected.ts
+++ b/src/state/CallViewModel/localMember/HomeserverConnected.ts
@@ -73,13 +73,12 @@ export function createHomeserverConnected$(
startWith([client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
distinctUntilChanged(),
- switchMap((isSyncing) =>
-{
- if (isSyncing || graceMs <= 0) {
- return of(isSyncing); // Sofortige Emission (Synchron)
- }
- return of(false).pipe(delay(graceMs)); // Verzögertes false
- } ),
+ switchMap((isSyncing) => {
+ if (isSyncing || graceMs <= 0) {
+ return of(isSyncing);
+ }
+ return of(false).pipe(delay(graceMs));
+ }),
startWith(client.getSyncState() === SyncState.Syncing),
distinctUntilChanged(),
);
From 44b6db6ff1156d4cb1a93c228fb87416380bb519 Mon Sep 17 00:00:00 2001
From: Half-Shot
Date: Fri, 24 Apr 2026 11:47:24 +0100
Subject: [PATCH 09/44] Replace LayoutToggle with compound-web Switch component
---
package.json | 2 +-
pnpm-lock.yaml | 11 +++--
src/components/CallFooter.tsx | 19 ++++++--
src/room/LayoutToggle.module.css | 79 -------------------------------
src/room/LayoutToggle.stories.tsx | 25 ----------
src/room/LayoutToggle.tsx | 59 -----------------------
6 files changed, 22 insertions(+), 173 deletions(-)
delete mode 100644 src/room/LayoutToggle.module.css
delete mode 100644 src/room/LayoutToggle.stories.tsx
delete mode 100644 src/room/LayoutToggle.tsx
diff --git a/package.json b/package.json
index c0f4d505..5fa4fed2 100644
--- a/package.json
+++ b/package.json
@@ -81,7 +81,7 @@
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0",
- "@vector-im/compound-web": "^9.0.0",
+ "@vector-im/compound-web": "element-hq/compound-web#e7c91ef18e20f2fc70069696f4414018361ac512",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c800b0c2..07bc5fbc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -150,8 +150,8 @@ importers:
specifier: ^10.0.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
'@vector-im/compound-web':
- specifier: ^9.0.0
- version: 9.2.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ specifier: element-hq/compound-web#e7c91ef18e20f2fc70069696f4414018361ac512
+ version: https://codeload.github.com/element-hq/compound-web/tar.gz/e7c91ef18e20f2fc70069696f4414018361ac512(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@vitejs/plugin-react':
specifier: ^4.0.1
version: 4.7.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
@@ -3037,8 +3037,9 @@ packages:
react:
optional: true
- '@vector-im/compound-web@9.2.0':
- resolution: {integrity: sha512-jHbABGEQ2yqNtm5xRIkklQs198VEfSk9AJQolI+e4WSJ0xg8Ozyv9t9KIuKQAmjdSV9aow5G6hDE861XB6DQgw==}
+ '@vector-im/compound-web@https://codeload.github.com/element-hq/compound-web/tar.gz/e7c91ef18e20f2fc70069696f4414018361ac512':
+ resolution: {tarball: https://codeload.github.com/element-hq/compound-web/tar.gz/e7c91ef18e20f2fc70069696f4414018361ac512}
+ version: 9.2.1
peerDependencies:
'@fontsource/inconsolata': ^5
'@fontsource/inter': ^5
@@ -9473,7 +9474,7 @@ snapshots:
'@types/react': 19.2.14
react: 19.2.5
- '@vector-im/compound-web@9.2.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ '@vector-im/compound-web@https://codeload.github.com/element-hq/compound-web/tar.gz/e7c91ef18e20f2fc70069696f4414018361ac512(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@fontsource/inconsolata': 5.2.8
diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx
index 4e728d3b..21ff52c6 100644
--- a/src/components/CallFooter.tsx
+++ b/src/components/CallFooter.tsx
@@ -8,6 +8,12 @@ Please see LICENSE in the repository root for full details.
import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames";
import { BehaviorSubject } from "rxjs";
+import { Switch } from "@vector-im/compound-web";
+import { t } from "i18next";
+import {
+ SpotlightIcon,
+ GridIcon,
+} from "@vector-im/compound-design-tokens/assets/web/icons";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -23,7 +29,6 @@ import {
type ReactionData,
} from "../button";
import styles from "./CallFooter.module.css";
-import { LayoutToggle } from "../room/LayoutToggle";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
export interface AudioOutputSwitcher {
@@ -232,10 +237,16 @@ export const CallFooter: FC = ({
{!hideControls && {buttons}
}
{setLayoutMode && layoutMode && showLayoutSwitcher && (
-
)}
diff --git a/src/room/LayoutToggle.module.css b/src/room/LayoutToggle.module.css
deleted file mode 100644
index d9ae5813..00000000
--- a/src/room/LayoutToggle.module.css
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
-Copyright 2023, 2024 New Vector Ltd.
-
-SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
-Please see LICENSE in the repository root for full details.
-*/
-
-.toggle {
- padding: 2px;
- border: 1px solid var(--cpd-color-border-interactive-secondary);
- border-radius: var(--cpd-radius-pill-effect);
- background: var(--cpd-color-bg-canvas-default);
- display: flex;
- position: relative;
-}
-
-.toggle input {
- appearance: none;
- /* Safari puts a margin on these, which is not removed via appearance: none */
- margin: 0;
- block-size: var(--cpd-space-11x);
- inline-size: var(--cpd-space-11x);
- cursor: pointer;
- border-radius: var(--cpd-radius-pill-effect);
- background: var(--cpd-color-bg-action-secondary-rest);
- box-shadow: var(--small-drop-shadow);
- transition: background-color 0.1s;
-}
-
-.toggle svg {
- display: block;
- position: absolute;
- padding: calc(2.5 * var(--cpd-space-1x));
- pointer-events: none;
- color: var(--cpd-color-icon-primary);
- transition: color 0.1s;
-}
-
-.toggle svg:nth-child(2) {
- inset-inline-start: 2px;
-}
-
-.toggle svg:nth-child(4) {
- inset-inline-end: 2px;
-}
-
-@media (hover: hover) {
- .toggle input:hover {
- background: var(--cpd-color-bg-action-secondary-hovered);
- box-shadow: none;
- }
-}
-
-.toggle input:active {
- background: var(--cpd-color-bg-action-secondary-pressed);
- box-shadow: none;
-}
-
-.toggle input:checked {
- background: var(--cpd-color-bg-action-primary-rest);
-}
-
-.toggle input:checked + svg {
- color: var(--cpd-color-icon-on-solid-primary);
-}
-
-@media (hover: hover) {
- .toggle input:checked:hover {
- background: var(--cpd-color-bg-action-primary-hovered);
- }
-}
-
-.toggle input:checked:active {
- background: var(--cpd-color-bg-action-primary-pressed);
-}
-
-.toggle input:first-child {
- margin-inline-end: 5px;
-}
diff --git a/src/room/LayoutToggle.stories.tsx b/src/room/LayoutToggle.stories.tsx
deleted file mode 100644
index 72a2ffad..00000000
--- a/src/room/LayoutToggle.stories.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
-Copyright 2026 Element Creations Ltd.
-
-SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
-Please see LICENSE in the repository root for full details.
-*/
-
-import { fn } from "storybook/test";
-
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { LayoutToggle } from "./LayoutToggle";
-
-const meta = {
- component: LayoutToggle,
-} satisfies Meta;
-
-export default meta;
-type Story = StoryObj;
-
-export const Default: Story = {
- args: {
- layout: "grid",
- setLayout: fn(),
- },
-};
diff --git a/src/room/LayoutToggle.tsx b/src/room/LayoutToggle.tsx
deleted file mode 100644
index 98ed91d3..00000000
--- a/src/room/LayoutToggle.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
-Copyright 2023, 2024 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 { type ChangeEvent, type FC, useCallback } from "react";
-import { useTranslation } from "react-i18next";
-import { Tooltip } from "@vector-im/compound-web";
-import {
- SpotlightIcon,
- GridIcon,
-} from "@vector-im/compound-design-tokens/assets/web/icons";
-import classNames from "classnames";
-
-import styles from "./LayoutToggle.module.css";
-
-export type Layout = "spotlight" | "grid";
-
-type Props = {
- layout: Layout;
- setLayout: (layout: Layout) => void;
- className?: string;
-};
-
-export const LayoutToggle: FC = ({ layout, setLayout, className }) => {
- const { t } = useTranslation();
-
- const onChange = useCallback(
- (e: ChangeEvent) => setLayout(e.target.value as Layout),
- [setLayout],
- );
-
- return (
-
- );
-};
From d8be06974767e36598f71003121b80b5c7673a00 Mon Sep 17 00:00:00 2001
From: Half-Shot
Date: Fri, 24 Apr 2026 11:54:24 +0100
Subject: [PATCH 10/44] Fix type
---
locales/en/app.json | 1 +
package.json | 2 +-
src/components/CallFooter.tsx | 3 ++-
3 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/locales/en/app.json b/locales/en/app.json
index 5398930f..2545bb4b 100644
--- a/locales/en/app.json
+++ b/locales/en/app.json
@@ -143,6 +143,7 @@
"text": "This call already exists, would you like to join?",
"title": "Join existing call?"
},
+ "layout_switch_label": "Layout",
"layout_grid_label": "Grid",
"layout_spotlight_label": "Spotlight",
"lobby": {
diff --git a/package.json b/package.json
index 5fa4fed2..f27cf8f4 100644
--- a/package.json
+++ b/package.json
@@ -81,7 +81,7 @@
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0",
- "@vector-im/compound-web": "element-hq/compound-web#e7c91ef18e20f2fc70069696f4414018361ac512",
+ "@vector-im/compound-web": "element-hq/compound-web#fc2e677326aaefec61ef74fb1d9de3c01eecfa7e",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx
index 21ff52c6..d10e4ecf 100644
--- a/src/components/CallFooter.tsx
+++ b/src/components/CallFooter.tsx
@@ -237,7 +237,8 @@ export const CallFooter: FC = ({
{!hideControls && {buttons}
}
{setLayoutMode && layoutMode && showLayoutSwitcher && (
-
+ aria-label={t("layout_switch_label")}
leftLabel={t("layout_spotlight_label")}
leftValue="spotlight"
leftIcon={SpotlightIcon}
From 9b71070ef8e312bd17d1b0c71eda775c36cf4e30 Mon Sep 17 00:00:00 2001
From: Half-Shot
Date: Fri, 24 Apr 2026 11:57:13 +0100
Subject: [PATCH 11/44] publish lock changes
---
pnpm-lock.yaml | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 07bc5fbc..06269963 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -150,8 +150,8 @@ importers:
specifier: ^10.0.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
'@vector-im/compound-web':
- specifier: element-hq/compound-web#e7c91ef18e20f2fc70069696f4414018361ac512
- version: https://codeload.github.com/element-hq/compound-web/tar.gz/e7c91ef18e20f2fc70069696f4414018361ac512(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ specifier: element-hq/compound-web#fc2e677326aaefec61ef74fb1d9de3c01eecfa7e
+ version: https://codeload.github.com/element-hq/compound-web/tar.gz/fc2e677326aaefec61ef74fb1d9de3c01eecfa7e(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@vitejs/plugin-react':
specifier: ^4.0.1
version: 4.7.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
@@ -3037,8 +3037,8 @@ packages:
react:
optional: true
- '@vector-im/compound-web@https://codeload.github.com/element-hq/compound-web/tar.gz/e7c91ef18e20f2fc70069696f4414018361ac512':
- resolution: {tarball: https://codeload.github.com/element-hq/compound-web/tar.gz/e7c91ef18e20f2fc70069696f4414018361ac512}
+ '@vector-im/compound-web@https://codeload.github.com/element-hq/compound-web/tar.gz/fc2e677326aaefec61ef74fb1d9de3c01eecfa7e':
+ resolution: {tarball: https://codeload.github.com/element-hq/compound-web/tar.gz/fc2e677326aaefec61ef74fb1d9de3c01eecfa7e}
version: 9.2.1
peerDependencies:
'@fontsource/inconsolata': ^5
@@ -9474,7 +9474,7 @@ snapshots:
'@types/react': 19.2.14
react: 19.2.5
- '@vector-im/compound-web@https://codeload.github.com/element-hq/compound-web/tar.gz/e7c91ef18e20f2fc70069696f4414018361ac512(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ '@vector-im/compound-web@https://codeload.github.com/element-hq/compound-web/tar.gz/fc2e677326aaefec61ef74fb1d9de3c01eecfa7e(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@fontsource/inconsolata': 5.2.8
From 62751787ca0c4f95572d3feadd880297d3933e5e Mon Sep 17 00:00:00 2001
From: Half-Shot
Date: Fri, 24 Apr 2026 13:02:47 +0100
Subject: [PATCH 12/44] Use actual package
---
package.json | 2 +-
pnpm-lock.yaml | 11 +++++------
2 files changed, 6 insertions(+), 7 deletions(-)
diff --git a/package.json b/package.json
index f27cf8f4..a8dc49fa 100644
--- a/package.json
+++ b/package.json
@@ -81,7 +81,7 @@
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0",
- "@vector-im/compound-web": "element-hq/compound-web#fc2e677326aaefec61ef74fb1d9de3c01eecfa7e",
+ "@vector-im/compound-web": "^9.3.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 06269963..41e3f943 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -150,8 +150,8 @@ importers:
specifier: ^10.0.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
'@vector-im/compound-web':
- specifier: element-hq/compound-web#fc2e677326aaefec61ef74fb1d9de3c01eecfa7e
- version: https://codeload.github.com/element-hq/compound-web/tar.gz/fc2e677326aaefec61ef74fb1d9de3c01eecfa7e(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ specifier: ^9.3.0
+ version: 9.3.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@vitejs/plugin-react':
specifier: ^4.0.1
version: 4.7.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))
@@ -3037,9 +3037,8 @@ packages:
react:
optional: true
- '@vector-im/compound-web@https://codeload.github.com/element-hq/compound-web/tar.gz/fc2e677326aaefec61ef74fb1d9de3c01eecfa7e':
- resolution: {tarball: https://codeload.github.com/element-hq/compound-web/tar.gz/fc2e677326aaefec61ef74fb1d9de3c01eecfa7e}
- version: 9.2.1
+ '@vector-im/compound-web@9.3.0':
+ resolution: {integrity: sha512-Elu4Uw8RbfP6JaudQYkVibALYT6qpwubqfKhteTxIPWBWzSYM+P5T+B1uX+ra+grNcXwXUt2xfMxpqYQsAHgYA==}
peerDependencies:
'@fontsource/inconsolata': ^5
'@fontsource/inter': ^5
@@ -9474,7 +9473,7 @@ snapshots:
'@types/react': 19.2.14
react: 19.2.5
- '@vector-im/compound-web@https://codeload.github.com/element-hq/compound-web/tar.gz/fc2e677326aaefec61ef74fb1d9de3c01eecfa7e(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ '@vector-im/compound-web@9.3.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@fontsource/inconsolata': 5.2.8
From 5aa45714bfa194a3224e0181a2e8970a0b693ff1 Mon Sep 17 00:00:00 2001
From: Half-Shot
Date: Mon, 27 Apr 2026 09:34:46 +0100
Subject: [PATCH 13/44] Snap updates
---
locales/en/app.json | 2 +-
.../ReactionToggleButton.test.tsx.snap | 10 +++----
.../GroupCallErrorBoundary.test.tsx.snap | 18 ++++++------
.../__snapshots__/InCallView.test.tsx.snap | 28 +++++++++----------
4 files changed, 29 insertions(+), 29 deletions(-)
diff --git a/locales/en/app.json b/locales/en/app.json
index 2545bb4b..b51c6ed9 100644
--- a/locales/en/app.json
+++ b/locales/en/app.json
@@ -143,9 +143,9 @@
"text": "This call already exists, would you like to join?",
"title": "Join existing call?"
},
- "layout_switch_label": "Layout",
"layout_grid_label": "Grid",
"layout_spotlight_label": "Spotlight",
+ "layout_switch_label": "Layout",
"lobby": {
"ask_to_join": "Request to join call",
"join_as_guest": "Join as guest",
diff --git a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap
index 608c1a0f..a1e319d9 100644
--- a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap
+++ b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap
@@ -10,7 +10,7 @@ exports[`Can close reaction dialog 1`] = `
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_bb_"
- class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
+ class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -44,7 +44,7 @@ exports[`Can fully expand emoji picker 1`] = `
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_7m_"
- class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
+ class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -75,7 +75,7 @@ exports[`Can lower hand 1`] = `
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_36_"
- class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
+ class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
role="button"
@@ -109,7 +109,7 @@ exports[`Can open menu 1`] = `
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_0_"
- class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
+ class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -140,7 +140,7 @@ exports[`Can raise hand 1`] = `
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_1j_"
- class="_button_13vu4_8 raisedButton _has-icon_13vu4_60 _icon-only_13vu4_53"
+ class="_button_1nw83_8 raisedButton _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
diff --git a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
index 0d2d39bc..92a6fe54 100644
--- a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
+++ b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap
@@ -134,7 +134,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
You were disconnected from the call.