From 83ea154e1a4c83d5e171a417e99ed4108f7f0192 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 2 Dec 2025 10:36:53 -0500 Subject: [PATCH] Fix the wrong layout being used until window size changes While looking into what had regressed https://github.com/element-hq/element-call/issues/3588, I found that 28047217b85e2e6f491c887ac7099499662fa46e had filled in a couple of behaviors with non-reactive default values, the "natural window mode" behavior being among them. This meant that the app would no longer determine the correct window mode upon joining a call, instead always guessing "normal" as the value. This change restores its reactivity. --- src/state/CallViewModel/CallViewModel.test.ts | 42 +++++++++++++++++++ src/state/CallViewModel/CallViewModel.ts | 19 ++++++--- .../CallViewModel/CallViewModelTestUtils.ts | 3 ++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index ef59270f..2e5b5700 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -502,6 +502,48 @@ describe("CallViewModel", () => { }); }); + test("layout reacts to window size", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + const windowSizeInputMarbles = "abc"; + const expectedLayoutMarbles = " abc"; + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember]), + windowSize$: behavior(windowSizeInputMarbles, { + a: { width: 300, height: 600 }, // Start very narrow, like a phone + b: { width: 1000, height: 800 }, // Go to normal desktop window size + c: { width: 200, height: 180 }, // Go to PiP size + }), + }, + (vm) => { + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + // This is the expected one-on-one layout for a narrow window + type: "spotlight-expanded", + spotlight: [`${aliceId}:0`], + pip: `${localId}:0`, + }, + b: { + // In a larger window, expect the normal one-on-one layout + type: "one-on-one", + local: `${localId}:0`, + remote: `${aliceId}:0`, + }, + c: { + // In a PiP-sized window, we of course expect a PiP layout + type: "pip", + spotlight: [`${aliceId}:0`], + }, + }, + ); + }, + ); + }); + }); + test("spotlight speakers swap places", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { // Go immediately into spotlight mode for the test diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 3c15958a..c8f5d836 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -147,6 +147,8 @@ export interface CallViewModelOptions { livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom; /** Optional behavior overriding the local connection state, mainly for testing purposes. */ connectionState$?: Behavior; + /** Optional behavior overriding the computed window size, mainly for testing purposes. */ + windowSize$?: Behavior<{ width: number; height: number }>; } // Do not play any sounds if the participant count has exceeded this @@ -949,11 +951,19 @@ export function createCallViewModel$( const pipEnabled$ = scope.behavior(setPipEnabled$, false); + const windowSize$ = + options.windowSize$ ?? + scope.behavior<{ width: number; height: number }>( + fromEvent(window, "resize").pipe( + startWith(null), + map(() => ({ width: window.innerWidth, height: window.innerHeight })), + ), + ); + + // A guess at what the window's mode should be based on its size and shape. const naturalWindowMode$ = scope.behavior( - fromEvent(window, "resize").pipe( - map(() => { - const height = window.innerHeight; - const width = window.innerWidth; + windowSize$.pipe( + map(({ width, height }) => { if (height <= 400 && width <= 340) return "pip"; // Our layouts for flat windows are better at adapting to a small width // than our layouts for narrow windows are at adapting to a small height, @@ -963,7 +973,6 @@ export function createCallViewModel$( return "normal"; }), ), - "normal", ); /** diff --git a/src/state/CallViewModel/CallViewModelTestUtils.ts b/src/state/CallViewModel/CallViewModelTestUtils.ts index f86921c5..f80b4bcb 100644 --- a/src/state/CallViewModel/CallViewModelTestUtils.ts +++ b/src/state/CallViewModel/CallViewModelTestUtils.ts @@ -75,6 +75,7 @@ export interface CallViewModelInputs { speaking: Map>; mediaDevices: MediaDevices; initialSyncState: SyncState; + windowSize$: Behavior<{ width: number; height: number }>; } const localParticipant = mockLocalParticipant({ identity: "" }); @@ -89,6 +90,7 @@ export function withCallViewModel( speaking = new Map(), mediaDevices = mockMediaDevices({}), initialSyncState = SyncState.Syncing, + windowSize$ = constant({ width: 1000, height: 800 }), }: Partial = {}, continuation: ( vm: CallViewModel, @@ -173,6 +175,7 @@ export function withCallViewModel( setE2EEEnabled: async () => Promise.resolve(), }), connectionState$, + windowSize$, }, raisedHands$, reactions$,