From 1eec7314e8b9f8505f9089c07de7fbad93d52fc0 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 17 Oct 2025 11:22:23 -0400 Subject: [PATCH 1/5] Remove unnecessary lint suppressions --- src/state/CallViewModel.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index ef4ef762..f6a5892a 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -193,7 +193,6 @@ function summarizeLayout$(l$: Observable): Observable { l.spotlight?.media$ ?? constant(undefined), ...l.grid.map((vm) => vm.media$), ], - // eslint-disable-next-line rxjs/finnish -- false positive (spotlight, ...grid) => ({ type: l.type, spotlight: spotlight?.map((vm) => vm.id), @@ -213,7 +212,6 @@ function summarizeLayout$(l$: Observable): Observable { case "spotlight-expanded": return combineLatest( [l.spotlight.media$, l.pip?.media$ ?? constant(undefined)], - // eslint-disable-next-line rxjs/finnish -- false positive (spotlight, pip) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), From 414322e5d9a19782bff6d6ebab1704538cc2f73b Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 17 Oct 2025 11:23:34 -0400 Subject: [PATCH 2/5] Fix TileStore's ability to swap spotlight speakers without a layout shift This was regressed in 79c40f198cb662481df012594f8fdbbb67be63bd because of the overlooked renaming of the 'speaking' field to 'speaking$'. --- src/state/CallViewModel.test.ts | 15 +++++++++++++++ src/state/TileStore.ts | 9 +++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index f6a5892a..21a398d4 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -675,6 +675,21 @@ test("spotlight speakers swap places", () => { }, }, ); + + // While we expect the media on tiles to change, layout$ itself should + // *never* meaningfully change. That is, we expect there to be no layout + // shifts as the spotlight speaker changes; instead, the same tiles + // should be reused for the whole duration of the test and simply have + // their media swapped out. This is meaningful for keeping the interface + // not too visually distracting during back-and-forth conversations, + // while still animating tiles to express people joining, leaving, etc. + expectObservable( + vm.layout$.pipe( + distinctUntilChanged(deepCompare), + debounceTime(0), + map(() => "x"), + ), + ).toBe("x"); // Expect just one emission }, ); }); diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index 85bf8bc7..9465a709 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -118,10 +118,11 @@ export class TileStore { */ export class TileStoreBuilder { private spotlight: SpotlightTileData | null = null; - private readonly prevSpotlightSpeaker = + private readonly prevSpotlightSpeaker: UserMediaViewModel | null = this.prevSpotlight?.media.length === 1 && - "speaking" in this.prevSpotlight.media[0] && - this.prevSpotlight.media[0]; + "speaking$" in this.prevSpotlight.media[0] + ? this.prevSpotlight.media[0] + : null; private readonly prevGridByMedia: Map< MediaViewModel, @@ -201,7 +202,7 @@ export class TileStoreBuilder { if ( media === this.prevSpotlightSpeaker && this.spotlight.media.length === 1 && - "speaking" in this.spotlight.media[0] && + "speaking$" in this.spotlight.media[0] && this.prevSpotlightSpeaker !== this.spotlight.media[0] ) { const prev = this.prevGridByMedia.get(this.spotlight.media[0]); From a12b9ccbb4975ef62daa0fe51e490c6401d399a7 Mon Sep 17 00:00:00 2001 From: fkwp Date: Mon, 20 Oct 2025 09:35:50 +0200 Subject: [PATCH 3/5] Add another community guide (#3539) * add another community guide --- docs/self-hosting.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 051bb31c..7127abee 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -283,6 +283,7 @@ self-hosters and developers working with Element Call. - [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/) - [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/) - [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/) +- [MatrixRTC Back-End for Synapse with Docker Compose and Traefik](https://forge.avontech.net/kstro1/matrixrtc-docker-traefik/) ## 🛠️ Tools From 340265a838b99a9a09a78444390face6f7d34e68 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 17 Oct 2025 11:27:51 -0400 Subject: [PATCH 4/5] Enable the PiP tile in expanded spotlight layout to swap speakers without a layout shift This was apparently left unimplemented during the first iteration of the TileStore. It's a welcome UI optimization and we can reliably test for it. --- src/state/CallViewModel.test.ts | 84 ++++++++++++++++++++++++++-- src/state/SpotlightExpandedLayout.ts | 2 +- src/state/TileStore.ts | 27 +++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 21a398d4..0fb2b262 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -732,6 +732,84 @@ test("layout enters picture-in-picture mode when requested", () => { }); }); +test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + // Switch to spotlight immediately + const modeInputMarbles = " s"; + // And expand the spotlight immediately + const expandInputMarbles = " a"; + // First Bob speaks, then Dave, then Bob again + const bSpeakingInputMarbles = "n-yn--yn"; + const dSpeakingInputMarbles = "n---yn"; + // Should show Alice (presenter) in the PiP, then Bob, then Dave, then Bob + // again + const expectedLayoutMarbles = "a-b-c-b"; + + withCallViewModel( + { + remoteParticipants$: constant([ + aliceSharingScreen, + bobParticipant, + daveParticipant, + ]), + rtcMembers$: constant([ + localRtcMember, + aliceRtcMember, + bobRtcMember, + daveRtcMember, + ]), + speaking: new Map([ + [bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], + [daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], + ]), + }, + (vm) => { + schedule(modeInputMarbles, { + s: () => vm.setGridMode("spotlight"), + }); + schedule(expandInputMarbles, { + a: () => vm.toggleSpotlightExpanded$.value!(), + }); + + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "spotlight-expanded", + spotlight: [`${aliceId}:0:screen-share`], + pip: `${aliceId}:0`, + }, + b: { + type: "spotlight-expanded", + spotlight: [`${aliceId}:0:screen-share`], + pip: `${bobId}:0`, + }, + c: { + type: "spotlight-expanded", + spotlight: [`${aliceId}:0:screen-share`], + pip: `${daveId}:0`, + }, + }, + ); + + // While we expect the media on the PiP tile to change, layout$ itself + // should *never* meaningfully change. That is, we expect the same PiP + // tile to exist throughout the test and just have its media swapped out + // when the speaker changes, rather than for tiles to animate in/out. + // This is meaningful for keeping the interface not too visually + // distracting during back-and-forth conversations. + expectObservable( + vm.layout$.pipe( + distinctUntilChanged(deepCompare), + debounceTime(0), + map(() => "x"), + ), + ).toBe("x"); // Expect just one emission + }, + ); + }); +}); + test("spotlight remembers whether it's expanded", () => { withTestScheduler(({ schedule, expectObservable }) => { // Start in spotlight mode, then switch to grid and back to spotlight a @@ -754,11 +832,7 @@ test("spotlight remembers whether it's expanded", () => { g: () => vm.setGridMode("grid"), }); schedule(expandInputMarbles, { - a: () => { - let toggle: () => void; - vm.toggleSpotlightExpanded$.subscribe((val) => (toggle = val!)); - toggle!(); - }, + a: () => vm.toggleSpotlightExpanded$.value!(), }); expectObservable(summarizeLayout$(vm.layout$)).toBe( diff --git a/src/state/SpotlightExpandedLayout.ts b/src/state/SpotlightExpandedLayout.ts index 4baba0a1..4a427be7 100644 --- a/src/state/SpotlightExpandedLayout.ts +++ b/src/state/SpotlightExpandedLayout.ts @@ -20,7 +20,7 @@ export function spotlightExpandedLayout( ): [SpotlightExpandedLayout, TileStore] { const update = prevTiles.from(1); update.registerSpotlight(media.spotlight, true); - if (media.pip !== undefined) update.registerGridTile(media.pip); + if (media.pip !== undefined) update.registerPipTile(media.pip); const tiles = update.build(); return [ diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index 9465a709..ab97e4f0 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -261,6 +261,33 @@ export class TileStoreBuilder { this.numGridEntries++; } + /** + * Sets up a PiP tile for the given media. This is a special kind of grid tile + * that is expected to stand on its own and switch between speakers, so this + * method will more eagerly try to reuse an existing tile, replacing its + * media, than registerGridTile would. + */ + public registerPipTile(media: UserMediaViewModel): void { + if (DEBUG_ENABLED) + logger.debug( + `[TileStore, ${this.generation}] register PiP tile: ${media.member?.rawDisplayName ?? "[👻]"}`, + ); + + // If there is a single grid tile that we can reuse + if (this.prevGrid.length === 1) { + const entry = this.prevGrid[0]; + this.stationaryGridEntries[0] = entry; + // Do the media swap + entry.media = media; + this.prevGridByMedia.delete(entry.media); + this.prevGridByMedia.set(media, [entry, 0]); + } else { + this.visibleGridEntries.push(new GridTileData(media)); + } + + this.numGridEntries++; + } + /** * Constructs a new collection of all registered tiles, transferring ownership * of the tiles to the new collection. Any tiles present in the previous From 1a26a85a7888aeb0546ca3e4c37d0ebb4251b1bd Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 21 Oct 2025 13:22:20 -0400 Subject: [PATCH 5/5] Show that we've proved to TypeScript that the media is user media --- src/state/TileStore.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index ab97e4f0..7166dc80 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -203,7 +203,8 @@ export class TileStoreBuilder { media === this.prevSpotlightSpeaker && this.spotlight.media.length === 1 && "speaking$" in this.spotlight.media[0] && - this.prevSpotlightSpeaker !== this.spotlight.media[0] + this.prevSpotlightSpeaker !== + (this.spotlight.media[0] satisfies UserMediaViewModel) ) { const prev = this.prevGridByMedia.get(this.spotlight.media[0]); if (prev !== undefined) {