From 29742006df0b0b4a1582882937ec2e964b336f47 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 28 Sep 2026 17:26:11 +0200 Subject: [PATCH 01/26] Move WindowMode next to the layout types to prevent cyclic import --- src/state/CallViewModel/CallViewModel.ts | 8 +------- src/state/LayoutSwitchViewModel.ts | 2 +- src/state/layout-types.ts | 7 +++++++ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 1ab4ea134..831dd0b0e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -102,6 +102,7 @@ import { type SpotlightExpandedLayoutMedia, type SpotlightLandscapeLayoutMedia, type SpotlightPortraitLayoutMedia, + type WindowMode, } from "../layout-types.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts"; import { type Epoch, type ObservableScope } from "../ObservableScope.ts"; @@ -271,13 +272,6 @@ const smallMobileCallThreshold = 3; // with the interface const showFooterMs = 4000; -/** - * The general shape of the space the call is drawn in. Called a window because - * that is what it is in the standalone app; for a component it is the container - * the host gave us, which may be a small corner of a large window. - */ -export type WindowMode = "normal" | "narrow" | "flat" | "pip"; - interface LayoutScanState { layout: Layout | null; overflowing: boolean; diff --git a/src/state/LayoutSwitchViewModel.ts b/src/state/LayoutSwitchViewModel.ts index 6e61abaaa..f495c6275 100644 --- a/src/state/LayoutSwitchViewModel.ts +++ b/src/state/LayoutSwitchViewModel.ts @@ -14,7 +14,7 @@ import { switchAll, } from "rxjs"; -import { type WindowMode } from "./CallViewModel/CallViewModel.ts"; +import { type WindowMode } from "./layout-types.ts"; import { constant, type Behavior } from "./Behavior.ts"; import { type ObservableScope } from "./ObservableScope.ts"; diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index be86f2a36..2d526617b 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -18,6 +18,13 @@ import { import { type Behavior } from "./Behavior.ts"; import { shallowEquals as arrayShallowEquals } from "../utils/array.ts"; +/** + * The general shape of the space the call is drawn in. Called a window because + * that is what it is in the standalone app; for a component it is the container + * the host gave us, which may be a small corner of a large window. + */ +export type WindowMode = "normal" | "narrow" | "flat" | "pip"; + export interface GridLayoutMedia { type: "grid"; edgeToEdge: false; From 10adf6658b82d8a35804b8e07b3b3549d954d8a0 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 28 Sep 2026 17:27:04 +0200 Subject: [PATCH 02/26] Let layoutShallowEquals compare any layout-shaped object --- src/state/layout-types.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 2d526617b..fd9b36a69 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -150,10 +150,11 @@ export type Layout = | PipLayout; /** - * Tests whether the top-level properties and array elements of layout `a` are - * equal to those of layout `b`. Useful for deduping redundant layout updates. + * Tests whether the top-level properties and array elements of `a` are equal + * to those of `b`. Useful for deduping redundant layout and layout media + * updates. */ -export function layoutShallowEquals(a: Layout, b: Layout): boolean { +export function layoutShallowEquals(a: T, b: T): boolean { // If a and b have the same number of keys and every key in a is also in b, // then they have the same keys. const aKeys = Object.keys(a); From 232e3f7ad47942849fdc193ee147f32b8fda74ac Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 28 Sep 2026 17:28:14 +0200 Subject: [PATCH 03/26] Extract the spotlight speaker choice into a pure function --- src/state/CallViewModel/CallViewModel.ts | 26 ++-------- src/state/layoutMedia.test.ts | 62 ++++++++++++++++++++++++ src/state/layoutMedia.ts | 33 +++++++++++++ 3 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 src/state/layoutMedia.test.ts create mode 100644 src/state/layoutMedia.ts diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 831dd0b0e..3e92c4e8e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -104,6 +104,7 @@ import { type SpotlightPortraitLayoutMedia, type WindowMode, } from "../layout-types.ts"; +import { chooseSpotlightSpeaker } from "../layoutMedia.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts"; import { type Epoch, type ObservableScope } from "../ObservableScope.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; @@ -975,33 +976,16 @@ export function createCallViewModel$( mediaItems.length === 0 ? of([]) : combineLatest( - mediaItems.map((m) => - m.speaking$.pipe(map((s) => [m, s] as const)), + mediaItems.map((media) => + media.speaking$.pipe(map((speaking) => ({ media, speaking }))), ), ), ), scan< - (readonly [UserMediaViewModel, boolean])[], + { media: UserMediaViewModel; speaking: boolean }[], UserMediaViewModel | undefined, undefined - >((prev, mediaItems) => { - // Only remote users that are still in the call should be sticky - const [stickyMedia, stickySpeaking] = - (!prev?.local && mediaItems.find(([m]) => m === prev)) || []; - // Decide who to spotlight: - // If the previous speaker is still speaking, stick with them rather - // than switching eagerly to someone else - return stickySpeaking - ? stickyMedia! - : // Otherwise, select any remote user who is speaking - (mediaItems.find(([m, s]) => !m.local && s)?.[0] ?? - // Otherwise, stick with the person who was last speaking - stickyMedia ?? - // Otherwise, spotlight an arbitrary remote user - mediaItems.find(([m]) => !m.local)?.[0] ?? - // Otherwise, spotlight the local user - mediaItems.find(([m]) => m.local)?.[0]); - }, undefined), + >(chooseSpotlightSpeaker, undefined), ), ); diff --git a/src/state/layoutMedia.test.ts b/src/state/layoutMedia.test.ts new file mode 100644 index 000000000..6936b1143 --- /dev/null +++ b/src/state/layoutMedia.test.ts @@ -0,0 +1,62 @@ +/* +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 { describe, expect, it } from "vitest"; + +import { chooseSpotlightSpeaker } from "./layoutMedia"; +import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel"; +import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel"; + +// The layout functions only look at `type` and `local`, so stubs suffice +const local = { type: "user", local: true } as LocalUserMediaViewModel; +const alice = { type: "user", local: false } as RemoteUserMediaViewModel; +const bob = { type: "user", local: false } as RemoteUserMediaViewModel; +const carol = { type: "user", local: false } as RemoteUserMediaViewModel; + +describe("chooseSpotlightSpeaker", () => { + const items = ( + ...speaking: boolean[] + ): { + media: RemoteUserMediaViewModel | LocalUserMediaViewModel; + speaking: boolean; + }[] => + [local, alice, bob].map((media, i) => ({ + media, + speaking: speaking[i] ?? false, + })); + + it("prefers a remote speaker over the local user", () => { + expect(chooseSpotlightSpeaker(undefined, items(true, false, true))).toBe( + bob, + ); + }); + + it("falls back to any remote user, then the local user", () => { + expect(chooseSpotlightSpeaker(undefined, items())).toBe(alice); + expect( + chooseSpotlightSpeaker(undefined, [{ media: local, speaking: false }]), + ).toBe(local); + expect(chooseSpotlightSpeaker(undefined, [])).toBeUndefined(); + }); + + it("sticks with the previous speaker while they keep speaking", () => { + expect(chooseSpotlightSpeaker(bob, items(false, true, true))).toBe(bob); + }); + + it("sticks with the previous speaker when nobody is speaking", () => { + expect(chooseSpotlightSpeaker(bob, items())).toBe(bob); + }); + + it("switches when someone else speaks and the previous speaker is silent", () => { + expect(chooseSpotlightSpeaker(bob, items(false, true, false))).toBe(alice); + }); + + it("forgets a previous speaker who left, and never sticks to the local user", () => { + expect(chooseSpotlightSpeaker(carol, items())).toBe(alice); + expect(chooseSpotlightSpeaker(local, items())).toBe(alice); + }); +}); diff --git a/src/state/layoutMedia.ts b/src/state/layoutMedia.ts new file mode 100644 index 000000000..3995f0216 --- /dev/null +++ b/src/state/layoutMedia.ts @@ -0,0 +1,33 @@ +/* +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. +*/ + +/** + * Picks who to spotlight based on who is speaking, preferring the previous + * pick so that the spotlight doesn't flip eagerly between speakers. + * + * @param prev The previous pick, if any. + * @param items All user media in the call along with whether each is speaking. + */ +export function chooseSpotlightSpeaker( + prev: M | undefined, + items: readonly { media: M; speaking: boolean }[], +): M | undefined { + // Only remote users that are still in the call should be sticky + const sticky = items.find((i) => i.media === prev && !i.media.local); + // If the previous speaker is still speaking, stick with them + if (sticky?.speaking) return sticky.media; + return ( + // Otherwise, select any remote user who is speaking + items.find((i) => !i.media.local && i.speaking)?.media ?? + // Otherwise, stick with the person who was last speaking + sticky?.media ?? + // Otherwise, spotlight an arbitrary remote user + items.find((i) => !i.media.local)?.media ?? + // Otherwise, spotlight the local user + items.find((i) => i.media.local)?.media + ); +} From 5a8714805c8913c1c8e3e2a31cb9769b8da156c3 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 28 Sep 2026 17:29:41 +0200 Subject: [PATCH 04/26] Extract the spotlight and PiP choice into a pure function --- src/state/CallViewModel/CallViewModel.ts | 51 ++++++----------------- src/state/layoutMedia.test.ts | 52 +++++++++++++++++++++++- src/state/layoutMedia.ts | 35 ++++++++++++++++ 3 files changed, 99 insertions(+), 39 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 3e92c4e8e..f171f8947 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -104,7 +104,7 @@ import { type SpotlightPortraitLayoutMedia, type WindowMode, } from "../layout-types.ts"; -import { chooseSpotlightSpeaker } from "../layoutMedia.ts"; +import { chooseSpotlightSpeaker, computeSpotlight } from "../layoutMedia.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts"; import { type Epoch, type ObservableScope } from "../ObservableScope.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; @@ -1029,33 +1029,12 @@ export function createCallViewModel$( ), ); - const spotlightAndPip$ = scope.behavior<{ - spotlight: MediaViewModel[]; - pip$: Observable; - }>( - ringingMedia$.pipe( - switchMap((ringingMedia) => { - if (ringingMedia !== null) - return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ }); - - return screenShares$.pipe( - switchMap((screenShares) => { - if (screenShares.length > 0) - return of({ spotlight: screenShares, pip$: spotlightSpeaker$ }); - - return spotlightSpeaker$.pipe( - map((speaker) => ({ - spotlight: speaker ? [speaker] : [], - // Hide PiP if redundant (i.e. if local user is already in spotlight) - pip$: localUserMediaForPip$.pipe( - map((m) => (m === speaker ? undefined : m)), - ), - })), - ); - }), - ); - }), - ), + const spotlightAndPip$ = scope.behavior( + combineLatest( + [ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$], + (ringing, screenShares, speaker, localPip) => + computeSpotlight({ ringing, screenShares, speaker, localPip }), + ).pipe(distinctUntilChanged(layoutShallowEquals)), ); const spotlight$ = scope.behavior( @@ -1147,16 +1126,12 @@ export function createCallViewModel$( edgeToEdge: boolean, ): Observable => spotlightAndPip$.pipe( - switchMap(({ spotlight, pip$ }) => - pip$.pipe( - map((pip) => ({ - type: "spotlight-expanded" as const, - edgeToEdge, - spotlight, - pip: pip ?? undefined, - })), - ), - ), + map(({ spotlight, pip }) => ({ + type: "spotlight-expanded" as const, + edgeToEdge, + spotlight, + pip, + })), ); const oneOnOneLayoutMedia$: Behavior<{ diff --git a/src/state/layoutMedia.test.ts b/src/state/layoutMedia.test.ts index 6936b1143..8e164cc9a 100644 --- a/src/state/layoutMedia.test.ts +++ b/src/state/layoutMedia.test.ts @@ -7,15 +7,22 @@ Please see LICENSE in the repository root for full details. import { describe, expect, it } from "vitest"; -import { chooseSpotlightSpeaker } from "./layoutMedia"; +import { chooseSpotlightSpeaker, computeSpotlight } from "./layoutMedia"; import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel"; import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel"; +import { type RingingMediaViewModel } from "./media/RingingMediaViewModel"; +import { type ScreenShareViewModel } from "./media/ScreenShareViewModel"; // The layout functions only look at `type` and `local`, so stubs suffice const local = { type: "user", local: true } as LocalUserMediaViewModel; const alice = { type: "user", local: false } as RemoteUserMediaViewModel; const bob = { type: "user", local: false } as RemoteUserMediaViewModel; const carol = { type: "user", local: false } as RemoteUserMediaViewModel; +const screenShare = { + type: "screen share", + local: false, +} as ScreenShareViewModel; +const ringing = { type: "ringing" } as RingingMediaViewModel; describe("chooseSpotlightSpeaker", () => { const items = ( @@ -60,3 +67,46 @@ describe("chooseSpotlightSpeaker", () => { expect(chooseSpotlightSpeaker(local, items())).toBe(alice); }); }); + +describe("computeSpotlight", () => { + it("spotlights ringing media with the local user in the PiP", () => { + expect( + computeSpotlight({ + ringing, + screenShares: [screenShare], + speaker: alice, + localPip: local, + }), + ).toEqual({ spotlight: [ringing], pip: local }); + }); + + it("spotlights screen shares with the speaker in the PiP", () => { + expect( + computeSpotlight({ + ringing: null, + screenShares: [screenShare], + speaker: alice, + localPip: local, + }), + ).toEqual({ spotlight: [screenShare], pip: alice }); + }); + + it("otherwise spotlights the speaker and hides a redundant PiP", () => { + expect( + computeSpotlight({ + ringing: null, + screenShares: [], + speaker: alice, + localPip: local, + }), + ).toEqual({ spotlight: [alice], pip: local }); + expect( + computeSpotlight({ + ringing: null, + screenShares: [], + speaker: local, + localPip: local, + }), + ).toEqual({ spotlight: [local], pip: undefined }); + }); +}); diff --git a/src/state/layoutMedia.ts b/src/state/layoutMedia.ts index 3995f0216..64365b3a8 100644 --- a/src/state/layoutMedia.ts +++ b/src/state/layoutMedia.ts @@ -5,6 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import { type MediaViewModel } from "./media/MediaViewModel.ts"; +import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts"; +import { type ScreenShareViewModel } from "./media/ScreenShareViewModel.ts"; +import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts"; + /** * Picks who to spotlight based on who is speaking, preferring the previous * pick so that the spotlight doesn't flip eagerly between speakers. @@ -31,3 +36,33 @@ export function chooseSpotlightSpeaker( items.find((i) => i.media.local)?.media ); } + +export interface SpotlightInputs { + ringing: RingingMediaViewModel | null; + screenShares: ScreenShareViewModel[]; + speaker: UserMediaViewModel | undefined; + /** Local user media suitable for showing in a PiP, if any. */ + localPip: UserMediaViewModel | undefined; +} + +/** + * Decides what goes in the spotlight, and what goes in the picture-in-picture + * tile of the expanded spotlight layout. + */ +export function computeSpotlight({ + ringing, + screenShares, + speaker, + localPip, +}: SpotlightInputs): { + spotlight: MediaViewModel[]; + pip: UserMediaViewModel | undefined; +} { + if (ringing !== null) return { spotlight: [ringing], pip: localPip }; + if (screenShares.length > 0) return { spotlight: screenShares, pip: speaker }; + return { + spotlight: speaker ? [speaker] : [], + // Hide PiP if redundant (i.e. if local user is already in spotlight) + pip: localPip === speaker ? undefined : localPip, + }; +} From 33cfd2d8adb62921d947d4f51e2e286be5b76e99 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 28 Sep 2026 17:34:22 +0200 Subject: [PATCH 05/26] Build the layout media from one snapshot of its inputs --- src/state/CallViewModel/CallViewModel.ts | 306 ++++++++++++----------- 1 file changed, 159 insertions(+), 147 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index f171f8947..76675c824 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -30,7 +30,6 @@ import { scan, startWith, Subject, - switchAll, switchMap, switchScan, take, @@ -148,6 +147,7 @@ import { import { Publisher } from "./localMember/Publisher.ts"; import { type Connection } from "./remoteMembers/Connection.ts"; import { + type LayoutMode, type LayoutSwitchViewModel, createLayoutSwitchViewModel, } from "../LayoutSwitchViewModel.ts"; @@ -1092,52 +1092,7 @@ export function createCallViewModel$( hasRemoteScreenShares$, ); - const gridLayoutMedia$: Observable = combineLatest( - [grid$, spotlight$], - (grid, spotlight) => ({ - type: "grid", - edgeToEdge: false, - spotlight: spotlight.some((vm) => vm.type === "screen share") - ? spotlight - : undefined, - grid, - }), - ); - - const spotlightLandscapeLayoutMedia$ = ( - edgeToEdge: boolean, - ): Observable => - combineLatest([grid$, spotlight$], (grid, spotlight) => ({ - type: "spotlight-landscape", - edgeToEdge, - spotlight, - grid, - })); - - const spotlightPortraitLayoutMedia$: Observable = - combineLatest([grid$, spotlight$], (grid, spotlight) => ({ - type: "spotlight-portrait", - edgeToEdge: false, - spotlight, - grid, - })); - - const spotlightExpandedLayoutMedia$ = ( - edgeToEdge: boolean, - ): Observable => - spotlightAndPip$.pipe( - map(({ spotlight, pip }) => ({ - type: "spotlight-expanded" as const, - edgeToEdge, - spotlight, - pip, - })), - ); - - const oneOnOneLayoutMedia$: Behavior<{ - local: LocalUserMediaViewModel; - remote: UserMediaViewModel | RingingMediaViewModel; - } | null> = scope.behavior( + const oneOnOneLayoutMedia$: Behavior = scope.behavior( combineLatest([userMedia$, screenShares$]).pipe( switchMap(([userMedia, screenShares]) => { // One-on-one layout only supports 2 user media, no screen shares @@ -1175,47 +1130,12 @@ export function createCallViewModel$( ), ); - const oneOnOneDesktopLayoutMedia$: Observable = + const localVideoEnabled$ = scope.behavior( oneOnOneLayoutMedia$.pipe( - map((media) => { - if (media === null) return null; - return media.remote.type === "ringing" - ? { - type: "one-on-one-desktop" as const, - edgeToEdge: false, - spotlight: media.local, - pip: media.remote, - } - : { - type: "one-on-one-desktop" as const, - edgeToEdge: false, - spotlight: media.remote, - pip: media.local, - }; - }), - ); - - const oneOnOneMobileLayoutMedia$: Observable = - oneOnOneLayoutMedia$.pipe( - switchMap((media) => { - if (media === null) return of(null); - return media.local.videoEnabled$.pipe( - map((videoEnabled) => ({ - type: "one-on-one-mobile" as const, - edgeToEdge: true as const, - spotlight: media.remote, - pip: videoEnabled ? media.local : undefined, - })), - ); - }), - ); - - const pipLayoutMedia$: Observable = spotlight$.pipe( - map((spotlight) => ({ - type: "pip", - edgeToEdge: platform !== "desktop", - spotlight, - })), + switchMap((media) => + media === null ? of(false) : media.local.videoEnabled$, + ), + ), ); spotlight$ @@ -1250,66 +1170,28 @@ export function createCallViewModel$( * The media to be used to produce a layout. */ const layoutMedia$ = scope.behavior( - windowMode$.pipe( - switchMap((windowMode) => { - switch (windowMode) { - case "normal": - return layoutSwitchVm.layout$.pipe( - switchMap((layout) => { - switch (layout) { - case "grid": - return oneOnOneDesktopLayoutMedia$.pipe( - switchMap((oneOnOne) => - oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne), - ), - ); - case "spotlight": - return spotlightExpanded$.pipe( - switchMap((expanded) => - expanded - ? spotlightExpandedLayoutMedia$(false) - : spotlightLandscapeLayoutMedia$(false), - ), - ); - } - }), - ); - case "narrow": - return oneOnOneMobileLayoutMedia$.pipe( - switchMap((oneOnOne) => - oneOnOne === null - ? combineLatest([grid$, spotlight$], (grid, spotlight) => - grid.length > smallMobileCallThreshold || - spotlight.some((vm) => vm.type === "screen share") - ? spotlightPortraitLayoutMedia$ - : gridLayoutMedia$, - ).pipe(switchAll()) - : of(oneOnOne), - ), - ); - case "flat": - return oneOnOneMobileLayoutMedia$.pipe( - switchMap((oneOnOne) => - oneOnOne === null - ? layoutSwitchVm.layout$.pipe( - switchMap((layout) => { - switch (layout) { - case "grid": - // Yes, grid mode actually gets you a "spotlight" layout in - // this window mode. - return spotlightLandscapeLayoutMedia$(true); - case "spotlight": - return spotlightExpandedLayoutMedia$(true); - } - }), - ) - : of(oneOnOne), - ), - ); - case "pip": - return pipLayoutMedia$; - } - }), + merge( + windowMode$, + layoutSwitchVm.layout$, + spotlightExpanded$, + oneOnOneLayoutMedia$, + localVideoEnabled$, + spotlightAndPip$, + grid$, + ).pipe( + map(() => + computeLayoutMedia({ + windowMode: windowMode$.value, + layoutMode: layoutSwitchVm.layout$.value, + spotlightExpanded: spotlightExpanded$.value, + oneOnOne: oneOnOneLayoutMedia$.value, + localVideoEnabled: localVideoEnabled$.value, + ...spotlightAndPip$.value, + grid: grid$.value, + desktop: platform === "desktop", + }), + ), + distinctUntilChanged(layoutShallowEquals), ), ); @@ -1891,3 +1773,133 @@ function getE2eeKeyProvider( return keyProvider; } } + +interface OneOnOneMedia { + local: LocalUserMediaViewModel; + remote: UserMediaViewModel | RingingMediaViewModel; +} + +interface LayoutMediaInputs { + windowMode: WindowMode; + /** The layout chosen with the layout switch. */ + layoutMode: LayoutMode; + spotlightExpanded: boolean; + /** Set when the call qualifies for a one-on-one layout. */ + oneOnOne: OneOnOneMedia | null; + localVideoEnabled: boolean; + spotlight: MediaViewModel[]; + grid: UserMediaViewModel[]; + pip: UserMediaViewModel | undefined; + desktop: boolean; +} + +/** + * Decides which layout to use and which media goes where in it. + */ +function computeLayoutMedia({ + windowMode, + layoutMode, + spotlightExpanded, + oneOnOne, + localVideoEnabled, + spotlight, + grid, + pip, + desktop, +}: LayoutMediaInputs): LayoutMedia { + switch (windowMode) { + case "normal": + if (layoutMode === "grid") + return oneOnOne === null + ? gridLayoutMedia(spotlight, grid) + : oneOnOneDesktopLayoutMedia(oneOnOne); + return spotlightExpanded + ? spotlightExpandedLayoutMedia(false, spotlight, pip) + : spotlightLandscapeLayoutMedia(false, spotlight, grid); + case "narrow": + if (oneOnOne !== null) + return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled); + return grid.length > smallMobileCallThreshold || hasScreenShare(spotlight) + ? spotlightPortraitLayoutMedia(spotlight, grid) + : gridLayoutMedia(spotlight, grid); + case "flat": + if (oneOnOne !== null) + return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled); + // Yes, grid mode actually gets you a "spotlight" layout in this window + // mode. + return layoutMode === "grid" + ? spotlightLandscapeLayoutMedia(true, spotlight, grid) + : spotlightExpandedLayoutMedia(true, spotlight, pip); + case "pip": + return { type: "pip", edgeToEdge: !desktop, spotlight }; + } +} + +function hasScreenShare(spotlight: MediaViewModel[]): boolean { + return spotlight.some((vm) => vm.type === "screen share"); +} + +function gridLayoutMedia( + spotlight: MediaViewModel[], + grid: UserMediaViewModel[], +): GridLayoutMedia { + return { + type: "grid", + edgeToEdge: false, + spotlight: hasScreenShare(spotlight) ? spotlight : undefined, + grid, + }; +} + +function oneOnOneDesktopLayoutMedia( + media: OneOnOneMedia, +): OneOnOneDesktopLayoutMedia { + return media.remote.type === "ringing" + ? { + type: "one-on-one-desktop", + edgeToEdge: false, + spotlight: media.local, + pip: media.remote, + } + : { + type: "one-on-one-desktop", + edgeToEdge: false, + spotlight: media.remote, + pip: media.local, + }; +} + +function spotlightExpandedLayoutMedia( + edgeToEdge: boolean, + spotlight: MediaViewModel[], + pip: UserMediaViewModel | undefined, +): SpotlightExpandedLayoutMedia { + return { type: "spotlight-expanded", edgeToEdge, spotlight, pip }; +} + +function spotlightLandscapeLayoutMedia( + edgeToEdge: boolean, + spotlight: MediaViewModel[], + grid: UserMediaViewModel[], +): SpotlightLandscapeLayoutMedia { + return { type: "spotlight-landscape", edgeToEdge, spotlight, grid }; +} + +function oneOnOneMobileLayoutMedia( + media: OneOnOneMedia, + localVideoEnabled: boolean, +): OneOnOneMobileLayoutMedia { + return { + type: "one-on-one-mobile", + edgeToEdge: true, + spotlight: media.remote, + pip: localVideoEnabled ? media.local : undefined, + }; +} + +function spotlightPortraitLayoutMedia( + spotlight: MediaViewModel[], + grid: UserMediaViewModel[], +): SpotlightPortraitLayoutMedia { + return { type: "spotlight-portrait", edgeToEdge: false, spotlight, grid }; +} From 6600c891f339151c20e4ba69ed09a7ac7fa13b73 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 28 Sep 2026 17:36:24 +0200 Subject: [PATCH 06/26] Extract the layout decision into a pure function --- src/state/CallViewModel/CallViewModel.ts | 148 +---------------------- src/state/layoutMedia.test.ts | 110 ++++++++++++++++- src/state/layoutMedia.ts | 148 +++++++++++++++++++++++ 3 files changed, 263 insertions(+), 143 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 76675c824..ae6a54697 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -93,17 +93,16 @@ import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { layoutShallowEquals, type Alignment, - type GridLayoutMedia, type Layout, type LayoutMedia, - type OneOnOneDesktopLayoutMedia, - type OneOnOneMobileLayoutMedia, - type SpotlightExpandedLayoutMedia, - type SpotlightLandscapeLayoutMedia, - type SpotlightPortraitLayoutMedia, type WindowMode, } from "../layout-types.ts"; -import { chooseSpotlightSpeaker, computeSpotlight } from "../layoutMedia.ts"; +import { + chooseSpotlightSpeaker, + computeLayoutMedia, + computeSpotlight, + type OneOnOneMedia, +} from "../layoutMedia.ts"; import { ElementCallError, UnknownCallError } from "../../utils/errors.ts"; import { type Epoch, type ObservableScope } from "../ObservableScope.ts"; import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts"; @@ -147,7 +146,6 @@ import { import { Publisher } from "./localMember/Publisher.ts"; import { type Connection } from "./remoteMembers/Connection.ts"; import { - type LayoutMode, type LayoutSwitchViewModel, createLayoutSwitchViewModel, } from "../LayoutSwitchViewModel.ts"; @@ -265,10 +263,6 @@ export function callViewModelOptionsFromParams( export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; export const THROTTLE_SOUND_EFFECT_MS = 500; -// This is the number of participants that we think constitutes a "small" call -// on mobile. No spotlight tile should be shown below this threshold. -const smallMobileCallThreshold = 3; - // How long the footer should be shown for when hovering over or interacting // with the interface const showFooterMs = 4000; @@ -1773,133 +1767,3 @@ function getE2eeKeyProvider( return keyProvider; } } - -interface OneOnOneMedia { - local: LocalUserMediaViewModel; - remote: UserMediaViewModel | RingingMediaViewModel; -} - -interface LayoutMediaInputs { - windowMode: WindowMode; - /** The layout chosen with the layout switch. */ - layoutMode: LayoutMode; - spotlightExpanded: boolean; - /** Set when the call qualifies for a one-on-one layout. */ - oneOnOne: OneOnOneMedia | null; - localVideoEnabled: boolean; - spotlight: MediaViewModel[]; - grid: UserMediaViewModel[]; - pip: UserMediaViewModel | undefined; - desktop: boolean; -} - -/** - * Decides which layout to use and which media goes where in it. - */ -function computeLayoutMedia({ - windowMode, - layoutMode, - spotlightExpanded, - oneOnOne, - localVideoEnabled, - spotlight, - grid, - pip, - desktop, -}: LayoutMediaInputs): LayoutMedia { - switch (windowMode) { - case "normal": - if (layoutMode === "grid") - return oneOnOne === null - ? gridLayoutMedia(spotlight, grid) - : oneOnOneDesktopLayoutMedia(oneOnOne); - return spotlightExpanded - ? spotlightExpandedLayoutMedia(false, spotlight, pip) - : spotlightLandscapeLayoutMedia(false, spotlight, grid); - case "narrow": - if (oneOnOne !== null) - return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled); - return grid.length > smallMobileCallThreshold || hasScreenShare(spotlight) - ? spotlightPortraitLayoutMedia(spotlight, grid) - : gridLayoutMedia(spotlight, grid); - case "flat": - if (oneOnOne !== null) - return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled); - // Yes, grid mode actually gets you a "spotlight" layout in this window - // mode. - return layoutMode === "grid" - ? spotlightLandscapeLayoutMedia(true, spotlight, grid) - : spotlightExpandedLayoutMedia(true, spotlight, pip); - case "pip": - return { type: "pip", edgeToEdge: !desktop, spotlight }; - } -} - -function hasScreenShare(spotlight: MediaViewModel[]): boolean { - return spotlight.some((vm) => vm.type === "screen share"); -} - -function gridLayoutMedia( - spotlight: MediaViewModel[], - grid: UserMediaViewModel[], -): GridLayoutMedia { - return { - type: "grid", - edgeToEdge: false, - spotlight: hasScreenShare(spotlight) ? spotlight : undefined, - grid, - }; -} - -function oneOnOneDesktopLayoutMedia( - media: OneOnOneMedia, -): OneOnOneDesktopLayoutMedia { - return media.remote.type === "ringing" - ? { - type: "one-on-one-desktop", - edgeToEdge: false, - spotlight: media.local, - pip: media.remote, - } - : { - type: "one-on-one-desktop", - edgeToEdge: false, - spotlight: media.remote, - pip: media.local, - }; -} - -function spotlightExpandedLayoutMedia( - edgeToEdge: boolean, - spotlight: MediaViewModel[], - pip: UserMediaViewModel | undefined, -): SpotlightExpandedLayoutMedia { - return { type: "spotlight-expanded", edgeToEdge, spotlight, pip }; -} - -function spotlightLandscapeLayoutMedia( - edgeToEdge: boolean, - spotlight: MediaViewModel[], - grid: UserMediaViewModel[], -): SpotlightLandscapeLayoutMedia { - return { type: "spotlight-landscape", edgeToEdge, spotlight, grid }; -} - -function oneOnOneMobileLayoutMedia( - media: OneOnOneMedia, - localVideoEnabled: boolean, -): OneOnOneMobileLayoutMedia { - return { - type: "one-on-one-mobile", - edgeToEdge: true, - spotlight: media.remote, - pip: localVideoEnabled ? media.local : undefined, - }; -} - -function spotlightPortraitLayoutMedia( - spotlight: MediaViewModel[], - grid: UserMediaViewModel[], -): SpotlightPortraitLayoutMedia { - return { type: "spotlight-portrait", edgeToEdge: false, spotlight, grid }; -} diff --git a/src/state/layoutMedia.test.ts b/src/state/layoutMedia.test.ts index 8e164cc9a..afa159877 100644 --- a/src/state/layoutMedia.test.ts +++ b/src/state/layoutMedia.test.ts @@ -7,7 +7,12 @@ Please see LICENSE in the repository root for full details. import { describe, expect, it } from "vitest"; -import { chooseSpotlightSpeaker, computeSpotlight } from "./layoutMedia"; +import { + chooseSpotlightSpeaker, + computeLayoutMedia, + computeSpotlight, + type LayoutMediaInputs, +} from "./layoutMedia"; import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel"; import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel"; import { type RingingMediaViewModel } from "./media/RingingMediaViewModel"; @@ -110,3 +115,106 @@ describe("computeSpotlight", () => { ).toEqual({ spotlight: [local], pip: undefined }); }); }); + +describe("computeLayoutMedia", () => { + const base: LayoutMediaInputs = { + windowMode: "normal", + layoutMode: "grid", + spotlightExpanded: false, + oneOnOne: null, + localVideoEnabled: true, + spotlight: [alice], + grid: [local, alice, bob], + pip: local, + desktop: false, + }; + const layout = ( + overrides: Partial, + ): ReturnType => + computeLayoutMedia({ ...base, ...overrides }); + + it("uses a plain grid unless a screen share needs the spotlight", () => { + expect(layout({})).toEqual({ + type: "grid", + edgeToEdge: false, + spotlight: undefined, + grid: base.grid, + }); + expect(layout({ spotlight: [screenShare] }).spotlight).toEqual([ + screenShare, + ]); + }); + + it("switches between landscape and expanded spotlight", () => { + expect(layout({ layoutMode: "spotlight" })).toMatchObject({ + type: "spotlight-landscape", + edgeToEdge: false, + }); + expect( + layout({ layoutMode: "spotlight", spotlightExpanded: true }), + ).toEqual({ + type: "spotlight-expanded", + edgeToEdge: false, + spotlight: [alice], + pip: local, + }); + }); + + it("puts the remote user in the desktop one-on-one spotlight, or the local user when ringing", () => { + expect(layout({ oneOnOne: { local, remote: alice } })).toEqual({ + type: "one-on-one-desktop", + edgeToEdge: false, + spotlight: alice, + pip: local, + }); + expect(layout({ oneOnOne: { local, remote: ringing } })).toMatchObject({ + spotlight: local, + pip: ringing, + }); + }); + + it("uses a mobile one-on-one layout in narrow and flat windows, hiding the PiP without video", () => { + const oneOnOne = { local, remote: alice }; + expect(layout({ windowMode: "narrow", oneOnOne })).toEqual({ + type: "one-on-one-mobile", + edgeToEdge: true, + spotlight: alice, + pip: local, + }); + expect( + layout({ windowMode: "flat", oneOnOne, localVideoEnabled: false }), + ).toMatchObject({ type: "one-on-one-mobile", pip: undefined }); + }); + + it("uses a portrait spotlight in narrow windows for big calls or screen shares", () => { + expect(layout({ windowMode: "narrow" }).type).toBe("grid"); + expect( + layout({ windowMode: "narrow", grid: [local, alice, bob, carol] }).type, + ).toBe("spotlight-portrait"); + expect( + layout({ windowMode: "narrow", spotlight: [screenShare] }).type, + ).toBe("spotlight-portrait"); + }); + + it("uses edge-to-edge spotlight layouts in flat windows", () => { + expect(layout({ windowMode: "flat" })).toMatchObject({ + type: "spotlight-landscape", + edgeToEdge: true, + }); + expect( + layout({ windowMode: "flat", layoutMode: "spotlight" }), + ).toMatchObject({ + type: "spotlight-expanded", + edgeToEdge: true, + }); + }); + + it("uses the PiP layout, edge to edge except on desktop", () => { + expect(layout({ windowMode: "pip" })).toEqual({ + type: "pip", + edgeToEdge: true, + spotlight: [alice], + }); + expect(layout({ windowMode: "pip", desktop: true }).edgeToEdge).toBe(false); + }); +}); diff --git a/src/state/layoutMedia.ts b/src/state/layoutMedia.ts index 64365b3a8..56dec9979 100644 --- a/src/state/layoutMedia.ts +++ b/src/state/layoutMedia.ts @@ -5,11 +5,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import { type LayoutMode } from "./LayoutSwitchViewModel.ts"; +import { + type GridLayoutMedia, + type LayoutMedia, + type OneOnOneDesktopLayoutMedia, + type OneOnOneMobileLayoutMedia, + type SpotlightExpandedLayoutMedia, + type SpotlightLandscapeLayoutMedia, + type SpotlightPortraitLayoutMedia, + type WindowMode, +} from "./layout-types.ts"; +import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts"; import { type MediaViewModel } from "./media/MediaViewModel.ts"; import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts"; import { type ScreenShareViewModel } from "./media/ScreenShareViewModel.ts"; import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts"; +/** + * This is the number of participants that we think constitutes a "small" call + * on mobile. No spotlight tile should be shown below this threshold. + */ +const smallMobileCallThreshold = 3; + /** * Picks who to spotlight based on who is speaking, preferring the previous * pick so that the spotlight doesn't flip eagerly between speakers. @@ -66,3 +84,133 @@ export function computeSpotlight({ pip: localPip === speaker ? undefined : localPip, }; } + +export interface OneOnOneMedia { + local: LocalUserMediaViewModel; + remote: UserMediaViewModel | RingingMediaViewModel; +} + +export interface LayoutMediaInputs { + windowMode: WindowMode; + /** The layout chosen with the layout switch. */ + layoutMode: LayoutMode; + spotlightExpanded: boolean; + /** Set when the call qualifies for a one-on-one layout. */ + oneOnOne: OneOnOneMedia | null; + localVideoEnabled: boolean; + spotlight: MediaViewModel[]; + grid: UserMediaViewModel[]; + pip: UserMediaViewModel | undefined; + desktop: boolean; +} + +/** + * Decides which layout to use and which media goes where in it. + */ +export function computeLayoutMedia({ + windowMode, + layoutMode, + spotlightExpanded, + oneOnOne, + localVideoEnabled, + spotlight, + grid, + pip, + desktop, +}: LayoutMediaInputs): LayoutMedia { + switch (windowMode) { + case "normal": + if (layoutMode === "grid") + return oneOnOne === null + ? gridLayoutMedia(spotlight, grid) + : oneOnOneDesktopLayoutMedia(oneOnOne); + return spotlightExpanded + ? spotlightExpandedLayoutMedia(false, spotlight, pip) + : spotlightLandscapeLayoutMedia(false, spotlight, grid); + case "narrow": + if (oneOnOne !== null) + return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled); + return grid.length > smallMobileCallThreshold || hasScreenShare(spotlight) + ? spotlightPortraitLayoutMedia(spotlight, grid) + : gridLayoutMedia(spotlight, grid); + case "flat": + if (oneOnOne !== null) + return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled); + // Yes, grid mode actually gets you a "spotlight" layout in this window + // mode. + return layoutMode === "grid" + ? spotlightLandscapeLayoutMedia(true, spotlight, grid) + : spotlightExpandedLayoutMedia(true, spotlight, pip); + case "pip": + return { type: "pip", edgeToEdge: !desktop, spotlight }; + } +} + +function hasScreenShare(spotlight: MediaViewModel[]): boolean { + return spotlight.some((vm) => vm.type === "screen share"); +} + +function gridLayoutMedia( + spotlight: MediaViewModel[], + grid: UserMediaViewModel[], +): GridLayoutMedia { + return { + type: "grid", + edgeToEdge: false, + spotlight: hasScreenShare(spotlight) ? spotlight : undefined, + grid, + }; +} + +function oneOnOneDesktopLayoutMedia( + media: OneOnOneMedia, +): OneOnOneDesktopLayoutMedia { + return media.remote.type === "ringing" + ? { + type: "one-on-one-desktop", + edgeToEdge: false, + spotlight: media.local, + pip: media.remote, + } + : { + type: "one-on-one-desktop", + edgeToEdge: false, + spotlight: media.remote, + pip: media.local, + }; +} + +function spotlightExpandedLayoutMedia( + edgeToEdge: boolean, + spotlight: MediaViewModel[], + pip: UserMediaViewModel | undefined, +): SpotlightExpandedLayoutMedia { + return { type: "spotlight-expanded", edgeToEdge, spotlight, pip }; +} + +function spotlightLandscapeLayoutMedia( + edgeToEdge: boolean, + spotlight: MediaViewModel[], + grid: UserMediaViewModel[], +): SpotlightLandscapeLayoutMedia { + return { type: "spotlight-landscape", edgeToEdge, spotlight, grid }; +} + +function oneOnOneMobileLayoutMedia( + media: OneOnOneMedia, + localVideoEnabled: boolean, +): OneOnOneMobileLayoutMedia { + return { + type: "one-on-one-mobile", + edgeToEdge: true, + spotlight: media.remote, + pip: localVideoEnabled ? media.local : undefined, + }; +} + +function spotlightPortraitLayoutMedia( + spotlight: MediaViewModel[], + grid: UserMediaViewModel[], +): SpotlightPortraitLayoutMedia { + return { type: "spotlight-portrait", edgeToEdge: false, spotlight, grid }; +} From be822adec8e906d541bab5d4a05b0890912d4bab Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 2 Oct 2026 13:33:29 +0200 Subject: [PATCH 07/26] Allow workflow_call on lint and test workflows --- .github/workflows/lint.yaml | 7 +++++++ .github/workflows/test.yaml | 28 +++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index c2ba3b394..b0d0c50cc 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,6 +1,12 @@ name: Lint, format & type check on: pull_request: {} + workflow_call: + inputs: + matrix-js-sdk-sha: + type: string + required: false + description: The matrix-js-sdk commit to build against instead of the locked one jobs: lint: name: Lint, format & type check @@ -22,6 +28,7 @@ jobs: run: "scripts/layered.sh --ignore-pnpmfile" env: PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} + JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} - name: Formatting run: "pnpm run format:check" - name: i18n diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d29697e1d..1e11d09ea 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,6 +3,26 @@ on: pull_request: {} push: branches: [main] + # Lets matrix-js-sdk run these tests against one of its commits before merging it. + workflow_call: + inputs: + matrix-js-sdk-sha: + type: string + required: false + description: The matrix-js-sdk commit to build against instead of the locked one + disable_coverage: + type: boolean + required: false + default: false + description: Skip generating and uploading coverage + skip_e2e: + type: boolean + required: false + default: false + description: Skip the end-to-end tests + secrets: + CODECOV_TOKEN: + required: false jobs: vitest: name: Run unit tests @@ -24,6 +44,7 @@ jobs: run: "scripts/layered.sh --ignore-pnpmfile" env: PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} + JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} - name: Get Playwright version run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV - name: Cache Playwright binaries @@ -37,8 +58,11 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm playwright install --with-deps - name: Vitest - run: "pnpm run test:coverage" + run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE" + env: + ENABLE_COVERAGE: ${{ !inputs.disable_coverage }} - name: Upload to codecov + if: ${{ !inputs.disable_coverage }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -47,6 +71,7 @@ jobs: fail_ci_if_error: true playwright: name: Run end-to-end tests + if: ${{ !inputs.skip_e2e }} timeout-minutes: 60 runs-on: ubuntu-latest steps: @@ -64,6 +89,7 @@ jobs: run: scripts/layered.sh --ignore-pnpmfile env: PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} + JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }} - name: Get Playwright version run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV - name: Cache Playwright binaries From 4e8fb6e4cbf9a438138d5efc27a0b462e392f65a Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 2 Oct 2026 16:51:00 +0200 Subject: [PATCH 08/26] Redact analytics ID from rageshakes To improve our minimization of external data we collect in rageshakes. --- src/UrlParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 9d7ee3001..120d57533 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -373,7 +373,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { "intent:", intent, "\nproperties:", - redact(properties, "password"), + redact(properties, "password", "posthogUserId"), "configuration:", configuration, ); From bfb868aec3142de64365e5717371489ec0c0497b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:48:09 +0000 Subject: [PATCH 09/26] Update ghcr.io/element-hq/element-web:develop Docker digest to 5371503 (#4321) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 2e570de44..4e66d0434 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -19,7 +19,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:588f0b4ef516310331432e885920ed722bcafa577b1dd30429394c423d3e2848 + image: ghcr.io/element-hq/element-web:develop@sha256:537150301e6bcbd75de04102d05699e8e11b65f8c63d86ac6eea04315db015ed element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:588f0b4ef516310331432e885920ed722bcafa577b1dd30429394c423d3e2848 + image: ghcr.io/element-hq/element-web:develop@sha256:537150301e6bcbd75de04102d05699e8e11b65f8c63d86ac6eea04315db015ed From 8835a6d75d36f9494dee63f5221cc45348225bae Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:34:33 +0000 Subject: [PATCH 10/26] Update ghcr.io/element-hq/element-web:develop Docker digest to 3cf23ff (#4322) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 4e66d0434..85d54a35b 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -19,7 +19,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:537150301e6bcbd75de04102d05699e8e11b65f8c63d86ac6eea04315db015ed + image: ghcr.io/element-hq/element-web:develop@sha256:fae407f3cf6b39487332236620194c66ba468d643ca4ac0b40049c55e5a4d28c element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:537150301e6bcbd75de04102d05699e8e11b65f8c63d86ac6eea04315db015ed + image: ghcr.io/element-hq/element-web:develop@sha256:fae407f3cf6b39487332236620194c66ba468d643ca4ac0b40049c55e5a4d28c From 096aaac9f3c6e7373053848c385f1989f1d1e704 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:27:09 +0000 Subject: [PATCH 11/26] Update dependency @vector-im/compound-design-tokens to v11.1.0 --- pnpm-lock.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73a7796ba..23e2eaa3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,10 +132,10 @@ importers: version: 10.3.1(react@19.2.8) '@vector-im/compound-design-tokens': specifier: ^11.0.0 - version: 11.0.0(@types/react@19.2.18)(react@19.2.8) + version: 11.1.0(@types/react@19.2.18)(react@19.2.8) '@vector-im/compound-web': specifier: ^10.0.0 - version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@vitejs/plugin-react': specifier: ^6.0.2 version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) @@ -3223,8 +3223,8 @@ packages: peerDependencies: react: '>= 16.8.0' - '@vector-im/compound-design-tokens@11.0.0': - resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==} + '@vector-im/compound-design-tokens@11.1.0': + resolution: {integrity: sha512-vkMcy/G5VBlVle8umQNpctKHqZITS8XOQprsgcbSGRmwW080Z+TXV/vPsEXhgq8JKdxURkLhua0zQUufCeo46A==} peerDependencies: '@adobe/leonardo-contrast-colors': ^1.0.0 '@types/react': '*' @@ -8667,12 +8667,12 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.8 - '@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8)': + '@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8)': optionalDependencies: '@types/react': 19.2.18 react: 19.2.8 - '@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.0.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -8684,7 +8684,7 @@ snapshots: '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) - '@vector-im/compound-design-tokens': 11.0.0(@types/react@19.2.18)(react@19.2.8) + '@vector-im/compound-design-tokens': 11.1.0(@types/react@19.2.18)(react@19.2.8) classnames: 2.5.1 react: 19.2.8 vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) From 89567726f811e9c3934324a9e868f12db131c28c Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 5 Oct 2026 08:13:58 +0200 Subject: [PATCH 12/26] Fix tests --- playwright/component/harness.ts | 6 ++++++ src/button/ReactionToggleButton.module.css | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/playwright/component/harness.ts b/playwright/component/harness.ts index 8e1a6e589..19bc2e08b 100644 --- a/playwright/component/harness.ts +++ b/playwright/component/harness.ts @@ -92,6 +92,12 @@ export async function expectWithin( outer: Locator, ): Promise { await expect(inner).toBeVisible(); + // Wait for animations to settle. + await inner.evaluate(async (element) => { + for (const animation of element.getAnimations({ subtree: true })) + await animation.finished; + }); + const innerBox = await inner.boundingBox(); const outerBox = await outer.boundingBox(); if (innerBox === null || outerBox === null) diff --git a/src/button/ReactionToggleButton.module.css b/src/button/ReactionToggleButton.module.css index 4a2bd8ba8..0b152a519 100644 --- a/src/button/ReactionToggleButton.module.css +++ b/src/button/ReactionToggleButton.module.css @@ -83,7 +83,7 @@ div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div { flex: 0 0 auto; } -.reactionButton { +.reactionPopupMenu .reactionsMenu .reactionButton { padding: var(--reaction-button-padding); border-radius: var(--cpd-radius-pill-effect); font-size: var(--reaction-button-fontsize); From 427376f78bfb5f617507ba8450b3976be772a9e3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:47:57 +0000 Subject: [PATCH 13/26] Update ghcr.io/element-hq/element-web:develop Docker digest to 573daa2 (#4323) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 85d54a35b..633e0bf39 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -19,7 +19,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:fae407f3cf6b39487332236620194c66ba468d643ca4ac0b40049c55e5a4d28c + image: ghcr.io/element-hq/element-web:develop@sha256:4eb122c93fb593e551f090bab6342565e731357c8fe98fc2fcac3966ecf8d94b element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:fae407f3cf6b39487332236620194c66ba468d643ca4ac0b40049c55e5a4d28c + image: ghcr.io/element-hq/element-web:develop@sha256:4eb122c93fb593e551f090bab6342565e731357c8fe98fc2fcac3966ecf8d94b From 6d70221fe6e29bce63e902e58f512d9004bcb72b Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 5 Oct 2026 13:04:34 +0200 Subject: [PATCH 14/26] Flip boolean flags --- .github/workflows/test.yaml | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1e11d09ea..3a022a2b5 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,23 +3,22 @@ on: pull_request: {} push: branches: [main] - # Lets matrix-js-sdk run these tests against one of its commits before merging it. workflow_call: inputs: matrix-js-sdk-sha: type: string required: false description: The matrix-js-sdk commit to build against instead of the locked one - disable_coverage: + enable_coverage: type: boolean required: false - default: false - description: Skip generating and uploading coverage - skip_e2e: + default: true + description: Enable generating and uploading coverage reports + enable_e2e_tests: type: boolean required: false - default: false - description: Skip the end-to-end tests + default: true + description: Enable the end-to-end tests secrets: CODECOV_TOKEN: required: false @@ -60,9 +59,9 @@ jobs: - name: Vitest run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE" env: - ENABLE_COVERAGE: ${{ !inputs.disable_coverage }} + ENABLE_COVERAGE: ${{ inputs.enable_coverage }} - name: Upload to codecov - if: ${{ !inputs.disable_coverage }} + if: ${{ inputs.enable_coverage }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -71,7 +70,7 @@ jobs: fail_ci_if_error: true playwright: name: Run end-to-end tests - if: ${{ !inputs.skip_e2e }} + if: ${{ inputs.enable_e2e_tests }} timeout-minutes: 60 runs-on: ubuntu-latest steps: From 067f3d3038e85afe0a60a28800eb27e59cb89667 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 5 Oct 2026 13:44:48 +0200 Subject: [PATCH 15/26] Fix repo selection and flags --- .github/workflows/lint.yaml | 2 ++ .github/workflows/test.yaml | 10 +++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index b0d0c50cc..c59ba4024 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -15,6 +15,8 @@ jobs: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + # Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here. + repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }} persist-credentials: false - name: Enable Corepack run: corepack enable diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3a022a2b5..1ebe3529d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -30,6 +30,8 @@ jobs: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + # Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here. + repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }} persist-credentials: false - name: Enable Corepack run: corepack enable @@ -59,9 +61,9 @@ jobs: - name: Vitest run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE" env: - ENABLE_COVERAGE: ${{ inputs.enable_coverage }} + ENABLE_COVERAGE: ${{ inputs.enable_coverage != false }} - name: Upload to codecov - if: ${{ inputs.enable_coverage }} + if: ${{ inputs.enable_coverage != false }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -70,12 +72,14 @@ jobs: fail_ci_if_error: true playwright: name: Run end-to-end tests - if: ${{ inputs.enable_e2e_tests }} + if: ${{ inputs.enable_e2e_tests != false }} timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + # Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here. + repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }} persist-credentials: false - name: Enable Corepack run: corepack enable From 6e5f71c796b8f6e7832957eef30eb9a77ee9dc21 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 5 Oct 2026 13:53:52 +0200 Subject: [PATCH 16/26] Revert back to negative flags --- .github/workflows/test.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1ebe3529d..5e2703f67 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,16 +9,16 @@ on: type: string required: false description: The matrix-js-sdk commit to build against instead of the locked one - enable_coverage: + disable_coverage: type: boolean required: false - default: true - description: Enable generating and uploading coverage reports - enable_e2e_tests: + default: false + description: Skip generating and uploading coverage reports + disable_e2e_tests: type: boolean required: false - default: true - description: Enable the end-to-end tests + default: false + description: Skip the end-to-end tests secrets: CODECOV_TOKEN: required: false @@ -61,9 +61,9 @@ jobs: - name: Vitest run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE" env: - ENABLE_COVERAGE: ${{ inputs.enable_coverage != false }} + ENABLE_COVERAGE: ${{ !inputs.disable_coverage }} - name: Upload to codecov - if: ${{ inputs.enable_coverage != false }} + if: ${{ !inputs.disable_coverage }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} @@ -72,7 +72,7 @@ jobs: fail_ci_if_error: true playwright: name: Run end-to-end tests - if: ${{ inputs.enable_e2e_tests != false }} + if: ${{ !inputs.disable_e2e_tests }} timeout-minutes: 60 runs-on: ubuntu-latest steps: From 9b3c3569ecefbb79cf527ea24e3756ba50553a8f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:57:18 +0000 Subject: [PATCH 17/26] Update ghcr.io/element-hq/element-web:develop Docker digest to 39c94f0 (#4327) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 633e0bf39..bce7db34f 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -19,7 +19,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:4eb122c93fb593e551f090bab6342565e731357c8fe98fc2fcac3966ecf8d94b + image: ghcr.io/element-hq/element-web:develop@sha256:39c94f0c29e7c5614ac7264a872bb3643320158b3bf1730fc4742ddf672c27d6 element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:4eb122c93fb593e551f090bab6342565e731357c8fe98fc2fcac3966ecf8d94b + image: ghcr.io/element-hq/element-web:develop@sha256:39c94f0c29e7c5614ac7264a872bb3643320158b3bf1730fc4742ddf672c27d6 From 2faa6c3dac1a4a0f6d69da328fca8d78e0f71396 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:42:16 +0000 Subject: [PATCH 18/26] Update ghcr.io/element-hq/element-web:develop Docker digest to 589c490 (#4328) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index bce7db34f..45d405009 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -19,7 +19,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:39c94f0c29e7c5614ac7264a872bb3643320158b3bf1730fc4742ddf672c27d6 + image: ghcr.io/element-hq/element-web:develop@sha256:589c4909fd5844a9db7dd730f9796186eabbb981580cdf9bfa597186a2fb81f3 element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:39c94f0c29e7c5614ac7264a872bb3643320158b3bf1730fc4742ddf672c27d6 + image: ghcr.io/element-hq/element-web:develop@sha256:589c4909fd5844a9db7dd730f9796186eabbb981580cdf9bfa597186a2fb81f3 From b408610277cbf2bca2bcd7c9943d6dcda0d9d84e Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Wed, 7 Oct 2026 12:01:21 +0200 Subject: [PATCH 19/26] Remove livekit-client from the list of external dependencies --- README.md | 5 +++-- component/package.json | 1 - docs/agents/architecture.md | 4 +++- scripts/check-component-externals.mjs | 3 +-- vite-component.config.ts | 6 +++++- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5f1ed6bd2..2cbe2d127 100644 --- a/README.md +++ b/README.md @@ -306,9 +306,10 @@ directory target it rather than the repository; run them from the repository root. The host imports the component from `@element-hq/element-call-component` and the stylesheet from `@element-hq/element-call-component/style.css`, and has to provide `react`, -`react-dom`, `matrix-js-sdk`, `livekit-client`, `@vector-im/compound-web` and +`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and `@vector-im/compound-design-tokens` itself, since the bundle leaves them -external. Compound's stylesheet are the host's to load too: the component shares +external. +Compound's stylesheet are the host's to load too: the component shares the host's copy of Compound, (required for example for tooltips) The component is large, and a host will usually load it lazily, only once a diff --git a/component/package.json b/component/package.json index ee81cd51b..0d45eb4d7 100644 --- a/component/package.json +++ b/component/package.json @@ -40,7 +40,6 @@ "peerDependencies": { "@vector-im/compound-design-tokens": "^11.0.0", "@vector-im/compound-web": "^10.0.0", - "livekit-client": "^2.18.1", "matrix-js-sdk": "*", "react": "^19", "react-dom": "^19" diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md index fab783c19..59b0940e3 100644 --- a/docs/agents/architecture.md +++ b/docs/agents/architecture.md @@ -76,8 +76,10 @@ Code that builds in only one is a bug. - `build:component` — `@element-hq/element-call-component`, sources in `component/` (its own pnpm project; run pnpm from the repo root). Host API is in the README; `pnpm lint:externals` rejects an import of a `react` / `react-dom` / - `matrix-js-sdk` / `livekit-client` / `@vector-im/compound-web` / + `matrix-js-sdk` / `@vector-im/compound-web` / `@vector-im/compound-design-tokens` subpath the externals list omits. + `livekit-client` is bundled on purpose: no host imports it, and the pinned + version travels with the component. Compound's stylesheets are loaded by `src/index.css` for the page and by the host for the component, never by `src/base.css`. Two entry points: `index.tsx` (the component) and `api.ts` (types, enums and diff --git a/scripts/check-component-externals.mjs b/scripts/check-component-externals.mjs index 42d9235df..e088682d7 100644 --- a/scripts/check-component-externals.mjs +++ b/scripts/check-component-externals.mjs @@ -9,7 +9,7 @@ Please see LICENSE in the repository root for full details. * Checks that the component build leaves the packages a host must supply to * the host. * - * A host application already has React, the Matrix SDK, LiveKit and Compound, + * A host application already has React, the Matrix SDK and Compound, * and a second copy of any of them is worse than dead weight: React would hold * two sets of hooks, the Matrix client would run two sync loops, and a second * Compound would style the tooltips it floats into the host's body with class @@ -44,7 +44,6 @@ const MUST_BE_EXTERNAL = [ "react", "react-dom", "matrix-js-sdk", - "livekit-client", "@vector-im/compound-web", "@vector-im/compound-design-tokens", ]; diff --git a/vite-component.config.ts b/vite-component.config.ts index 93e661acd..5c4e01cb6 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -72,6 +72,11 @@ export default defineConfig(({ mode }) => { // host's Compound stylesheet only when the two happened to be the same // version. // + // LiveKit is deliberately not in this list. No host imports + // `livekit-client` itself, so bundling it duplicates nothing, and the + // exact version Element Call was tested against travels with it instead + // of being something every host has to pin and keep in step. + // // Every subpath has to be named. Element Call reaches most of the Matrix // SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not // catch those — while the pattern and callback forms of this option are @@ -88,7 +93,6 @@ export default defineConfig(({ mode }) => { "react/compiler-runtime", "react-dom", "react-dom/client", - "livekit-client", "matrix-js-sdk", "matrix-js-sdk/lib/browser-index", "matrix-js-sdk/lib/client", From 8b6adf067efd8fee95b807e72afc06b41ded3c97 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Wed, 7 Oct 2026 14:20:42 +0200 Subject: [PATCH 20/26] remove unnecassary (confused) comment --- vite-component.config.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/vite-component.config.ts b/vite-component.config.ts index 5c4e01cb6..573fb3729 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -72,11 +72,6 @@ export default defineConfig(({ mode }) => { // host's Compound stylesheet only when the two happened to be the same // version. // - // LiveKit is deliberately not in this list. No host imports - // `livekit-client` itself, so bundling it duplicates nothing, and the - // exact version Element Call was tested against travels with it instead - // of being something every host has to pin and keep in step. - // // Every subpath has to be named. Element Call reaches most of the Matrix // SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not // catch those — while the pattern and callback forms of this option are From a60036b3d729d583c73d15d8cc546ff13be7030b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:37:14 +0000 Subject: [PATCH 21/26] Update dependency postcss-selector-parser to v7.1.6 [SECURITY] --- pnpm-lock.yaml | 52 +++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23e2eaa3a..69805cfcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,7 +222,7 @@ importers: version: 10.6.1(postcss@8.5.26) postcss-selector-parser: specifier: ^7.1.1 - version: 7.1.3 + version: 7.1.6 posthog-js: specifier: 1.408.2 version: 1.408.2 @@ -5160,8 +5160,8 @@ packages: peerDependencies: postcss: ^8.4 - postcss-selector-parser@7.1.3: - resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} postcss-value-parser@4.2.0: @@ -6397,9 +6397,9 @@ snapshots: '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)': dependencies: - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6) postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)': dependencies: @@ -6505,9 +6505,9 @@ snapshots: '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)': dependencies: - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6) postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)': dependencies: @@ -6609,7 +6609,7 @@ snapshots: '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)': dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)': dependencies: @@ -6653,13 +6653,13 @@ snapshots: dependencies: postcss: 8.5.26 - '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.3)': + '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.6)': dependencies: - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 - '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.3)': + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.6)': dependencies: - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 '@csstools/utilities@2.0.0(postcss@8.5.26)': dependencies: @@ -9216,13 +9216,13 @@ snapshots: css-blank-pseudo@7.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 css-has-pseudo@7.0.3(postcss@8.5.26): dependencies: - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6) postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 postcss-value-parser: 4.2.0 css-prefers-color-scheme@10.0.0(postcss@8.5.26): @@ -10666,7 +10666,7 @@ snapshots: postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 postcss-clamp@4.1.0(postcss@8.5.26): dependencies: @@ -10717,12 +10717,12 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 postcss-dir-pseudo-class@9.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 postcss-double-position-gradients@6.0.4(postcss@8.5.26): dependencies: @@ -10734,12 +10734,12 @@ snapshots: postcss-focus-visible@10.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 postcss-focus-within@9.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 postcss-font-variant@5.0.0(postcss@8.5.26): dependencies: @@ -10771,10 +10771,10 @@ snapshots: postcss-nesting@13.0.2(postcss@8.5.26): dependencies: - '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.3) - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3) + '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.6) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6) postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 postcss-opacity-percentage@3.0.0(postcss@8.5.26): dependencies: @@ -10872,7 +10872,7 @@ snapshots: postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26): dependencies: @@ -10881,9 +10881,9 @@ snapshots: postcss-selector-not@8.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.3 + postcss-selector-parser: 7.1.6 - postcss-selector-parser@7.1.3: + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 From 3b7ccb63e243739576efabca96daf55b6f0df113 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:17:28 +0000 Subject: [PATCH 22/26] Update ghcr.io/element-hq/element-web:develop Docker digest to 53a4fe6 (#4334) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 45d405009..889b94bd2 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -19,7 +19,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:589c4909fd5844a9db7dd730f9796186eabbb981580cdf9bfa597186a2fb81f3 + image: ghcr.io/element-hq/element-web:develop@sha256:53a4fe6d60afc0647b95d9e1365d696679241821d63da94dfbfc33171a42ed6d element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:589c4909fd5844a9db7dd730f9796186eabbb981580cdf9bfa597186a2fb81f3 + image: ghcr.io/element-hq/element-web:develop@sha256:53a4fe6d60afc0647b95d9e1365d696679241821d63da94dfbfc33171a42ed6d From 53e038b09b4e2099caea8fecc5069f4fe6ef3036 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 8 Oct 2026 10:57:03 +0200 Subject: [PATCH 23/26] layoutShallowEquals -> shallowEquals --- src/state/CallViewModel/CallViewModel.ts | 8 +-- src/state/layout-types.test.ts | 74 ------------------------ src/state/layout-types.ts | 29 ---------- src/utils/object.test.ts | 40 +++++++++++++ src/utils/object.ts | 38 ++++++++++++ 5 files changed, 82 insertions(+), 107 deletions(-) delete mode 100644 src/state/layout-types.test.ts create mode 100644 src/utils/object.test.ts create mode 100644 src/utils/object.ts diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index ae6a54697..083666001 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -82,6 +82,7 @@ import { type ReactionOption, } from "../../reactions"; import { shallowEquals as shallowArrayEquals } from "../../utils/array"; +import { shallowEquals as shallowObjectEquals } from "../../utils/object"; import { type MediaDevices } from "../MediaDevices"; import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; @@ -91,7 +92,6 @@ import { HeaderStyle, type UrlParams } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { - layoutShallowEquals, type Alignment, type Layout, type LayoutMedia, @@ -1028,7 +1028,7 @@ export function createCallViewModel$( [ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$], (ringing, screenShares, speaker, localPip) => computeSpotlight({ ringing, screenShares, speaker, localPip }), - ).pipe(distinctUntilChanged(layoutShallowEquals)), + ).pipe(distinctUntilChanged(shallowObjectEquals)), ); const spotlight$ = scope.behavior( @@ -1185,7 +1185,7 @@ export function createCallViewModel$( desktop: platform === "desktop", }), ), - distinctUntilChanged(layoutShallowEquals), + distinctUntilChanged(shallowObjectEquals), ), ); @@ -1486,7 +1486,7 @@ export function createCallViewModel$( layoutInternals$.pipe( map(({ layout }) => layout), // Drop redundant layout updates before they would hit React. - distinctUntilChanged(layoutShallowEquals), + distinctUntilChanged(shallowObjectEquals), ), ); diff --git a/src/state/layout-types.test.ts b/src/state/layout-types.test.ts deleted file mode 100644 index 468c15bb5..000000000 --- a/src/state/layout-types.test.ts +++ /dev/null @@ -1,74 +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 { test, expect } from "vitest"; -import { - type Alignment, - layoutShallowEquals, - type Layout, -} from "./layout-types"; -import { - type SpotlightTileViewModel, - type GridTileViewModel, -} from "./TileViewModel"; -import { BehaviorSubject } from "rxjs"; - -const spotlightTile = {} as unknown as SpotlightTileViewModel; -const gridTile = {} as unknown as GridTileViewModel; -const pipAlignment$ = new BehaviorSubject({ - inline: "end", - block: "end", -}); - -const spotlightExpanded: Layout = { - type: "spotlight-expanded", - spotlight: spotlightTile, - pipAlignment$, -}; - -const spotlightPortrait: Layout = { - type: "spotlight-portrait", - spotlight: spotlightTile, - grid: [gridTile], - setVisibleTiles: () => {}, -}; - -test("layoutShallowEquals considers a layout to be equal to its shallow clone", () => - expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe( - true, - )); - -test("layoutShallowEquals detects a missing key", () => { - expect( - layoutShallowEquals(spotlightExpanded, { - ...spotlightExpanded, - pip: gridTile, - }), - ).toBe(false); - expect( - layoutShallowEquals( - { ...spotlightExpanded, pip: gridTile }, - spotlightExpanded, - ), - ).toBe(false); -}); - -test("layoutShallowEquals considers grid arrays with equal contents to be equal", () => - expect( - layoutShallowEquals(spotlightPortrait, { - ...spotlightPortrait, - grid: [...spotlightPortrait.grid], - }), - ).toBe(true)); - -test("layoutShallowEquals detects grid arrays with different contents", () => - expect( - layoutShallowEquals(spotlightPortrait, { - ...spotlightPortrait, - grid: [...spotlightPortrait.grid, gridTile], - }), - ).toBe(false)); diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index fd9b36a69..3d80b361e 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -16,7 +16,6 @@ import { type SpotlightTileViewModel, } from "./TileViewModel.ts"; import { type Behavior } from "./Behavior.ts"; -import { shallowEquals as arrayShallowEquals } from "../utils/array.ts"; /** * The general shape of the space the call is drawn in. Called a window because @@ -148,31 +147,3 @@ export type Layout = | OneOnOneDesktopLayout | OneOnOneMobileLayout | PipLayout; - -/** - * Tests whether the top-level properties and array elements of `a` are equal - * to those of `b`. Useful for deduping redundant layout and layout media - * updates. - */ -export function layoutShallowEquals(a: T, b: T): boolean { - // If a and b have the same number of keys and every key in a is also in b, - // then they have the same keys. - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) return false; - - for (const key of aKeys) { - if (!(key in b)) return false; - - // Now check that they have the same values. - const aValue = (a as any)[key]; - const bValue = (b as any)[key]; - if (Array.isArray(aValue) && Array.isArray(bValue)) { - // Special case for arrays so we can detect when the grid tiles arrays are - // essentially the same. - if (!arrayShallowEquals(aValue, bValue)) return false; - } else if (aValue !== bValue) return false; - } - - return true; -} diff --git a/src/utils/object.test.ts b/src/utils/object.test.ts new file mode 100644 index 000000000..6b55ac7f3 --- /dev/null +++ b/src/utils/object.test.ts @@ -0,0 +1,40 @@ +/* +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 { test, expect } from "vitest"; +import { shallowEquals } from "./object"; + +interface Thing { + type: string; + items: object[]; + value: number; + extra?: object; +} + +const item = {}; +const withArray: Thing = { type: "a", items: [item], value: 1 }; + +test("shallowEquals considers an object to be equal to its shallow clone", () => + expect(shallowEquals(withArray, { ...withArray })).toBe(true)); + +test("shallowEquals detects a missing key", () => { + expect(shallowEquals(withArray, { ...withArray, extra: item })).toBe(false); + expect(shallowEquals({ ...withArray, extra: item }, withArray)).toBe(false); +}); + +test("shallowEquals detects a differing value", () => + expect(shallowEquals(withArray, { ...withArray, value: 2 })).toBe(false)); + +test("shallowEquals considers arrays with equal contents to be equal", () => + expect( + shallowEquals(withArray, { ...withArray, items: [...withArray.items] }), + ).toBe(true)); + +test("shallowEquals detects arrays with different contents", () => + expect( + shallowEquals(withArray, { ...withArray, items: [...withArray.items, {}] }), + ).toBe(false)); diff --git a/src/utils/object.ts b/src/utils/object.ts new file mode 100644 index 000000000..7fc7b5b1d --- /dev/null +++ b/src/utils/object.ts @@ -0,0 +1,38 @@ +/* +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 { shallowEquals as arrayShallowEquals } from "./array.ts"; + +/** + * Determine whether two objects are equal by shallow comparison of their + * top-level properties. Array-valued properties are themselves compared + * shallowly, so two objects holding distinct arrays with the same elements are + * considered equal. + */ +export function shallowEquals( + a: Readonly, + b: Readonly, +): boolean { + // If a and b have the same number of keys and every key in a is also in b, + // then they have the same keys. + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (!(key in b)) return false; + + // Now check that they have the same values. + const aValue = (a as any)[key]; + const bValue = (b as any)[key]; + if (Array.isArray(aValue) && Array.isArray(bValue)) { + if (!arrayShallowEquals(aValue, bValue)) return false; + } else if (aValue !== bValue) return false; + } + + return true; +} From 4fd2f669490c94dbd604fbb6a8bab7cbadb9fdaa Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 8 Oct 2026 11:14:26 +0200 Subject: [PATCH 24/26] Explain why we use merge --- src/state/CallViewModel/CallViewModel.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 083666001..90c4cff3b 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -1164,6 +1164,16 @@ export function createCallViewModel$( * The media to be used to produce a layout. */ const layoutMedia$ = scope.behavior( + // We deliberately don't use combineLatest here. Several of the inputs + // below derive from the same source. So one upstream change cascades + // through them one at a time. combineLatest would see each step of the + // cascade and emit layouts built from inputs that never coexisted. + // Instead, we treat the emissions as a mere "something changed" signal + // and read every input's current value. The derived behaviors were + // subscribed before this one and a BehaviorSubject updates its value + // before notifying. So by the time the first signal arrives every value + // is already consistent. The duplicate signals from the rest of the + // cascade are dropped by distinctUntilChanged. merge( windowMode$, layoutSwitchVm.layout$, From aa8f73e632f8083628d5e1f8412b61716a52f027 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 8 Oct 2026 14:23:59 +0200 Subject: [PATCH 25/26] Fix Vite warning --- vite-component.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite-component.config.ts b/vite-component.config.ts index 573fb3729..fa6ce4168 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. import { defineConfig } from "vite"; import { vitePluginsConfig } from "./vite.config.ts"; -import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot"; +import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot.ts"; // Config for Element Call as a React component, to be imported by an // application embedding it rather than served as a page of its own. From 49c29f160a3b18ffe156f16d60f08b9a1f283757 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 24 Sep 2026 15:11:41 +0200 Subject: [PATCH 26/26] Add workflow for automated Claude review of pull requests --- .github/labels.yml | 3 ++ .github/workflows/claude.yaml | 90 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 .github/workflows/claude.yaml diff --git a/.github/labels.yml b/.github/labels.yml index 063787d96..0747a55b7 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -102,6 +102,9 @@ - name: "backport-candidate" description: "Something that is a candidate for backport to a particular release branch" color: "0B8D85" +- name: "Claude-Review" + description: "Request code review from Claude" + color: "e57157" - name: "customer-retainer" color: "F44A5F" - name: "dependencies" diff --git a/.github/workflows/claude.yaml b/.github/workflows/claude.yaml new file mode 100644 index 000000000..1ddbeadaa --- /dev/null +++ b/.github/workflows/claude.yaml @@ -0,0 +1,90 @@ +name: Claude +on: + pull_request: + types: [labeled] + +permissions: + # The action exchanges the GitHub OIDC token for a short-lived Anthropic + # access token. GitHub API access goes through CLAUDE_GH_TOKEN instead. + id-token: write + +jobs: + review: + name: Review + # Only run on labelling and skip forks. + if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # The review looks at the individual commits, not just the combined diff + fetch-depth: 0 + persist-credentials: false + - name: Review + uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245 + with: + anthropic_federation_rule_id: fdrl_01XQmWo4nka1ZTqZdsdeScyr + anthropic_organization_id: dfbc9a23-d636-4653-8970-035204ef60fa + anthropic_service_account_id: svac_018Ay5qaA7ZW8X7XsbdekqUM + anthropic_workspace_id: wrkspc_01Aw8LHjBS6fZysu9yqzYifW + # The token used here must grant read access to code, issues, and metadata as well as + # read and write access to pull requests. + github_token: ${{ secrets.CLAUDE_GH_TOKEN }} + use_sticky_comment: true + track_progress: true + prompt: | + Review this pull request under the following (and no further) angles: + + 1. Scope: + + - Does the PR do one thing? + - Is the PR free of unrelated fixes and drive-by refactorings? + - If the PR is large, could it reasonably be split into smaller PRs + that still deliver value? + + 2. Commits: + + - Is the work split into self-contained commits of reasonable size? + - Are the commits arranged in a sensible order? + - Have required refactorings been split out into separate commits? + - Is the history free of "wip", checkpoint and fixup-style commits + which only correct an earlier commit in the same PR? + + 3. Code: + + - Are the code changes functionally correct? + - Do the changes introduce new security vulnerabilities or metadata + leaks? + - Is the new code performant enough? + - Don't check anything that is already validated via other GitHub + workflows like linting, formatting or tests passing. + + Read the commits themselves with `git log` and `git diff` against + the base branch of the PR. + + Drop any findings that are outside the above review angles or that don't + require resolution. Grade the severity of each finding as either "🟡 low", + "🟠 medium" or "🔴 high". Devise at least one possible resolution for each + finding. + + Report the graded findings and resolutions from the "Code" angle as inline + comments. Add a footer "_-- Finding from automated review_" to each inline + comment. + + Finally, report your overall result as PR comment. Start your message with + "# Automated review". Then add one section per review angle with: + + - A verdict of "✅ PASS" or "❌ NEEDS WORK" + - A bullet list summarizing the remaining non-inlined graded findings and + resolutions (as nested bullets). + + Be brief and concise in all your communication. Do not mention what you have + not covered and don't bring up issues that have already been fixed. + + Use Markdown links for anything that can be navigated to on GitHub such + as files, commits, issues, pull requests, workflow runs, etc. + claude_args: | + --model claude-fable-5-1 + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git log:*),Bash(git diff:*),Bash(git show:*)" + --max-turns 50