From 444c50d8c4a37eb4908e5dce84e61cada4520b85 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 9 Jan 2026 18:27:56 +0100 Subject: [PATCH 01/13] Change code style of user media --- src/state/CallViewModel/CallViewModel.ts | 82 +++++++------------ .../remoteMembers/MatrixLivekitMembers.ts | 2 +- 2 files changed, 30 insertions(+), 54 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index bf5ea441..28b098d2 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -124,9 +124,9 @@ import { } from "./remoteMembers/ConnectionManager.ts"; import { createMatrixLivekitMembers$, - type TaggedParticipant, type LocalMatrixLivekitMember, type RemoteMatrixLivekitMember, + type MatrixLivekitMember, } from "./remoteMembers/MatrixLivekitMembers.ts"; import { type AutoLeaveReason, @@ -717,62 +717,38 @@ export function createCallViewModel$( matrixLivekitMembers, duplicateTiles, ]) { - let localUserMediaId: string | undefined = undefined; - // add local member if available - if (localMatrixLivekitMember) { - const { userId, participant, connection$, membership$ } = - localMatrixLivekitMember; + const computeMediaId = (m: MatrixLivekitMember): string => + `${m.userId}:${m.membership$.value.deviceId}`; - localUserMediaId = `${userId}:${membership$.value.deviceId}`; + const localUserMediaId = localMatrixLivekitMember + ? computeMediaId(localMatrixLivekitMember) + : undefined; + + const localAsArray = localMatrixLivekitMember + ? [localMatrixLivekitMember] + : []; + const remoteWithoutLocal = matrixLivekitMembers.value.filter( + (m) => computeMediaId(m) !== localUserMediaId, + ); + const allMatrixLivekitMembers = [ + ...localAsArray, + ...remoteWithoutLocal, + ]; + + for (const matrixLivekitMember of allMatrixLivekitMembers) { + const { userId, participant, connection$, membership$ } = + matrixLivekitMember; + const memb = membership$.value; + const mediaId = computeMediaId(matrixLivekitMember); for (let dup = 0; dup < 1 + duplicateTiles; dup++) { yield { - keys: [ - dup, - localUserMediaId, - userId, - participant satisfies TaggedParticipant as TaggedParticipant, // Widen the type safely - connection$, - membership$.value, - ], - data: undefined, - }; - } - } - // add remote members that are available - for (const { - userId, - participant, - connection$, - membership$, - } of matrixLivekitMembers.value) { - const userMediaId = `${userId}:${membership$.value.deviceId}`; - // skip local user as we added them manually before - if (userMediaId === localUserMediaId) continue; - for (let dup = 0; dup < 1 + duplicateTiles; dup++) { - yield { - keys: [ - dup, - userMediaId, - userId, - participant, - connection$, - membership$.value, - ], + keys: [dup, mediaId, userId, participant, connection$, memb], data: undefined, }; } } }, - ( - scope, - _data$, - dup, - userMediaId, - userId, - participant, - connection$, - membership, - ) => { + (scope, _, dup, mediaId, userId, participant, connection$, memb) => { const livekitRoom$ = scope.behavior( connection$.pipe(map((c) => c?.livekitRoom)), ); @@ -787,9 +763,9 @@ export function createCallViewModel$( return new UserMedia( scope, - `${userMediaId}:${dup}`, + `${mediaId}:${dup}`, userId, - membership, + memb, participant, options.encryptionSystem, livekitRoom$, @@ -798,8 +774,8 @@ export function createCallViewModel$( localMembership.reconnecting$, displayName$, matrixMemberMetadataStore.createAvatarUrlBehavior$(userId), - handsRaised$.pipe(map((v) => v[userMediaId]?.time ?? null)), - reactions$.pipe(map((v) => v[userMediaId] ?? undefined)), + handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)), + reactions$.pipe(map((v) => v[mediaId] ?? undefined)), ); }, ), diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index 10a3e2cb..db52ce2e 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -33,7 +33,7 @@ export type TaggedParticipant = | LocalTaggedParticipant | RemoteTaggedParticipant; -interface MatrixLivekitMember { +export interface MatrixLivekitMember { membership$: Behavior; connection$: Behavior; // participantId: string; We do not want a participantId here since it will be generated by the jwt From 7a8f5cc8596afffed6fdcced4dbd540c1b9e0f4e Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 9 Jan 2026 19:40:21 +0100 Subject: [PATCH 02/13] use `crypto.randomUUID()` --- src/state/CallViewModel/CallViewModel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index bf3e9521..aea0f7fa 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -47,7 +47,7 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { type IWidgetApiRequest } from "matrix-widget-api"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; -import { v4 as uuidv4 } from "uuid"; +import { randomUUID } from "crypto"; import { LocalUserMediaViewModel, @@ -432,7 +432,7 @@ export function createCallViewModel$( userId, deviceId, // This will only be consumed by the sticky membership manager. So it has no impact on legacy calls. - memberId: uuidv4(), + memberId: randomUUID(), }; const localTransport$ = createLocalTransport$({ From fbdcde305878775ec1a83ad809c5e49d8365b14b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 01:33:44 +0000 Subject: [PATCH 03/13] Update dependency @livekit/components-react to v2.9.17 --- yarn.lock | 44 +++++++++++++++----------------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/yarn.lock b/yarn.lock index 8ef454d1..167ab618 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2903,16 +2903,6 @@ __metadata: languageName: node linkType: hard -"@floating-ui/dom@npm:1.6.13, @floating-ui/dom@npm:^1.0.0": - version: 1.6.13 - resolution: "@floating-ui/dom@npm:1.6.13" - dependencies: - "@floating-ui/core": "npm:^1.6.0" - "@floating-ui/utils": "npm:^0.2.9" - checksum: 10c0/272242d2eb6238ffcee0cb1f3c66e0eafae804d5d7b449db5ecf904bc37d31ad96cf575a9e650b93c1190f64f49a684b1559d10e05ed3ec210628b19116991a9 - languageName: node - linkType: hard - "@floating-ui/dom@npm:1.7.4": version: 1.7.4 resolution: "@floating-ui/dom@npm:1.7.4" @@ -2923,6 +2913,16 @@ __metadata: languageName: node linkType: hard +"@floating-ui/dom@npm:^1.0.0": + version: 1.6.13 + resolution: "@floating-ui/dom@npm:1.6.13" + dependencies: + "@floating-ui/core": "npm:^1.6.0" + "@floating-ui/utils": "npm:^0.2.9" + checksum: 10c0/272242d2eb6238ffcee0cb1f3c66e0eafae804d5d7b449db5ecf904bc37d31ad96cf575a9e650b93c1190f64f49a684b1559d10e05ed3ec210628b19116991a9 + languageName: node + linkType: hard + "@floating-ui/react-dom@npm:^2.0.0, @floating-ui/react-dom@npm:^2.1.2": version: 2.1.2 resolution: "@floating-ui/react-dom@npm:2.1.2" @@ -3213,21 +3213,7 @@ __metadata: languageName: node linkType: hard -"@livekit/components-core@npm:0.12.11": - version: 0.12.11 - resolution: "@livekit/components-core@npm:0.12.11" - dependencies: - "@floating-ui/dom": "npm:1.6.13" - loglevel: "npm:1.9.1" - rxjs: "npm:7.8.2" - peerDependencies: - livekit-client: ^2.15.14 - tslib: ^2.6.2 - checksum: 10c0/9c2ac3d30bb8cc9067ae0b2049784f81e90e57df9eabf7edbaf3c8ceb65a63f644a4e6abeb6cc38d3ebe52663d8dbb88535e01a965011f365d5ae1f3daf86052 - languageName: node - linkType: hard - -"@livekit/components-core@npm:^0.12.0": +"@livekit/components-core@npm:0.12.12, @livekit/components-core@npm:^0.12.0": version: 0.12.12 resolution: "@livekit/components-core@npm:0.12.12" dependencies: @@ -3242,10 +3228,10 @@ __metadata: linkType: hard "@livekit/components-react@npm:^2.0.0": - version: 2.9.16 - resolution: "@livekit/components-react@npm:2.9.16" + version: 2.9.17 + resolution: "@livekit/components-react@npm:2.9.17" dependencies: - "@livekit/components-core": "npm:0.12.11" + "@livekit/components-core": "npm:0.12.12" clsx: "npm:2.1.1" events: "npm:^3.3.0" jose: "npm:^6.0.12" @@ -3259,7 +3245,7 @@ __metadata: peerDependenciesMeta: "@livekit/krisp-noise-filter": optional: true - checksum: 10c0/4ba4ff473c5a29d3107412733a6676a3b708d70684ed463e9b34cda26abb3d2f317c2828a52e730837b756de9df3fc248260d6f390aedebfb6ec96ef63c7b151 + checksum: 10c0/ba64ada37d4b3ce4d5ee7c5b2a6bddbffc17c2e641e95881aac9f02b4ff7428105e0a372d364e50ff124e988b7426d322d94caabdb55b634aebf0144d7e37f99 languageName: node linkType: hard From 89e5e5b10aaa4c4679e8b76e2eb74e32eb8ac3e6 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 12 Jan 2026 12:34:50 +0100 Subject: [PATCH 04/13] fix uuid --- src/state/CallViewModel/CallViewModel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index aea0f7fa..bf3e9521 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -47,7 +47,7 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { type IWidgetApiRequest } from "matrix-widget-api"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; -import { randomUUID } from "crypto"; +import { v4 as uuidv4 } from "uuid"; import { LocalUserMediaViewModel, @@ -432,7 +432,7 @@ export function createCallViewModel$( userId, deviceId, // This will only be consumed by the sticky membership manager. So it has no impact on legacy calls. - memberId: randomUUID(), + memberId: uuidv4(), }; const localTransport$ = createLocalTransport$({ From e82a0480880b6777474f28f6a05169017b3feed0 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 12 Jan 2026 09:31:35 +0100 Subject: [PATCH 05/13] playwright: Test that declining a call in DM works as expected --- playwright/widget/test-helpers.ts | 25 +++++++++ playwright/widget/voice-call-dm.spec.ts | 68 +++++++++++++++++-------- 2 files changed, 73 insertions(+), 20 deletions(-) create mode 100644 playwright/widget/test-helpers.ts diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts new file mode 100644 index 00000000..5a74cbac --- /dev/null +++ b/playwright/widget/test-helpers.ts @@ -0,0 +1,25 @@ +/* +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 { expect, type Page } from "@playwright/test"; + +export class TestHelpers { + public static async startCallInCurrentRoom( + page: Page, + voice: boolean = false, + ): Promise { + const buttonName = voice ? "Voice call" : "Video call"; + await expect(page.getByRole("button", { name: buttonName })).toBeVisible(); + await page.getByRole("button", { name: buttonName }).click(); + + await expect( + page.getByRole("menuitem", { name: "Element Call" }), + ).toBeVisible(); + + await page.getByRole("menuitem", { name: "Element Call" }).click(); + } +} diff --git a/playwright/widget/voice-call-dm.spec.ts b/playwright/widget/voice-call-dm.spec.ts index a7aed984..6a8473cf 100644 --- a/playwright/widget/voice-call-dm.spec.ts +++ b/playwright/widget/voice-call-dm.spec.ts @@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details. import { expect, test } from "@playwright/test"; import { widgetTest } from "../fixtures/widget-user.ts"; +import { TestHelpers } from "./test-helpers.ts"; widgetTest.use({ callType: "dm" }); @@ -23,16 +24,7 @@ widgetTest( const { brooks, whistler } = asWidget; - await expect( - brooks.page.getByRole("button", { name: "Voice call" }), - ).toBeVisible(); - await brooks.page.getByRole("button", { name: "Voice call" }).click(); - - await expect( - brooks.page.getByRole("menuitem", { name: "Element Call" }), - ).toBeVisible(); - - await brooks.page.getByRole("menuitem", { name: "Element Call" }).click(); + await TestHelpers.startCallInCurrentRoom(brooks.page, true); await expect( brooks.page.locator('iframe[title="Element Call"]'), @@ -123,16 +115,7 @@ widgetTest( const { brooks, whistler } = asWidget; - await expect( - brooks.page.getByRole("button", { name: "Video call" }), - ).toBeVisible(); - await brooks.page.getByRole("button", { name: "Video call" }).click(); - - await expect( - brooks.page.getByRole("menuitem", { name: "Element Call" }), - ).toBeVisible(); - - await brooks.page.getByRole("menuitem", { name: "Element Call" }).click(); + await TestHelpers.startCallInCurrentRoom(brooks.page, false); await expect( brooks.page.locator('iframe[title="Element Call"]'), @@ -210,3 +193,48 @@ widgetTest( await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible(); }, ); + +widgetTest( + "Decline a new video call in DM as widget", + async ({ asWidget, browserName }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); + + test.slow(); // Triples the timeout + + const { brooks, whistler } = asWidget; + + await TestHelpers.startCallInCurrentRoom(brooks.page, false); + + await expect( + brooks.page.locator('iframe[title="Element Call"]'), + ).toBeVisible(); + + const brooksFrame = brooks.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + // We should show a ringing overlay, let's check for that + await expect( + brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`), + ).toBeVisible(); + + await expect(whistler.page.getByText("Incoming video call")).toBeVisible(); + await whistler.page.getByRole("button", { name: "Decline" }).click(); + + await expect( + whistler.page.locator('iframe[title="Element Call"]'), + ).not.toBeVisible(); + + // The widget should be closed and the timeline should be back on screen + await expect( + brooks.page.locator('iframe[title="Element Call"]'), + ).not.toBeVisible(); + + await expect( + brooks.page.getByText("This is the beginning of your"), + ).toBeVisible(); + }, +); From 72ca3821abbd7b983243449624fb571cc7f96a22 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 13 Jan 2026 12:37:46 +0100 Subject: [PATCH 06/13] Change the livekit alias to just be the room id for backwards compatibility. --- .../CallViewModel/localMember/LocalTransport.ts | 13 ++++++++++++- .../remoteMembers/MatrixLivekitMembers.ts | 10 +++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 6e0e56a3..5229b700 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -250,7 +250,18 @@ async function makeTransport( transport: { type: "livekit", livekit_service_url: url, - livekit_alias: sfuConfig.livekitAlias, + // WARNING PLS READ ME!!! + // This looks unintuitive especially considering that `sfuConfig.livekitAlias` exists. + // Why do we not use: `livekit_alias: sfuConfig.livekitAlias` + // + // - This is going to be used for sending our state event transport (focus_preferred) + // - In sticky events it is expected to NOT send this field at all. The transport is only the `type`, `livekit_service_url` + // - If we set it to the hased alias we get from the jwt, we will end up using the hashed alias as the body.roomId field + // in v0.16.0. (It will use oldest member transport. It is using the transport.livekit_alias as the body.roomId) + // + // TLDR this is a temporal fild that allow for comaptibilty but the spec expects it to not exists. (but its existance also does not break anything) + // It is just named poorly: It was intetended to be the actual alias. But now we do pseudonymys ids so we use a hashed alias. + livekit_alias: roomId, }, sfuConfig, }; diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index 10a3e2cb..24e18af2 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -143,9 +143,13 @@ export function areLivekitTransportsEqual( t1: T | null, t2: T | null, ): boolean { - if (t1 && t2) return t1.livekit_service_url === t2.livekit_service_url; - // In case we have different lk rooms in the same SFU (depends on the livekit authorization service) - // It is only needed in case the livekit authorization service is not behaving as expected (or custom implementation) + if (t1 && t2) + return ( + t1.livekit_service_url === t2.livekit_service_url && + // In case we have different lk rooms in the same SFU (depends on the livekit authorization service) + // It is only needed in case the livekit authorization service is not behaving as expected (or custom implementation) + t1.livekit_alias === t2.livekit_alias + ); if (!t1 && !t2) return true; return false; } From d185b52091e7d0e020daddbab94b0086dcdfbb16 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 13 Jan 2026 12:44:17 +0100 Subject: [PATCH 07/13] more docstrings to docuemnt the migration away from livekit_alias --- .../localMember/LocalTransport.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 5229b700..7f87c392 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -68,6 +68,27 @@ export enum JwtEndpointVersion { Matrix_2_0 = "matrix_2_0", } +// TODO livekit_alias-cleanup +// 1. We need to move away from transports map to connections!!! +// +// 2. We need to stop sending livekit_alias all together +// +// +// 1. +// Transports are just the jwt service adress but do not contain the information which room on this transport to use. +// That requires slot and roomId. +// +// We need one connection per room on the transport. +// +// We need an object that contains: +// transport +// roomId +// slotId +// +// To map to the connections. Prosposal: `ConnectionIdentifier` +// +// 2. +// We need to make sure we do not sent livekit_alias in sticky events and that we drop all code for sending state events! export interface LocalTransportWithSFUConfig { transport: LivekitTransport; sfuConfig: SFUConfig; From ff8d71380d7418fd64f69a8ae42d24bfe54bb3d0 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 13 Jan 2026 15:14:19 +0100 Subject: [PATCH 08/13] Add livekitAlias debugging info --- src/room/InCallView.tsx | 1 + src/settings/DeveloperSettingsTab.tsx | 8 +++++++- src/state/CallViewModel/remoteMembers/Connection.ts | 11 ++++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 5b80db3d..5ceb30f5 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -842,6 +842,7 @@ export const InCallView: FC = ({ .getConnections() .map((connectionItem) => ({ room: connectionItem.livekitRoom, + livekitAlias: connectionItem.livekitAlias, // TODO compute is local or tag it in the livekit room items already isLocal: undefined, url: connectionItem.transport.livekit_service_url, diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index a187e4b5..a94dca26 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -48,7 +48,12 @@ import { useUrlParams } from "../UrlParams"; interface Props { client: MatrixClient; - livekitRooms?: { room: LivekitRoom; url: string; isLocal?: boolean }[]; + livekitRooms?: { + room: LivekitRoom; + url: string; + isLocal?: boolean; + livekitAlias?: string; + }[]; env: ImportMetaEnv; } @@ -310,6 +315,7 @@ export const DeveloperSettingsTab: FC = ({ url: livekitRoom.url || "unknown", })} +

LivekitAlias: {livekitRoom.livekitAlias}

{livekitRoom.isLocal &&

ws-url: {localSfuUrl?.href}

}

{t("developer_mode.livekit_server_info")}( diff --git a/src/state/CallViewModel/remoteMembers/Connection.ts b/src/state/CallViewModel/remoteMembers/Connection.ts index f286b0cd..028b28f6 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.ts @@ -117,6 +117,14 @@ export class Connection { */ public readonly remoteParticipants$: Behavior; + /** + * The alias of the LiveKit room. + */ + public get livekitAlias(): string | undefined { + return this._livekitAlias; + } + private _livekitAlias?: string; + /** * Whether the connection has been stopped. * @see Connection.stop @@ -144,9 +152,10 @@ export class Connection { this._state$.next(ConnectionState.FetchingConfig); // We should already have this information after creating the localTransport. // only call getSFUConfigWithOpenID for connections where we do not have a token yet. (existingJwtTokenData === undefined) - const { url, jwt } = + const { url, jwt, livekitAlias } = this.existingSFUConfig ?? (await this.getSFUConfigForRemoteConnection()); + this._livekitAlias = livekitAlias; // If we were stopped while fetching the config, don't proceed to connect if (this.stopped) return; From 20e63c8dc796bdc6b39ff6deeffc492efe202bc1 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 13 Jan 2026 17:39:55 +0100 Subject: [PATCH 09/13] fix tests --- src/settings/DeveloperSettingsTab.test.tsx | 5 ++++- .../__snapshots__/DeveloperSettingsTab.test.tsx.snap | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index 23fa67a7..c19e4f4d 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -25,7 +25,7 @@ function createMockLivekitRoom( wsUrl: string, serverInfo: object, metadata: string, -): { isLocal: boolean; url: string; room: LivekitRoom } { +): { isLocal: boolean; url: string; room: LivekitRoom; livekitAlias: string } { const mockRoom = { serverInfo, metadata, @@ -38,6 +38,7 @@ function createMockLivekitRoom( isLocal: true, url: wsUrl, room: mockRoom, + livekitAlias: "TestAlias", }; } @@ -61,6 +62,7 @@ describe("DeveloperSettingsTab", () => { room: LivekitRoom; url: string; isLocal?: boolean; + livekitAlias: string; }[] = [ createMockLivekitRoom( "wss://local-sfu.example.org", @@ -69,6 +71,7 @@ describe("DeveloperSettingsTab", () => { ), { isLocal: false, + livekitAlias: "TestAlias2", url: "wss://remote-sfu.example.org", room: { localParticipant: { identity: "localParticipantIdentity" }, diff --git a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap index 2ee3710b..57afe4d9 100644 --- a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap +++ b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap @@ -355,6 +355,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `

LiveKit SFU: wss://local-sfu.example.org

+

+ LivekitAlias: + TestAlias +

ws-url: wss://local-sfu.example.org/ @@ -393,6 +397,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `

LiveKit SFU: wss://remote-sfu.example.org

+

+ LivekitAlias: + TestAlias2 +

LiveKit Server Info ( From 4d35b77077b86f619529c3b10fa74da0d816a128 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 13 Jan 2026 17:41:46 +0100 Subject: [PATCH 10/13] bump jwt docker --- 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 0efefd07..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:pr_139 + 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:pr_139 + image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00 pull_policy: always hostname: auth-server-1 environment: From 68f04d46a93c6c93a722ad82e7ac0d3a3a81c908 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 13 Jan 2026 18:38:26 +0100 Subject: [PATCH 11/13] playwright: End to end test for huddle calls in widget mode --- .eslintrc.cjs | 7 ++ playwright/fixtures/widget-user.ts | 137 ++++++---------------- playwright/widget/huddle-call.test.ts | 161 ++++++++++++++++++++++++++ playwright/widget/test-helpers.ts | 133 ++++++++++++++++++++- 4 files changed, 337 insertions(+), 101 deletions(-) create mode 100644 playwright/widget/huddle-call.test.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 11116ed2..164afa07 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -99,6 +99,13 @@ module.exports = { "jsdoc/require-param-description": "off", }, }, + { + files: ["playwright/**"], + rules: { + // Playwright as a `use` function that has nothing to do with React hooks. + "react-hooks/rules-of-hooks": "off", + }, + }, ], settings: { react: { diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts index c2f8ca23..eeddda47 100644 --- a/playwright/fixtures/widget-user.ts +++ b/playwright/fixtures/widget-user.ts @@ -6,15 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - type Browser, - type Page, - test, - expect, - type JSHandle, -} from "@playwright/test"; +import { type Page, test, expect, type JSHandle } from "@playwright/test"; import type { MatrixClient } from "matrix-js-sdk"; +import { TestHelpers } from "../widget/test-helpers.ts"; export type UserBaseFixture = { mxId: string; @@ -31,10 +26,11 @@ export type BaseWidgetSetup = { export interface MyFixtures { asWidget: BaseWidgetSetup; callType: "room" | "dm"; + addUser: ( + username: string /**, homeserver: string*/, + ) => Promise; } -const PASSWORD = "foobarbaz1!"; - // Minimal config.json for the local element-web instance const CONFIG_JSON = { default_server_config: { @@ -68,85 +64,6 @@ const CONFIG_JSON = { }, }; -/** - * Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`. - */ -const setDevToolElementCallDevUrl = process.env.USE_DOCKER - ? async (page: Page): Promise => { - await page.evaluate(() => { - window.mxSettingsStore.setValue( - "Developer.elementCallUrl", - null, - "device", - "http://localhost:8080/room", - ); - }); - } - : async (page: Page): Promise => { - await page.evaluate(() => { - window.mxSettingsStore.setValue( - "Developer.elementCallUrl", - null, - "device", - "https://localhost:3000/room", - ); - }); - }; - -/** - * Registers a new user and returns page, clientHandle and mxId. - */ -async function registerUser( - browser: Browser, - username: string, -): Promise<{ page: Page; clientHandle: JSHandle; mxId: string }> { - const userContext = await browser.newContext({ - reducedMotion: "reduce", - }); - const page = await userContext.newPage(); - await page.goto("http://localhost:8081/#/welcome"); - await page.getByRole("link", { name: "Create Account" }).click(); - await page.getByRole("textbox", { name: "Username" }).fill(username); - await page - .getByRole("textbox", { name: "Password", exact: true }) - .fill(PASSWORD); - await page.getByRole("textbox", { name: "Confirm password" }).click(); - await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD); - await page.getByRole("button", { name: "Register" }).click(); - - await expect( - page.getByRole("heading", { name: `Welcome ${username}` }), - ).toBeVisible(); - - const browserUnsupportedToast = page - .getByText("Element does not support this browser") - .locator("..") - .locator(".."); - - // Dismiss incompatible browser toast - const dismissButton = browserUnsupportedToast.getByRole("button", { - name: "Dismiss", - }); - try { - await expect(dismissButton).toBeVisible({ timeout: 700 }); - await dismissButton.click(); - } catch { - // dismissButton not visible, continue as normal - } - - await setDevToolElementCallDevUrl(page); - - const clientHandle = await page.evaluateHandle(() => - window.mxMatrixClientPeg.get(), - ); - const mxId = (await clientHandle.evaluate( - (cli: MatrixClient) => cli.getUserId(), - clientHandle, - ))!; - - return { page, clientHandle, mxId }; -} - export const widgetTest = test.extend({ // allow per-test override: `widgetTest.use({ callType: "dm" })` callType: ["room", { option: true }], @@ -163,25 +80,16 @@ export const widgetTest = test.extend({ page: ewPage1, clientHandle: brooksClientHandle, mxId: brooksMxId, - } = await registerUser(browser, brooksDisplayName); + } = await TestHelpers.registerUser(browser, brooksDisplayName); const { page: ewPage2, clientHandle: whistlerClientHandle, mxId: whistlerMxId, - } = await registerUser(browser, whistlerDisplayName); + } = await TestHelpers.registerUser(browser, whistlerDisplayName); // Invite the second user - await ewPage1 - .getByRole("navigation", { name: "Room list" }) - .getByRole("button", { name: "New conversation" }) - .click(); - if (callType === "room") { - await ewPage1.getByRole("menuitem", { name: "New Room" }).click(); - await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room"); - await ewPage1.getByRole("button", { name: "Create room" }).click(); - await expect(ewPage1.getByText("You created this room.")).toBeVisible(); - await expect(ewPage1.getByText("Encryption enabled")).toBeVisible(); + await TestHelpers.createRoom("Welcome Room", ewPage1); await ewPage1 .getByRole("button", { name: "Invite to this room", exact: true }) @@ -211,6 +119,11 @@ export const widgetTest = test.extend({ .getByRole("heading", { name: "Welcome Room" }), ).toBeVisible(); } else if (callType === "dm") { + await ewPage1 + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); + await ewPage1.getByRole("menuitem", { name: "Start chat" }).click(); await ewPage1.getByRole("textbox", { name: "Search" }).click(); await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId); @@ -253,4 +166,28 @@ export const widgetTest = test.extend({ }, }); }, + + /** + * Provide a way to add additional users within a test. + * The returned user will be registered on the default homeserver, the name will be made unique by appending a timestamp. + */ + addUser: async ({ browser }, use) => { + await use( + async ( + username: string /**, homeserver?: string*/, + ): Promise => { + const uniqueSuffix = Date.now(); + const { page, clientHandle, mxId } = await TestHelpers.registerUser( + browser, + `${username.toLowerCase()}_${uniqueSuffix}`, + ); + return { + mxId, + displayName: username, + page, + clientHandle, + }; + }, + ); + }, }); diff --git a/playwright/widget/huddle-call.test.ts b/playwright/widget/huddle-call.test.ts new file mode 100644 index 00000000..ee354984 --- /dev/null +++ b/playwright/widget/huddle-call.test.ts @@ -0,0 +1,161 @@ +/* +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 { expect, test } from "@playwright/test"; + +import { widgetTest } from "../fixtures/widget-user.ts"; +import { TestHelpers } from "./test-helpers.ts"; + +widgetTest("Create and join a group call", async ({ addUser, browserName }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); + + test.slow(); // We are registering multiple users here, give it more time + + const valere = await addUser("Valere"); + const timo = await addUser("Timo"); + const robin = await addUser("Robin"); + const halfshot = await addUser("Halfshot"); + const florian = await addUser("florian"); + + const roomName = "Group Call Room"; + await TestHelpers.createRoom(roomName, valere.page, [ + timo.mxId, + robin.mxId, + halfshot.mxId, + florian.mxId, + ]); + + for (const user of [timo, robin, halfshot, florian]) { + // Accept the invite + // This doesn't super stable to get this as this super generic locator, + // but it works for now. + await expect( + user.page.getByRole("option", { name: roomName }), + ).toBeVisible(); + await user.page.getByRole("option", { name: roomName }).click(); + await user.page.getByRole("button", { name: "Accept" }).click(); + + await expect( + user.page.getByRole("main").getByRole("heading", { name: roomName }), + ).toBeVisible(); + } + + // Start the call as Valere + await TestHelpers.startCallInCurrentRoom(valere.page, false); + await expect( + valere.page.locator('iframe[title="Element Call"]'), + ).toBeVisible(); + + await expect( + valere.page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByTestId("lobby_joinCall"), + ).toBeVisible(); + + await valere.page + .locator('iframe[title="Element Call"]') + .contentFrame() + .getByTestId("lobby_joinCall") + .click(); + + for (const user of [timo, robin, halfshot, florian]) { + // THis is the header button that notifies about an ongoing call + await expect(user.page.getByText("Video call started")).toBeVisible(); + await expect(user.page.getByRole("button", { name: "Join" })).toBeVisible(); + await user.page.getByRole("button", { name: "Join" }).click(); + } + + for (const user of [timo, robin, halfshot, florian]) { + const frame = user.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + // No lobby, should start with video on + // The only way to know if it is muted or not is to look at the data-kind attribute.. + const videoButton = frame.getByTestId("incall_videomute"); + await expect(videoButton).toBeVisible(); + // video should be off by default in a voice call + await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); + } + + // We should see 5 video tiles everywhere now + for (const user of [valere, timo, robin, halfshot, florian]) { + const frame = user.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + await expect(frame.getByTestId("videoTile")).toHaveCount(5); + for (const participant of [valere, timo, robin, halfshot, florian]) { + // Check the names are correct + await expect(frame.getByText(participant.displayName)).toBeVisible(); + } + + // There is no other options than to wait for all media to be ready? + // Or it is too flaky :/ + await user.page.waitForTimeout(5000); + // No one should be waiting for media + await expect(frame.getByText("Waiting for media...")).not.toBeVisible(); + + // There should be 5 video elements, visible and autoplaying + const videoElements = await frame.locator("video").all(); + expect(videoElements.length).toBe(5); + await expect(frame.locator("video[autoplay]")).toHaveCount(5); + + const blockDisplayCount = await frame + .locator("video") + .evaluateAll( + (videos: Element[]) => + videos.filter( + (v: Element) => window.getComputedStyle(v).display === "block", + ).length, + ); + expect(blockDisplayCount).toBe(5); + } + + // Quickly test muting one participant to see it reflects and that our asserts works + const florianFrame = florian.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + const florianMuteButton = florianFrame.getByTestId("incall_videomute"); + await florianMuteButton.click(); + // Now the button should indicate we can start video + await expect(florianMuteButton).toHaveAttribute( + "aria-label", + /^Start video$/, + ); + + // wait a bit for the state to propagate + await valere.page.waitForTimeout(3000); + { + const frame = valere.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + const videoElements = await frame.locator("video").all(); + expect(videoElements.length).toBe(5); + // + // // ONLY 4 !!!!! + // await expect(frame.locator('video[autoplay]')).toHaveCount(4); + + const blockDisplayCount = await frame + .locator("video") + .evaluateAll( + (videos: Element[]) => + videos.filter( + (v: Element) => window.getComputedStyle(v).display === "block", + ).length, + ); + + // ONLY 4!! + expect(blockDisplayCount).toBe(4); + } + + await valere.page.pause(); +}); diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts index 5a74cbac..4ef05ef4 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -5,7 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { expect, type Page } from "@playwright/test"; +import { + type Browser, + expect, + type JSHandle, + type Page, +} from "@playwright/test"; +import { type MatrixClient } from "matrix-js-sdk"; + +const PASSWORD = "foobarbaz1!"; export class TestHelpers { public static async startCallInCurrentRoom( @@ -22,4 +30,127 @@ export class TestHelpers { await page.getByRole("menuitem", { name: "Element Call" }).click(); } + + /** + * Registers a new user and returns page, clientHandle and mxId. + */ + public static async registerUser( + browser: Browser, + username: string, + ): Promise<{ + page: Page; + clientHandle: JSHandle; + mxId: string; + }> { + const userContext = await browser.newContext({ + reducedMotion: "reduce", + }); + const page = await userContext.newPage(); + await page.goto("http://localhost:8081/#/welcome"); + await page.getByRole("link", { name: "Create Account" }).click(); + await page.getByRole("textbox", { name: "Username" }).fill(username); + await page + .getByRole("textbox", { name: "Password", exact: true }) + .fill(PASSWORD); + await page.getByRole("textbox", { name: "Confirm password" }).click(); + await page + .getByRole("textbox", { name: "Confirm password" }) + .fill(PASSWORD); + await page.getByRole("button", { name: "Register" }).click(); + + await expect( + page.getByRole("heading", { name: `Welcome ${username}` }), + ).toBeVisible({ + // Increase timeout as registration can be slow :/ + timeout: 15_000, + }); + + const browserUnsupportedToast = page + .getByText("Element does not support this browser") + .locator("..") + .locator(".."); + + // Dismiss incompatible browser toast + const dismissButton = browserUnsupportedToast.getByRole("button", { + name: "Dismiss", + }); + try { + await expect(dismissButton).toBeVisible({ timeout: 700 }); + await dismissButton.click(); + } catch { + // dismissButton not visible, continue as normal + } + + await TestHelpers.setDevToolElementCallDevUrl(page); + + const clientHandle = await page.evaluateHandle(() => + window.mxMatrixClientPeg.get(), + ); + const mxId = (await clientHandle.evaluate( + (cli: MatrixClient) => cli.getUserId(), + clientHandle, + ))!; + + return { page, clientHandle, mxId }; + } + + public static async createRoom( + name: string, + page: Page, + andInvite: string[] = [], + ): Promise { + await page.pause(); + await page + .getByRole("navigation", { name: "Room list" }) + .getByRole("button", { name: "New conversation" }) + .click(); + + await page.getByRole("menuitem", { name: "New Room" }).click(); + await page.getByRole("textbox", { name: "Name" }).fill(name); + await page.getByRole("button", { name: "Create room" }).click(); + await expect(page.getByText("You created this room.")).toBeVisible(); + await expect(page.getByText("Encryption enabled")).toBeVisible(); + + // Invite users if any + if (andInvite.length > 0) { + await page + .getByRole("button", { name: "Invite to this room", exact: true }) + .click(); + + const inviteInput = page.getByRole("dialog").getByRole("textbox"); + for (const mxId of andInvite) { + await inviteInput.focus(); + await inviteInput.fill(mxId); + await inviteInput.press("Enter"); + } + + await page.getByRole("button", { name: "Invite" }).click(); + } + } + + /** + * Sets the current Element Web app to use the dev Element Call URL. + * @param page - The EW page + */ + public static async setDevToolElementCallDevUrl(page: Page): Promise { + if (process.env.USE_DOCKER) { + await page.evaluate(() => { + window.mxSettingsStore.setValue( + "Developer.elementCallUrl", + null, + "device", + "http://localhost:8080/room", + ); + }); + } else { + await page.evaluate(() => { + window.mxSettingsStore.setValue( + "Developer.elementCallUrl", + null, + "device", + "https://localhost:3000/room", + ); + }); + } + } } From 7338a55e76cfd9106ae93308c22314add054bb01 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 14 Jan 2026 12:15:00 +0100 Subject: [PATCH 12/13] review: improve doc --- playwright/widget/huddle-call.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/playwright/widget/huddle-call.test.ts b/playwright/widget/huddle-call.test.ts index ee354984..6acf176c 100644 --- a/playwright/widget/huddle-call.test.ts +++ b/playwright/widget/huddle-call.test.ts @@ -34,7 +34,7 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { for (const user of [timo, robin, halfshot, florian]) { // Accept the invite - // This doesn't super stable to get this as this super generic locator, + // This isn't super stable to get this as this super generic locator, // but it works for now. await expect( user.page.getByRole("option", { name: roomName }), @@ -140,9 +140,6 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { const videoElements = await frame.locator("video").all(); expect(videoElements.length).toBe(5); - // - // // ONLY 4 !!!!! - // await expect(frame.locator('video[autoplay]')).toHaveCount(4); const blockDisplayCount = await frame .locator("video") @@ -153,7 +150,9 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { ).length, ); - // ONLY 4!! + // out of 5 ONLY 4 are visible (display:block) !! + // XXX we need to be better at our HTML markup and accessibility, it would make + // this kind of stuff way easier to test if we could look out for aria attributes. expect(blockDisplayCount).toBe(4); } From 41eb45b3c46340e35d6999b0cc2f4bf34be27324 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 14 Jan 2026 12:29:22 +0100 Subject: [PATCH 13/13] fix comments --- src/state/CallViewModel/localMember/LocalTransport.ts | 2 +- src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 7f87c392..0625866d 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -280,7 +280,7 @@ async function makeTransport( // - If we set it to the hased alias we get from the jwt, we will end up using the hashed alias as the body.roomId field // in v0.16.0. (It will use oldest member transport. It is using the transport.livekit_alias as the body.roomId) // - // TLDR this is a temporal fild that allow for comaptibilty but the spec expects it to not exists. (but its existance also does not break anything) + // TLDR this is a temporal field that allow for comaptibilty but the spec expects it to not exists. (but its existance also does not break anything) // It is just named poorly: It was intetended to be the actual alias. But now we do pseudonymys ids so we use a hashed alias. livekit_alias: roomId, }, diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index 24e18af2..4ac9753f 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -148,6 +148,7 @@ export function areLivekitTransportsEqual( t1.livekit_service_url === t2.livekit_service_url && // In case we have different lk rooms in the same SFU (depends on the livekit authorization service) // It is only needed in case the livekit authorization service is not behaving as expected (or custom implementation) + // Also LivekitTransport is planned to become a `ConnectionIdentifier` which moves this equal somewhere else. t1.livekit_alias === t2.livekit_alias ); if (!t1 && !t2) return true;