Factor out a layout switch view + view model

So it can easily be shown and hidden wholesale.
This commit is contained in:
Robin
2026-07-15 15:52:13 +02:00
parent 9b2d9fdd1f
commit a443a55bec
9 changed files with 207 additions and 178 deletions

View File

@@ -327,8 +327,8 @@ describe.each([
},
(vm) => {
schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"),
g: () => vm.setGridMode("grid"),
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
g: () => vm.layoutSwitchVm$.value!.setLayout("grid"),
});
expectObservable(summarizeLayout$(vm.layout$)).toBe(
@@ -815,7 +815,9 @@ describe.each([
]),
},
(vm) => {
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
schedule(modeInputMarbles, {
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
});
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
@@ -1021,7 +1023,7 @@ describe.each([
},
(vm) => {
schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"),
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
});
schedule(expandInputMarbles, {
a: () => vm.toggleSpotlightExpanded$.value!(),
@@ -1091,7 +1093,7 @@ describe.each([
},
(vm) => {
schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"),
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
});
schedule(expandInputMarbles, {
a: () => vm.toggleSpotlightExpanded$.value!(),
@@ -1131,7 +1133,7 @@ describe.each([
},
(vm) => {
schedule("s", {
s: () => vm.setGridMode("spotlight"),
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
});
schedule("a", {
a: () => vm.toggleSpotlightExpanded$.value!(),
@@ -1168,8 +1170,8 @@ describe.each([
},
(vm) => {
schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"),
g: () => vm.setGridMode("grid"),
s: () => vm.layoutSwitchVm$.value!.setLayout("spotlight"),
g: () => vm.layoutSwitchVm$.value!.setLayout("grid"),
});
schedule(expandInputMarbles, {
a: () => vm.toggleSpotlightExpanded$.value!(),
@@ -1235,7 +1237,7 @@ describe.each([
]),
},
(vm) => {
vm.setGridMode("grid");
vm.layoutSwitchVm$.value!.setLayout("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
@@ -1278,7 +1280,7 @@ describe.each([
}),
},
(vm) => {
vm.setGridMode("grid");
vm.layoutSwitchVm$.value!.setLayout("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{

View File

@@ -141,7 +141,10 @@ import {
} from "./remoteMembers/MatrixMemberMetadata.ts";
import { Publisher } from "./localMember/Publisher.ts";
import { type Connection } from "./remoteMembers/Connection.ts";
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
import {
type LayoutSwitchViewModel,
createLayoutSwitchViewModel,
} from "../LayoutSwitchViewModel.ts";
import {
createWrappedUserMedia,
type WrappedUserMediaViewModel,
@@ -201,8 +204,6 @@ const smallMobileCallThreshold = 3;
// with the interface
const showFooterMs = 4000;
export type GridMode = "grid" | "spotlight";
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
interface LayoutScanState {
@@ -349,8 +350,7 @@ export interface CallViewModel {
showNameTags$: Behavior<boolean>;
spotlightExpanded$: Behavior<boolean>;
toggleSpotlightExpanded$: Behavior<(() => void) | null>;
gridMode$: Behavior<GridMode>;
setGridMode: (value: GridMode) => void;
layoutSwitchVm$: Behavior<LayoutSwitchViewModel | null>;
// header/footer visibility
showHeader$: Behavior<boolean>;
@@ -1056,7 +1056,7 @@ export function createCallViewModel$(
spotlightExpandedToggle$,
);
const { setGridMode, gridMode$ } = createLayoutModeSwitch(
const layoutSwitchVm = createLayoutSwitchViewModel(
scope,
windowMode$,
hasRemoteScreenShares$,
@@ -1223,9 +1223,9 @@ export function createCallViewModel$(
switchMap((windowMode) => {
switch (windowMode) {
case "normal":
return gridMode$.pipe(
switchMap((gridMode) => {
switch (gridMode) {
return layoutSwitchVm.layout$.pipe(
switchMap((layout) => {
switch (layout) {
case "grid":
return oneOnOneDesktopLayoutMedia$.pipe(
switchMap((oneOnOne) =>
@@ -1260,9 +1260,9 @@ export function createCallViewModel$(
return oneOnOneMobileLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? gridMode$.pipe(
switchMap((gridMode) => {
switch (gridMode) {
? layoutSwitchVm.layout$.pipe(
switchMap((layout) => {
switch (layout) {
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
@@ -1775,8 +1775,7 @@ export function createCallViewModel$(
spotlightExpanded$: spotlightExpanded$,
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
gridMode$: gridMode$,
setGridMode: setGridMode,
layoutSwitchVm$: constant(layoutSwitchVm),
layout$: layout$,
localMatrixLivekitMember$,
remoteMatrixLivekitMembers$: scope.behavior(

View File

@@ -1,126 +0,0 @@
/*
Copyright 2025 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, test } from "vitest";
import { createLayoutModeSwitch } from "./LayoutSwitch";
import { testScope, withTestScheduler } from "../../utils/test";
function testLayoutSwitch({
windowMode = "n",
hasScreenShares = "n",
userSelection = "",
expectedGridMode,
}: {
windowMode?: string;
hasScreenShares?: string;
userSelection?: string;
expectedGridMode: string;
}): void {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
const { gridMode$, setGridMode } = createLayoutModeSwitch(
testScope(),
behavior(windowMode, { n: "normal", N: "narrow", f: "flat" }),
behavior(hasScreenShares, { y: true, n: false }),
);
schedule(userSelection, {
g: () => setGridMode("grid"),
s: () => setGridMode("spotlight"),
});
expectObservable(gridMode$).toBe(expectedGridMode, {
g: "grid",
s: "spotlight",
});
});
}
describe("default mode", () => {
test("uses grid layout in normal window", () =>
testLayoutSwitch({
windowMode: " n",
expectedGridMode: "g",
}));
test("uses grid layout in flat window", () =>
testLayoutSwitch({
windowMode: " f",
expectedGridMode: "g",
}));
});
test("allows switching modes manually", () =>
testLayoutSwitch({
userSelection: " --sgs",
expectedGridMode: "g-sgs",
}));
test("switches to spotlight mode when there is a remote screen share", () =>
testLayoutSwitch({
hasScreenShares: " n--y",
expectedGridMode: "g--s",
}));
test("can manually switch to grid when there is a screenshare", () =>
testLayoutSwitch({
hasScreenShares: " n-y",
userSelection: " ---g",
expectedGridMode: "g-sg",
}));
test("auto-switches after manually selecting grid", () =>
testLayoutSwitch({
// Two screenshares will happen in sequence. There is a screen share that
// forces spotlight, then the user manually switches back to grid.
hasScreenShares: " n-y-ny",
userSelection: " ---g",
expectedGridMode: "g-sg-s",
// If we did want to respect manual selection, the expectation would be: g-sg
}));
test("switches back to grid mode when the remote screen share ends", () =>
testLayoutSwitch({
hasScreenShares: " n--y--n",
expectedGridMode: "g--s--g",
}));
test("auto-switches to spotlight again after first screen share ends", () =>
testLayoutSwitch({
hasScreenShares: " nyny",
expectedGridMode: "gsgs",
}));
test("switches manually to grid after screen share while manually in spotlight", () =>
testLayoutSwitch({
// Initially, no one is sharing. Then the user manually switches to spotlight.
// After a screen share starts, the user manually switches to grid.
hasScreenShares: " n-y",
userSelection: " -s-g",
expectedGridMode: "gs-g",
}));
test("allows switching modes manually when in flat window mode", () =>
testLayoutSwitch({
// Window becomes flat, then user switches to spotlight and back.
// Finally the window returns to a normal shape.
windowMode: " nf--n",
userSelection: " --sg",
expectedGridMode: "g-sg",
}));
test("switches to grid when in flat window mode even when there are screen shares", () =>
testLayoutSwitch({
windowMode: " nf",
hasScreenShares: " y",
expectedGridMode: "sg",
}));
test("ignores screen share until window mode returns to normal", () =>
testLayoutSwitch({
windowMode: " f-n",
hasScreenShares: " ny-n",
expectedGridMode: "g-sg",
}));

View File

@@ -1,95 +0,0 @@
/*
Copyright 2025 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 {
combineLatest,
map,
Subject,
startWith,
skipWhile,
switchMap,
} from "rxjs";
import { type GridMode, type WindowMode } from "./CallViewModel.ts";
import { constant, type Behavior } from "../Behavior.ts";
import { type ObservableScope } from "../ObservableScope.ts";
/**
* Creates a layout mode switch that allows switching between grid and spotlight modes.
* The actual layout mode might switch automatically to spotlight if there is a
* remote screen share active or if the window mode is flat.
*
* @param scope - The observable scope to manage subscriptions.
* @param windowMode$ - The current window mode.
* @param hasRemoteScreenShares$ - A behavior indicating if there are remote screen shares active.
*/
export function createLayoutModeSwitch(
scope: ObservableScope,
windowMode$: Behavior<WindowMode>,
hasRemoteScreenShares$: Behavior<boolean>,
): {
gridMode$: Behavior<GridMode>;
setGridMode: (value: GridMode) => void;
} {
const userSelection$ = new Subject<GridMode>();
// Callback to set the grid mode desired by the user.
// Notice that this is only a preference, the actual grid mode can be overridden
// if there is a remote screen share active.
const setGridMode = (value: GridMode): void => userSelection$.next(value);
/**
* The natural grid mode - the mode that the grid would prefer to be in,
* not accounting for the user's manual selections.
*/
const naturalGridMode$ = scope.behavior<GridMode>(
combineLatest(
[hasRemoteScreenShares$, windowMode$],
(hasRemoteScreenShares, windowMode) => {
// When the window is flat (as with a phone in landscape orientation),
// grid mode is preferable as there's usually more than enough
// horizontal space to fit in some grid tiles on the side.
if (windowMode === "flat") return "grid";
// When there are screen shares, spotlight is a better experience. We
// want them to be big and readable.
return hasRemoteScreenShares ? "spotlight" : "grid";
},
),
);
/**
* The layout mode of the media tile grid.
*/
const gridMode$ = scope.behavior<GridMode>(
// Whenever the user makes a selection, we enter a new mode of behavior:
userSelection$.pipe(
map((selection) => {
if (selection === "grid")
// The user has selected grid mode. Start by respecting their choice,
// but then follow the natural mode again as soon as it matches.
return naturalGridMode$.pipe(
skipWhile((naturalMode) => naturalMode !== selection),
startWith(selection),
);
// The user has selected spotlight mode. If this matches the natural
// mode, then follow the natural mode going forward.
return selection === naturalGridMode$.value
? naturalGridMode$
: constant(selection);
}),
// Initially the mode of behavior is to just follow the natural grid mode.
startWith(naturalGridMode$),
// Switch between each mode of behavior.
switchMap((mode$) => mode$),
),
);
return {
gridMode$,
setGridMode,
};
}