From b49411abfa8a5ab4bd909e8231d86b10ca0c246c Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 14 Jan 2026 17:35:35 +0100 Subject: [PATCH 01/13] dont set localTransport while still fetching oldest member transport --- src/room/InCallView.tsx | 24 +++++++++---------- .../localMember/LocalTransport.ts | 12 +++++++--- src/widget.ts | 1 + 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 5ceb30f5..3f437693 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -435,18 +435,18 @@ export const InCallView: FC = ({ [vm], ); - useEffect(() => { - widget?.api.transport - .send( - gridMode === "grid" - ? ElementWidgetActions.TileLayout - : ElementWidgetActions.SpotlightLayout, - {}, - ) - .catch((e) => { - logger.error("Failed to send layout change to widget API", e); - }); - }, [gridMode]); + // useEffect(() => { + // widget?.api.transport + // .send( + // gridMode === "grid" + // ? ElementWidgetActions.TileLayout + // : ElementWidgetActions.SpotlightLayout, + // {}, + // ) + // .catch((e) => { + // logger.error("Failed to send layout change to widget API", e); + // }); + // }, [gridMode]); useEffect(() => { if (widget) { diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 0625866d..e3e42111 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -126,7 +126,9 @@ export const createLocalTransport$ = ({ * The transport over which we should be actively publishing our media. * undefined when not joined. */ - const oldestMemberTransport$ = scope.behavior( + const oldestMemberTransport$ = scope.behavior< + LocalTransportWithSFUConfig | null | "fetching" + >( combineLatest([memberships$]).pipe( map(([memberships]) => { const oldestMember = memberships.value[0]; @@ -154,7 +156,7 @@ export const createLocalTransport$ = ({ return from(computeLocalTransportWithSFUConfig()); }), ), - null, + "fetching", ); /** @@ -202,7 +204,11 @@ export const createLocalTransport$ = ({ ]).pipe( map(([useOldestMember, oldestMemberTransport, preferredTransport]) => useOldestMember - ? (oldestMemberTransport ?? preferredTransport) + ? oldestMemberTransport === null + ? preferredTransport + : oldestMemberTransport === "fetching" + ? null + : oldestMemberTransport : preferredTransport, ), distinctUntilChanged((t1, t2) => diff --git a/src/widget.ts b/src/widget.ts index 7862df33..e2584d0d 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -110,6 +110,7 @@ export const widget = ((): WidgetHelpers | null => { EventType.RoomRedaction, ElementCallReactionEventType, EventType.RTCDecline, + EventType.RTCMembership, // Send/Read the membership sticky events ]; const sendState = [ From c74accb906ef0f894636648f952cfa740e79400a Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 15 Jan 2026 18:11:08 +0100 Subject: [PATCH 02/13] remove "fetching" state again (we have to be able to deal with those kind of switches!) Optimize to not always fetch the oldest member jwt (only do so in legacy mode) --- .../localMember/LocalTransport.ts | 97 ++++++++++--------- 1 file changed, 53 insertions(+), 44 deletions(-) diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index e3e42111..83b9d7c8 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -19,6 +19,7 @@ import { first, from, map, + of, switchMap, } from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; @@ -126,38 +127,41 @@ export const createLocalTransport$ = ({ * The transport over which we should be actively publishing our media. * undefined when not joined. */ - const oldestMemberTransport$ = scope.behavior< - LocalTransportWithSFUConfig | null | "fetching" - >( - combineLatest([memberships$]).pipe( - map(([memberships]) => { - const oldestMember = memberships.value[0]; - const transport = oldestMember?.getTransport(memberships.value[0]); - if (!transport) return null; - return transport; - }), - first((t) => t != null && isLivekitTransport(t)), - switchMap((transport) => { - // Get the open jwt token to connect to the sfu - const computeLocalTransportWithSFUConfig = - async (): Promise => { - return { - transport, - sfuConfig: await getSFUConfigWithOpenID( - client, - ownMembershipIdentity, - transport.livekit_service_url, - roomId, - { forceJwtEndpoint: JwtEndpointVersion.Legacy }, - logger, - ), - }; - }; - return from(computeLocalTransportWithSFUConfig()); - }), - ), - "fetching", - ); + const oldestMemberTransport$ = + scope.behavior( + combineLatest([memberships$, useOldestMember$]).pipe( + map(([memberships, useOldestMember]) => { + if (!useOldestMember) return null; // No need to do any prefetching if not using oldest member + const oldestMember = memberships.value[0]; + const transport = oldestMember?.getTransport(oldestMember); + if (!transport) return null; + return transport; + }), + switchMap((transport) => { + if (transport !== null && isLivekitTransport(transport)) { + // Get the open jwt token to connect to the sfu + const computeLocalTransportWithSFUConfig = + async (): Promise => { + // await sleep(1000); + return { + transport, + sfuConfig: await getSFUConfigWithOpenID( + client, + ownMembershipIdentity, + transport.livekit_service_url, + roomId, + { forceJwtEndpoint: JwtEndpointVersion.Legacy }, + logger, + ), + }; + }; + return from(computeLocalTransportWithSFUConfig()); + } + return of(null); + }), + ), + null, + ); /** * The transport that we would personally prefer to publish on (if not for the @@ -202,18 +206,23 @@ export const createLocalTransport$ = ({ oldestMemberTransport$, preferredTransport$, ]).pipe( - map(([useOldestMember, oldestMemberTransport, preferredTransport]) => - useOldestMember - ? oldestMemberTransport === null - ? preferredTransport - : oldestMemberTransport === "fetching" - ? null - : oldestMemberTransport - : preferredTransport, - ), - distinctUntilChanged((t1, t2) => - areLivekitTransportsEqual(t1?.transport ?? null, t2?.transport ?? null), - ), + map(([useOldestMember, oldestMemberTransport, preferredTransport]) => { + return useOldestMember + ? (oldestMemberTransport ?? preferredTransport) + : preferredTransport; + }), + distinctUntilChanged((t1, t2) => { + logger.info( + "Local Transport Update from:", + t1?.transport.livekit_service_url, + " to ", + t2?.transport.livekit_service_url, + ); + return areLivekitTransportsEqual( + t1?.transport ?? null, + t2?.transport ?? null, + ); + }), ), ); }; From f7590a33d79570b00f8d5b73121f10e8fdea45fc Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 15 Jan 2026 18:11:43 +0100 Subject: [PATCH 03/13] Fix publisher clean up! This is the acutal bug we chased --- src/state/CallViewModel/CallViewModel.ts | 5 ++++- .../CallViewModel/localMember/LocalMember.ts | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index bf3e9521..ab45b07c 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -527,7 +527,10 @@ export function createCallViewModel$( connectOptions$.value, ); }, - createPublisherFactory: (connection: Connection) => { + createPublisherFactory: ( + scope: ObservableScope, + connection: Connection, + ) => { return new Publisher( scope, connection, diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 4749e942..6298979f 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -40,7 +40,7 @@ import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/En import { type Behavior } from "../../Behavior.ts"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts"; -import { type ObservableScope } from "../../ObservableScope.ts"; +import { ObservableScope } from "../../ObservableScope.ts"; import { type Publisher } from "./Publisher.ts"; import { type MuteStates } from "../../MuteStates.ts"; import { @@ -124,7 +124,10 @@ interface Props { scope: ObservableScope; muteStates: MuteStates; connectionManager: IConnectionManager; - createPublisherFactory: (connection: Connection) => Publisher; + createPublisherFactory: ( + scope: ObservableScope, + connection: Connection, + ) => Publisher; joinMatrixRTC: (transport: LivekitTransport) => void; homeserverConnected: HomeserverConnected; localTransport$: Behavior; @@ -310,13 +313,18 @@ export const createLocalMembership$ = ({ // - destruct all current streams // - overwrite current publisher scope.reconcile(localConnection$, async (connection) => { + logger.info( + "reconcile based on new localConnection:", + connection?.transport.livekit_service_url, + ); if (connection !== null) { - const publisher = createPublisherFactory(connection); + const scope = new ObservableScope(); + const publisher = createPublisherFactory(scope, connection); publisher$.next(publisher); // Clean-up callback + return Promise.resolve(async (): Promise => { - await publisher.stopPublishing(); - await publisher.stopTracks(); + scope.end(); }); } }); From f71e4830806008f732d1876bbfb0a4075d41e123 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 15 Jan 2026 18:13:34 +0100 Subject: [PATCH 04/13] logging updates --- src/livekit/MatrixAudioRenderer.tsx | 1 + src/settings/DeveloperSettingsTab.tsx | 1 + .../CallViewModel/localMember/Publisher.ts | 14 +++++++++-- .../CallViewModel/remoteMembers/Connection.ts | 24 ++++++++++++++++--- .../remoteMembers/ConnectionManager.ts | 4 ---- 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/livekit/MatrixAudioRenderer.tsx b/src/livekit/MatrixAudioRenderer.tsx index 5a4b2257..10579c1b 100644 --- a/src/livekit/MatrixAudioRenderer.tsx +++ b/src/livekit/MatrixAudioRenderer.tsx @@ -78,6 +78,7 @@ export function LivekitRoomAudioRenderer({ .filter((ref) => { const isValid = validIdentities.includes(ref.participant.identity); if (!isValid) { + // TODO make sure to also skip the warn logging for the local identity // Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call. prefixedLogger.warn( `Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`, diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index a94dca26..91a2e241 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -316,6 +316,7 @@ export const DeveloperSettingsTab: FC = ({ })}

LivekitAlias: {livekitRoom.livekitAlias}

+

connectionState (wont hot reload): {livekitRoom.room.state}

{livekitRoom.isLocal &&

ws-url: {localSfuUrl?.href}

}

{t("developer_mode.livekit_server_info")}( diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 27c53726..06f40bde 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -79,10 +79,20 @@ export class Publisher { this.workaroundRestartAudioInputTrackChrome(devices, scope); this.scope.onEnd(() => { - this.logger.info("Scope ended -> stop publishing all tracks"); - void this.stopPublishing(); muteStates.audio.unsetHandler(); muteStates.video.unsetHandler(); + this.logger.info( + "Scope ended -> unset handler + stop publishing all tracks", + ); + + const stopAllMedia = async () => { + logger.info("onEnd: start stopping all media"); + await this.stopPublishing(); + logger.info("onEnd: stopped publishing"); + await this.stopTracks(); + logger.info("onEnd: stopped tracks"); + }; + void stopAllMedia(); }); this.connection.livekitRoom.localParticipant.on( diff --git a/src/state/CallViewModel/remoteMembers/Connection.ts b/src/state/CallViewModel/remoteMembers/Connection.ts index 028b28f6..4db96d9b 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.ts @@ -155,6 +155,16 @@ export class Connection { const { url, jwt, livekitAlias } = this.existingSFUConfig ?? (await this.getSFUConfigForRemoteConnection()); + this.logger.debug( + "Starting Connection to: ", + this.transport.livekit_service_url, + "jwt: ", + jwt, + "wss: ", + url, + "livekitAlias: ", + livekitAlias, + ); this._livekitAlias = livekitAlias; // If we were stopped while fetching the config, don't proceed to connect if (this.stopped) return; @@ -171,8 +181,11 @@ export class Connection { }); try { + this.logger.info(`livekitRoom.connect ${url}`); await this.livekitRoom.connect(url, jwt); + this.logger.info(`livekitRoom.connect SUCCESS ${url}`); } catch (e) { + this.logger.info(`livekitRoom.connect FAILED ${url}`, e); // LiveKit uses 503 to indicate that the server has hit its track limits. // https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171 // It also errors with a status code of 200 (yes, really) for room @@ -233,12 +246,15 @@ export class Connection { */ public async stop(): Promise { this.logger.debug( - `Stopping connection to ${this.transport.livekit_service_url}`, + `stop: disconnecing from lk room ${this.transport.livekit_service_url}`, ); if (this.stopped) return; await this.livekitRoom.disconnect(); this._state$.next(ConnectionState.Stopped); this.stopped = true; + this.logger.debug( + `stop: DONE disconnecing from lk room ${this.transport.livekit_service_url}`, + ); } private readonly client: OpenIDClientParts; @@ -255,9 +271,11 @@ export class Connection { public constructor(opts: ConnectionOpts, logger: Logger) { this.ownMembershipIdentity = opts.ownMembershipIdentity; this.existingSFUConfig = opts.existingSFUConfig; - this.logger = logger.getChild("[Connection]"); + this.logger = logger.getChild( + "[Connection " + opts.transport.livekit_service_url + "]", + ); this.logger.info( - `Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`, + `constructor: ${opts.transport.livekit_service_url} alias: ${opts.transport.livekit_alias} withSfuConfig?: ${sfuConfig})`, ); const { transport, client, scope } = opts; diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts index 4295c5f2..8bc008ea 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts @@ -189,10 +189,6 @@ export function createConnectionManager$({ } }, (scope, _data$, serviceUrl, alias, sfuConfig) => { - logger.debug( - `Creating connection to ${serviceUrl} (${alias}, withSfuConfig (local connection?): ${JSON.stringify(sfuConfig) ?? "no config->remote connection"})`, - ); - const connection = connectionFactory.createConnection( scope, { From 9b40d7420a4526020b64e0128d516eb93782a677 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 15 Jan 2026 19:04:32 +0100 Subject: [PATCH 05/13] refactor to use destroy method --- src/state/CallViewModel/CallViewModel.ts | 6 +-- .../CallViewModel/localMember/LocalMember.ts | 14 +++--- .../localMember/LocalTransport.ts | 1 - .../CallViewModel/localMember/Publisher.ts | 44 +++++++++---------- .../CallViewModel/remoteMembers/Connection.ts | 2 +- 5 files changed, 28 insertions(+), 39 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index ab45b07c..554060bf 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -527,12 +527,8 @@ export function createCallViewModel$( connectOptions$.value, ); }, - createPublisherFactory: ( - scope: ObservableScope, - connection: Connection, - ) => { + createPublisherFactory: (connection: Connection) => { return new Publisher( - scope, connection, mediaDevices, muteStates, diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 6298979f..44b6c63b 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -40,7 +40,7 @@ import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/En import { type Behavior } from "../../Behavior.ts"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts"; -import { ObservableScope } from "../../ObservableScope.ts"; +import { type ObservableScope } from "../../ObservableScope.ts"; import { type Publisher } from "./Publisher.ts"; import { type MuteStates } from "../../MuteStates.ts"; import { @@ -124,10 +124,7 @@ interface Props { scope: ObservableScope; muteStates: MuteStates; connectionManager: IConnectionManager; - createPublisherFactory: ( - scope: ObservableScope, - connection: Connection, - ) => Publisher; + createPublisherFactory: (connection: Connection) => Publisher; joinMatrixRTC: (transport: LivekitTransport) => void; homeserverConnected: HomeserverConnected; localTransport$: Behavior; @@ -318,13 +315,12 @@ export const createLocalMembership$ = ({ connection?.transport.livekit_service_url, ); if (connection !== null) { - const scope = new ObservableScope(); - const publisher = createPublisherFactory(scope, connection); + const publisher = createPublisherFactory(connection); publisher$.next(publisher); - // Clean-up callback + // Clean-up callback return Promise.resolve(async (): Promise => { - scope.end(); + await publisher.destroy(); }); } }); diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 83b9d7c8..7e1c4155 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -16,7 +16,6 @@ import { MatrixError, type MatrixClient } from "matrix-js-sdk"; import { combineLatest, distinctUntilChanged, - first, from, map, of, diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 06f40bde..c9c1c08c 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -32,7 +32,7 @@ import { import { getUrlParams } from "../../../UrlParams.ts"; import { observeTrackReference$ } from "../../MediaViewModel.ts"; import { type Connection } from "../remoteMembers/Connection.ts"; -import { type ObservableScope } from "../../ObservableScope.ts"; +import { ObservableScope } from "../../ObservableScope.ts"; /** * A wrapper for a Connection object. @@ -47,9 +47,10 @@ export class Publisher { */ public shouldPublish = false; + private readonly scope = new ObservableScope(); + /** * Creates a new Publisher. - * @param scope - The observable scope to use for managing the publisher. * @param connection - The connection to use for publishing. * @param devices - The media devices to use for audio and video input. * @param muteStates - The mute states for audio and video. @@ -57,7 +58,6 @@ export class Publisher { * @param logger - The logger to use for logging :D. */ public constructor( - private scope: ObservableScope, private connection: Pick, //setE2EEEnabled, devices: MediaDevices, private readonly muteStates: MuteStates, @@ -65,7 +65,6 @@ export class Publisher { private logger: Logger, ) { const { controlledAudioDevices } = getUrlParams(); - const room = connection.livekitRoom; room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => { @@ -73,27 +72,11 @@ export class Publisher { }); // Setup track processor syncing (blur) - this.observeTrackProcessors(scope, room, trackerProcessorState$); + this.observeTrackProcessors(this.scope, room, trackerProcessorState$); // Observe media device changes and update LiveKit active devices accordingly - this.observeMediaDevices(scope, devices, controlledAudioDevices); + this.observeMediaDevices(this.scope, devices, controlledAudioDevices); - this.workaroundRestartAudioInputTrackChrome(devices, scope); - this.scope.onEnd(() => { - muteStates.audio.unsetHandler(); - muteStates.video.unsetHandler(); - this.logger.info( - "Scope ended -> unset handler + stop publishing all tracks", - ); - - const stopAllMedia = async () => { - logger.info("onEnd: start stopping all media"); - await this.stopPublishing(); - logger.info("onEnd: stopped publishing"); - await this.stopTracks(); - logger.info("onEnd: stopped tracks"); - }; - void stopAllMedia(); - }); + this.workaroundRestartAudioInputTrackChrome(devices, this.scope); this.connection.livekitRoom.localParticipant.on( ParticipantEvent.LocalTrackPublished, @@ -101,6 +84,21 @@ export class Publisher { ); } + public async destroy(): Promise { + this.scope.end(); + this.logger.info("Scope ended -> unset handler"); + this.muteStates.audio.unsetHandler(); + this.muteStates.video.unsetHandler(); + + this.logger.info(`Start to stop tracks`); + try { + await this.stopTracks(); + this.logger.info(`Done to stop tracks`); + } catch (e) { + this.logger.error(`Failed to stop publishing: ${e}`); + } + } + // LiveKit will publish the tracks as soon as they are created // but we want to control when tracks are published. // We cannot just mute the tracks, even if this will effectively stop the publishing, diff --git a/src/state/CallViewModel/remoteMembers/Connection.ts b/src/state/CallViewModel/remoteMembers/Connection.ts index 4db96d9b..f649e931 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.ts @@ -275,7 +275,7 @@ export class Connection { "[Connection " + opts.transport.livekit_service_url + "]", ); this.logger.info( - `constructor: ${opts.transport.livekit_service_url} alias: ${opts.transport.livekit_alias} withSfuConfig?: ${sfuConfig})`, + `constructor: ${opts.transport.livekit_service_url} alias: ${opts.transport.livekit_alias} withSfuConfig?: ${opts.existingSFUConfig ? JSON.stringify(opts.existingSFUConfig) : "undefined"}`, ); const { transport, client, scope } = opts; From 971d16d243d0fa3905bed3f87a83d53deb8360f2 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 16 Jan 2026 10:21:26 +0100 Subject: [PATCH 06/13] revert jwt to stable version --- dev-backend-docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-backend-docker-compose.yml b/dev-backend-docker-compose.yml index 28682a33..884dd5d9 100644 --- a/dev-backend-docker-compose.yml +++ b/dev-backend-docker-compose.yml @@ -3,7 +3,7 @@ networks: services: auth-service: - image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00 + image: ghcr.io/element-hq/lk-jwt-service:0.4.1 pull_policy: always hostname: auth-server environment: @@ -25,7 +25,7 @@ services: - ecbackend auth-service-1: - image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00 + image: ghcr.io/element-hq/lk-jwt-service:0.4.1 pull_policy: always hostname: auth-server-1 environment: From 5a488209276c784af23a50f0c01c076da75e2f3a Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 16 Jan 2026 10:22:23 +0100 Subject: [PATCH 07/13] remove widget layout widget action for good. --- src/room/InCallView.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 3f437693..e096dd54 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -435,19 +435,6 @@ export const InCallView: FC = ({ [vm], ); - // useEffect(() => { - // widget?.api.transport - // .send( - // gridMode === "grid" - // ? ElementWidgetActions.TileLayout - // : ElementWidgetActions.SpotlightLayout, - // {}, - // ) - // .catch((e) => { - // logger.error("Failed to send layout change to widget API", e); - // }); - // }, [gridMode]); - useEffect(() => { if (widget) { const onTileLayout = (ev: CustomEvent): void => { From f5c31626a66552a8236b35deeef6d12b999cd17b Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 16 Jan 2026 12:43:13 +0100 Subject: [PATCH 08/13] fix unit tests --- .../DeveloperSettingsTab.test.tsx.snap | 6 ++++ .../localMember/LocalMember.test.ts | 31 +++++++------------ .../localMember/Publisher.test.ts | 11 ++++--- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap index 57afe4d9..cfa25ca5 100644 --- a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap +++ b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap @@ -359,6 +359,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` LivekitAlias: TestAlias

+

+ connectionState (wont hot reload): +

ws-url: wss://local-sfu.example.org/ @@ -401,6 +404,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` LivekitAlias: TestAlias2

+

+ connectionState (wont hot reload): +

LiveKit Server Info ( diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index af12c98b..76c0f4a8 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -301,6 +301,7 @@ describe("LocalMembership", () => { logger.info(`stopPublishing [${a}]`); }), stopTracks: vi.fn(), + destroy: vi.fn(), }; publishers.push(p as unknown as Publisher); return p; @@ -325,13 +326,12 @@ describe("LocalMembership", () => { await flushPromises(); localTransport$.next(bTransport); await flushPromises(); + expect(publisherFactory).toHaveBeenCalledTimes(2); expect(publishers.length).toBe(2); // stop the first Publisher and let the second one life. - expect(publishers[0].stopTracks).toHaveBeenCalled(); - expect(publishers[1].stopTracks).not.toHaveBeenCalled(); - expect(publishers[0].stopPublishing).toHaveBeenCalled(); - expect(publishers[1].stopPublishing).not.toHaveBeenCalled(); + expect(publishers[0].destroy).toHaveBeenCalled(); + expect(publishers[1].destroy).not.toHaveBeenCalled(); expect(publisherFactory.mock.calls[0][0].transport).toBe( aTransport.transport, ); @@ -341,7 +341,7 @@ describe("LocalMembership", () => { scope.end(); await flushPromises(); // stop all tracks after ending scopes - expect(publishers[1].stopPublishing).toHaveBeenCalled(); + expect(publishers[1].destroy).toHaveBeenCalled(); // expect(publishers[1].stopTracks).toHaveBeenCalled(); defaultCreateLocalMemberValues.createPublisherFactory.mockReset(); @@ -359,8 +359,7 @@ describe("LocalMembership", () => { defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation( () => { const p = { - stopPublishing: vi.fn(), - stopTracks: vi.fn(), + destroy: vi.fn(), createAndSetupTracks: vi.fn().mockImplementation(async () => { tracks$.next([{}, {}] as LocalTrack[]); return Promise.resolve(); @@ -395,11 +394,11 @@ describe("LocalMembership", () => { localMembership.startTracks(); await flushPromises(); expect(publishers[0].createAndSetupTracks).toHaveBeenCalled(); - // expect(localMembership.tracks$.value.length).toBe(2); + scope.end(); await flushPromises(); // stop all tracks after ending scopes - expect(publishers[0].stopPublishing).toHaveBeenCalled(); + expect(publishers[0].destroy).toHaveBeenCalled(); // expect(publishers[0].stopTracks).toHaveBeenCalled(); publisherFactory.mockClear(); }); @@ -416,27 +415,21 @@ describe("LocalMembership", () => { ); const publishers: Publisher[] = []; - const tracks$ = new BehaviorSubject([]); const publishing$ = new BehaviorSubject(false); const createTrackResolver = Promise.withResolvers(); const publishResolver = Promise.withResolvers(); defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation( () => { const p = { - stopPublishing: vi.fn(), - stopTracks: vi.fn().mockImplementation(() => { - logger.info("stopTracks"); - tracks$.next([]); - }), + destroy: vi.fn(), createAndSetupTracks: vi.fn().mockImplementation(async () => { await createTrackResolver.promise; - tracks$.next([{}, {}] as LocalTrack[]); }), startPublishing: vi.fn().mockImplementation(async () => { await publishResolver.promise; publishing$.next(true); }), - tracks$, + publishing$, }; publishers.push(p as unknown as Publisher); @@ -536,7 +529,7 @@ describe("LocalMembership", () => { (localMembership.localMemberState$.value as any).media, ).toStrictEqual(PublishState.Publishing); - expect(publishers[0].stopPublishing).not.toHaveBeenCalled(); + expect(publishers[0].destroy).not.toHaveBeenCalled(); expect(localMembership.localMemberState$.isStopped).toBe(false); scope.end(); @@ -547,7 +540,7 @@ describe("LocalMembership", () => { (localMembership.localMemberState$.value as any).media, ).toStrictEqual(PublishState.Publishing); // stop all tracks after ending scopes - expect(publishers[0].stopPublishing).toHaveBeenCalled(); + expect(publishers[0].destroy).toHaveBeenCalled(); // expect(publishers[0].stopTracks).toHaveBeenCalled(); }); // TODO add tests for matrix local matrix participation. diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 38a80bed..0bb97797 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -183,7 +183,6 @@ describe("Publisher", () => { beforeEach(() => { publisher = new Publisher( - scope, connection, mockMediaDevices({}), muteStates, @@ -192,7 +191,9 @@ describe("Publisher", () => { ); }); - afterEach(() => {}); + afterEach(async () => { + await publisher.destroy(); + }); it("Should not create tracks if started muted to avoid unneeded permission requests", async () => { const createTracksSpy = vi.spyOn( @@ -267,7 +268,6 @@ describe("Publisher", () => { let publisher: Publisher; beforeEach(() => { publisher = new Publisher( - scope, connection, mockMediaDevices({}), muteStates, @@ -275,6 +275,9 @@ describe("Publisher", () => { logger, ); }); + afterEach(async () => { + await publisher.destroy(); + }); test.each([ { mutes: { audioEnabled: true, videoEnabled: false } }, @@ -320,7 +323,6 @@ describe("Bug fix", () => { it("wrongly publish tracks while muted", async () => { // setLogLevel(`debug`); const publisher = new Publisher( - scope, connection, mockMediaDevices({}), muteStates, @@ -356,5 +358,6 @@ describe("Bug fix", () => { expect(track!.mute).toHaveBeenCalled(); expect(track!.isMuted).toBe(true); } + await publisher.destroy(); }); }); From 83df1526087d81ae8fa1f9ef0d06360142af58d0 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 16 Jan 2026 13:20:03 +0100 Subject: [PATCH 09/13] review --- src/state/CallViewModel/localMember/Publisher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index c9c1c08c..8df38743 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -95,7 +95,7 @@ export class Publisher { await this.stopTracks(); this.logger.info(`Done to stop tracks`); } catch (e) { - this.logger.error(`Failed to stop publishing: ${e}`); + this.logger.error(`Failed to stop tracks: ${e}`); } } From a99a413b8866ca893e8ecbb44315c798d61c967d Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 16 Jan 2026 14:07:56 +0100 Subject: [PATCH 10/13] temp --- src/state/CallViewModel/localMember/LocalMember.test.ts | 1 - src/state/CallViewModel/localMember/Publisher.test.ts | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 76c0f4a8..8990b46b 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -429,7 +429,6 @@ describe("LocalMembership", () => { await publishResolver.promise; publishing$.next(true); }), - publishing$, }; publishers.push(p as unknown as Publisher); diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 0bb97797..dd8117a3 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -208,6 +208,13 @@ describe("Publisher", () => { expect(createTracksSpy).not.toHaveBeenCalled(); }); + it("should unsetHandler and stop tracks on destroy", async () => { + await publisher.destroy(); + expect(publisher.stopTracks).toHaveBeenCalled(); + expect( this.muteStates.audio.unsetHandler(); + this.muteStates.video.unsetHandler();).toHaveBeenCalled(); + }); + it("Should minimize permission request by querying create at once", async () => { const enableCameraAndMicrophoneSpy = vi.spyOn( localParticipant, From 3f8b3ba3f1c0aa56465bbc9d0fe577d073fa1d88 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 16 Jan 2026 14:41:11 +0100 Subject: [PATCH 11/13] add test to check if publisher is properly destroyed --- .../localMember/LocalMember.test.ts | 8 ++++- .../localMember/Publisher.test.ts | 31 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 8990b46b..9d2ded79 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -297,11 +297,13 @@ describe("LocalMembership", () => { seed += 1; logger.info(`creating [${a}]`); const p = { + // It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does + // all the cleanup we need. + destroy: vi.fn(), stopPublishing: vi.fn().mockImplementation(() => { logger.info(`stopPublishing [${a}]`); }), stopTracks: vi.fn(), - destroy: vi.fn(), }; publishers.push(p as unknown as Publisher); return p; @@ -359,6 +361,8 @@ describe("LocalMembership", () => { defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation( () => { const p = { + // It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does + // all the cleanup we need. destroy: vi.fn(), createAndSetupTracks: vi.fn().mockImplementation(async () => { tracks$.next([{}, {}] as LocalTrack[]); @@ -421,6 +425,8 @@ describe("LocalMembership", () => { defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation( () => { const p = { + // It is enought to check if destroy is called. Destroy itself is tested in the publisher to make sure it does + // all the cleanup we need. destroy: vi.fn(), createAndSetupTracks: vi.fn().mockImplementation(async () => { await createTrackResolver.promise; diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index dd8117a3..a0eaa2fd 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -209,10 +209,35 @@ describe("Publisher", () => { }); it("should unsetHandler and stop tracks on destroy", async () => { + // setup all spies + const unsetVideoSpy = vi.spyOn( + ( + publisher as unknown as { + muteStates: { video: { unsetHandler: () => void } }; + } + ).muteStates.video, + "unsetHandler", + ); + const unsetAudioSpy = vi.spyOn( + ( + publisher as unknown as { + muteStates: { audio: { unsetHandler: () => void } }; + } + ).muteStates.audio, + "unsetHandler", + ); + const scopeEndSpy = vi.spyOn( + (publisher as unknown as { scope: { end: () => void } }).scope, + "end", + ); + const stopTracksSpy = vi.spyOn(publisher, "stopTracks"); + // destroy publisher await publisher.destroy(); - expect(publisher.stopTracks).toHaveBeenCalled(); - expect( this.muteStates.audio.unsetHandler(); - this.muteStates.video.unsetHandler();).toHaveBeenCalled(); + + expect(stopTracksSpy).toHaveBeenCalledOnce(); + expect(unsetVideoSpy).toHaveBeenCalledOnce(); + expect(unsetAudioSpy).toHaveBeenCalledOnce(); + expect(scopeEndSpy).toHaveBeenCalled(); }); it("Should minimize permission request by querying create at once", async () => { From 102664482b25dd664a051f78e7d0aaab0d7ce79c Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 16 Jan 2026 15:41:25 +0100 Subject: [PATCH 12/13] Activate the hotswap test that was failing before. --- playwright/widget/hotswap-legacy-compat.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/playwright/widget/hotswap-legacy-compat.test.ts b/playwright/widget/hotswap-legacy-compat.test.ts index a2edb27d..e4695624 100644 --- a/playwright/widget/hotswap-legacy-compat.test.ts +++ b/playwright/widget/hotswap-legacy-compat.test.ts @@ -27,9 +27,6 @@ import { HOST1, HOST2, TestHelpers } from "./test-helpers"; widgetTest( `Test swapping publisher from ${HOST1} to ${HOST2}`, async ({ addUser, browserName }) => { - // ALWAYS SKIPT THE TEST SINCE IT IS EXPECTED TO FAIL. - // confirmed locally that its failing without: https://github.com/element-hq/element-call/pull/3675 - test.skip(true); test.skip( browserName === "firefox", "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", From f41175444b4588dc1e9e41581147c0a4b0068ed9 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 16 Jan 2026 16:23:00 +0100 Subject: [PATCH 13/13] revert changes unrelated to this PR --- dev-backend-docker-compose.yml | 4 ++-- src/room/InCallView.tsx | 13 +++++++++++++ src/widget.ts | 1 - 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/dev-backend-docker-compose.yml b/dev-backend-docker-compose.yml index 884dd5d9..28682a33 100644 --- a/dev-backend-docker-compose.yml +++ b/dev-backend-docker-compose.yml @@ -3,7 +3,7 @@ networks: services: auth-service: - image: ghcr.io/element-hq/lk-jwt-service:0.4.1 + image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00 pull_policy: always hostname: auth-server environment: @@ -25,7 +25,7 @@ services: - ecbackend auth-service-1: - image: ghcr.io/element-hq/lk-jwt-service:0.4.1 + image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00 pull_policy: always hostname: auth-server-1 environment: diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e096dd54..5ceb30f5 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -435,6 +435,19 @@ export const InCallView: FC = ({ [vm], ); + useEffect(() => { + widget?.api.transport + .send( + gridMode === "grid" + ? ElementWidgetActions.TileLayout + : ElementWidgetActions.SpotlightLayout, + {}, + ) + .catch((e) => { + logger.error("Failed to send layout change to widget API", e); + }); + }, [gridMode]); + useEffect(() => { if (widget) { const onTileLayout = (ev: CustomEvent): void => { diff --git a/src/widget.ts b/src/widget.ts index e2584d0d..7862df33 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -110,7 +110,6 @@ export const widget = ((): WidgetHelpers | null => { EventType.RoomRedaction, ElementCallReactionEventType, EventType.RTCDecline, - EventType.RTCMembership, // Send/Read the membership sticky events ]; const sendState = [