From a443a55bec573dd7bcd012a047ed14f91ae7f0ed Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 15 Jul 2026 15:52:13 +0200 Subject: [PATCH 1/3] Factor out a layout switch view + view model So it can easily be shown and hidden wholesale. --- src/components/CallFooter.stories.tsx | 95 ++++++++++--------- src/components/CallFooter.tsx | 33 ++----- src/components/CallFooterViewModel.tsx | 13 +-- src/room/LayoutSwitch.tsx | 43 +++++++++ .../__snapshots__/InCallView.test.tsx.snap | 4 +- src/state/CallViewModel/CallViewModel.test.ts | 22 +++-- src/state/CallViewModel/CallViewModel.ts | 27 +++--- ....test.ts => LayoutSwitchViewModel.test.ts} | 76 +++++++-------- ...youtSwitch.ts => LayoutSwitchViewModel.ts} | 72 +++++++------- 9 files changed, 207 insertions(+), 178 deletions(-) create mode 100644 src/room/LayoutSwitch.tsx rename src/state/{CallViewModel/LayoutSwitch.test.ts => LayoutSwitchViewModel.test.ts} (65%) rename src/state/{CallViewModel/LayoutSwitch.ts => LayoutSwitchViewModel.ts} (52%) diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index a6b509fab..f95751094 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -16,10 +16,12 @@ import inCallViewStyles from "../room/InCallView.module.css"; import { useStaticViewModel } from "../state/ViewModel"; import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { type ReactionOption } from "../reactions"; -import { type GridMode } from "../state/CallViewModel/CallViewModel"; import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevices } from "../state/MediaDevices"; import { globalScope } from "../state/ObservableScope"; +import { constant } from "../state/Behavior"; +import { type LayoutMode } from "../state/LayoutSwitchViewModel"; + // consts for tests const reactionIdentifier = "@user:example.com:DEVICE"; const reactionData = { @@ -32,6 +34,7 @@ const mediaDevices = new MediaDevices(globalScope); /** * A wrapper component that is used for: * - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm) + * - constructing the layout switch view model * - Add additional react context * The paraeters are all params from the FooterSnapshot, * the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter. @@ -40,11 +43,18 @@ const mediaDevices = new MediaDevices(globalScope); */ function CallFooterStoryWrapper({ children, + layout, + setLayout, ...vmSnapshot -}: FooterSnapshot & { +}: Omit & { children?: false | JSX.Element | JSX.Element[] | undefined; + layout: LayoutMode | null; + setLayout: (value: LayoutMode) => void; }): ReactNode { - const vm = useStaticViewModel(vmSnapshot); + const vm = useStaticViewModel({ + ...vmSnapshot, + layoutSwitchVm: layout && { layout$: constant(layout), setLayout }, + }); return (
@@ -62,28 +72,50 @@ function CallFooterStoryWrapper({ ); } -const meta = { - component: CallFooterStoryWrapper, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - const fnArgType = { control: { type: "select" as const }, options: ["MockedCallback", "undefined"], mapping: { MockedCallback: fn(), undefined: undefined }, }; +const meta = { + component: CallFooterStoryWrapper, + argTypes: { + layout: { + control: "radio", + options: ["grid", "spotlight"] satisfies LayoutMode[], + }, + audioOutputSwitcher: { + control: "select", + options: ["NoOutputCallback", "speaker", "earpiece"], + table: { defaultValue: { summary: "NoOutputCallback" } }, + mapping: { + NoOutputCallback: undefined, + // This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one. + speaker: { targetOutput: "earpiece", switch: fn() }, + earpiece: { targetOutput: "speaker", switch: fn() }, + }, + }, + toggleScreenSharing: fnArgType, + openSettings: fnArgType, + toggleAudio: fnArgType, + toggleVideo: fnArgType, + hangup: fnArgType, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + export const Default: Story = { args: { showLogo: false, - layoutMode: "grid", + layout: "grid", + setLayout: fn(), audioEnabled: true, audioBusy: false, videoEnabled: true, videoBusy: false, - setLayoutMode: fn(), openSettings: fn(), toggleAudio: fn(), toggleVideo: fn(), @@ -111,29 +143,6 @@ export const Default: Story = { parameters: { layout: "fullscreen", }, - argTypes: { - layoutMode: { - control: "radio", - options: ["grid", "spotlight"] satisfies GridMode[], - }, - audioOutputSwitcher: { - control: "select", - options: ["NoOutputCallback", "speaker", "earpiece"], - table: { defaultValue: { summary: "NoOutputCallback" } }, - mapping: { - NoOutputCallback: undefined, - // This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one. - speaker: { targetOutput: "earpiece", switch: fn() }, - earpiece: { targetOutput: "speaker", switch: fn() }, - }, - }, - toggleScreenSharing: fnArgType, - setLayoutMode: fnArgType, - openSettings: fnArgType, - toggleAudio: fnArgType, - toggleVideo: fnArgType, - hangup: fnArgType, - }, }; export const WithAudioAndVideoOptions: Story = { @@ -194,7 +203,7 @@ export const AudioVideoEnabled: Story = { const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" }); await userEvent.click(spotlightRadio); - await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight"); + await expect(args.setLayout).toHaveBeenCalledWith("spotlight"); const micButtonMute = canvas.getByRole("switch", { name: "Mute microphone", @@ -225,14 +234,14 @@ export const SpotlightMode: Story = { ...Default, args: { ...Default.args, - layoutMode: "spotlight", + layout: "spotlight", }, play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); const spotlightRadio = canvas.getByRole("radio", { name: "Grid" }); await userEvent.click(spotlightRadio); - await expect(args.setLayoutMode).toHaveBeenCalledWith("grid"); + await expect(args.setLayout).toHaveBeenCalledWith("grid"); }, }; @@ -264,7 +273,7 @@ export const Pip: Story = { args: { ...Default.args, buttonSize: "md", - layoutMode: undefined, + layout: null, }, play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); @@ -348,7 +357,7 @@ export const Lobby: Story = { ...Default.args, showLogo: false, openSettings: undefined, - setLayoutMode: undefined, + layout: null, toggleScreenSharing: undefined, }, parameters: { @@ -362,7 +371,7 @@ export const LobbyMobile: Story = { ...Default.args, showLogo: false, - setLayoutMode: undefined, + layout: null, toggleScreenSharing: undefined, }, globals: { @@ -379,7 +388,7 @@ export const LobbyRecentButton: Story = { ...Default.args, children: Back To Recents, showLogo: false, - setLayoutMode: undefined, + layout: null, toggleScreenSharing: undefined, }, parameters: { @@ -393,7 +402,7 @@ export const LobbyRecentButtonMobile: Story = { ...Default.args, children: Back To Recents, showLogo: false, - setLayoutMode: undefined, + layout: null, toggleScreenSharing: undefined, }, globals: { diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index f2aa5168b..f5c54ee38 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -7,12 +7,6 @@ Please see LICENSE in the repository root for full details. import { type FC, type JSX, type Ref, useMemo } from "react"; import classNames from "classnames"; -import { - SpotlightViewIcon, - GridIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; -import { Switch } from "@vector-im/compound-web"; -import { t } from "i18next"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -28,13 +22,14 @@ import { type ReactionData, } from "../button"; import styles from "./CallFooter.module.css"; -import { type GridMode } from "../state/CallViewModel/CallViewModel"; import { MediaMuteAndSwitchButton, type MenuOptions, } from "./MediaMuteAndSwitchButton"; import { type ViewModel } from "../state/ViewModel"; import { useBehavior } from "../useBehavior"; +import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel"; +import { LayoutSwitch } from "../room/LayoutSwitch"; export interface AudioOutputSwitcher { targetOutput: string; @@ -61,8 +56,6 @@ export interface FooterActions { /** Also controls if the videoMute button is disabled */ toggleVideo: (() => void) | undefined; toggleBlur: (() => void) | undefined; - /** Also controls if the layout button is visible */ - setLayoutMode: ((mode: GridMode) => void) | undefined; toggleScreenSharing: (() => void) | undefined; /** Also controls if the settings button is visible */ openSettings: (() => void) | undefined; @@ -87,7 +80,8 @@ export interface FooterState { buttonSize: "md" | "lg"; showLogo: boolean; - layoutMode: GridMode | undefined; + /** Also controls if the layout switch is visible */ + layoutSwitchVm: LayoutSwitchViewModel | null; sharingScreen: boolean; @@ -126,8 +120,7 @@ export const CallFooter: FC = ({ const asOverlay = useBehavior(vm.asOverlay$); const showFooter = useBehavior(vm.showFooter$); const hideControls = useBehavior(vm.hideControls$); - const layoutMode = useBehavior(vm.layoutMode$); - const setLayoutMode = useBehavior(vm.setLayoutMode$); + const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$); const openSettings = useBehavior(vm.openSettings$); const audioEnabled = useBehavior(vm.audioEnabled$); const audioBusy = useBehavior(vm.audioBusy$); @@ -317,20 +310,8 @@ export const CallFooter: FC = ({ {(showLogo || debugTileLayout) && logoDebugContainer}
{!hideControls &&
{buttons}
} - {!hideControls && setLayoutMode && layoutMode && ( - - name="layoutMode" - aria-label={t("layout_switch_label")} - leftLabel={t("layout_spotlight_label")} - leftValue="spotlight" - leftIcon={SpotlightViewIcon} - rightLabel={t("layout_grid_label")} - rightValue="grid" - rightIcon={GridIcon} - className={styles.layout} - value={layoutMode} - onChange={setLayoutMode} - /> + {!hideControls && layoutSwitchVm && ( + )} ); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index 2f64cc83b..a374a0258 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -182,14 +182,7 @@ export function createCallFooterViewModel( showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))), - layoutMode$: callModel.gridMode$, - setLayoutMode$: scope.behavior( - isPip$.pipe( - map((isPip) => - !isPip && showControls ? callModel.setGridMode : undefined, - ), - ), - ), + layoutSwitchVm$: callModel.layoutSwitchVm$, sharingScreen$: callModel.sharingScreen$, toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined), @@ -247,20 +240,18 @@ export function createLobbyFooterViewModel( hideControls: false, asOverlay: false, buttonSize: "lg", - showLayoutSwitcher: false, openSettings, hangup, debugTileLayout: false, showFooter: true, toggleAudio: undefined, toggleVideo: undefined, - setLayoutMode: undefined, toggleScreenSharing: undefined, audioEnabled: undefined, audioBusy: false, videoEnabled: undefined, videoBusy: false, - layoutMode: undefined, + layoutSwitchVm: null, sharingScreen: false, audioOutputSwitcher: undefined, reactionIdentifier: undefined, diff --git a/src/room/LayoutSwitch.tsx b/src/room/LayoutSwitch.tsx new file mode 100644 index 000000000..80995a35c --- /dev/null +++ b/src/room/LayoutSwitch.tsx @@ -0,0 +1,43 @@ +/* +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 { type FC } from "react"; +import { + SpotlightViewIcon, + GridIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel"; +import { useBehavior } from "../useBehavior"; +import { useTranslation } from "react-i18next"; +import { Switch } from "@vector-im/compound-web"; + +interface Props { + vm: LayoutSwitchViewModel; + className?: string; +} + +export const LayoutSwitch: FC = ({ vm, className }) => { + const { t } = useTranslation(); + const layout = useBehavior(vm.layout$); + + return ( + + name="layout" + aria-label={t("layout_switch_label")} + leftLabel={t("layout_spotlight_label")} + leftValue="spotlight" + leftIcon={SpotlightViewIcon} + rightLabel={t("layout_grid_label")} + rightValue="grid" + rightIcon={GridIcon} + className={className} + value={layout} + onChange={vm.setLayout} + /> + ); +}; diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 5499623d1..8a805668d 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -433,7 +433,7 @@ exports[`InCallView > rendering > renders 1`] = ` > @@ -454,7 +454,7 @@ exports[`InCallView > rendering > renders 1`] = ` diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 73b657cb0..0f77a2db9 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -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, { diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 7b94e9ff2..5a066453e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -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; spotlightExpanded$: Behavior; toggleSpotlightExpanded$: Behavior<(() => void) | null>; - gridMode$: Behavior; - setGridMode: (value: GridMode) => void; + layoutSwitchVm$: Behavior; // header/footer visibility showHeader$: Behavior; @@ -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( diff --git a/src/state/CallViewModel/LayoutSwitch.test.ts b/src/state/LayoutSwitchViewModel.test.ts similarity index 65% rename from src/state/CallViewModel/LayoutSwitch.test.ts rename to src/state/LayoutSwitchViewModel.test.ts index ea533ec7e..d230408c3 100644 --- a/src/state/CallViewModel/LayoutSwitch.test.ts +++ b/src/state/LayoutSwitchViewModel.test.ts @@ -7,31 +7,31 @@ 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"; +import { createLayoutSwitchViewModel } from "./LayoutSwitchViewModel"; +import { testScope, withTestScheduler } from "../utils/test"; function testLayoutSwitch({ windowMode = "n", hasScreenShares = "n", userSelection = "", - expectedGridMode, + expectedLayout, }: { windowMode?: string; hasScreenShares?: string; userSelection?: string; - expectedGridMode: string; + expectedLayout: string; }): void { withTestScheduler(({ behavior, schedule, expectObservable }) => { - const { gridMode$, setGridMode } = createLayoutModeSwitch( + const { layout$, setLayout } = createLayoutSwitchViewModel( testScope(), behavior(windowMode, { n: "normal", N: "narrow", f: "flat" }), behavior(hasScreenShares, { y: true, n: false }), ); schedule(userSelection, { - g: () => setGridMode("grid"), - s: () => setGridMode("spotlight"), + g: () => setLayout("grid"), + s: () => setLayout("spotlight"), }); - expectObservable(gridMode$).toBe(expectedGridMode, { + expectObservable(layout$).toBe(expectedLayout, { g: "grid", s: "spotlight", }); @@ -41,86 +41,86 @@ function testLayoutSwitch({ describe("default mode", () => { test("uses grid layout in normal window", () => testLayoutSwitch({ - windowMode: " n", - expectedGridMode: "g", + windowMode: " n", + expectedLayout: "g", })); test("uses grid layout in flat window", () => testLayoutSwitch({ - windowMode: " f", - expectedGridMode: "g", + windowMode: " f", + expectedLayout: "g", })); }); test("allows switching modes manually", () => testLayoutSwitch({ - userSelection: " --sgs", - expectedGridMode: "g-sgs", + userSelection: " --sgs", + expectedLayout: "g-sgs", })); test("switches to spotlight mode when there is a remote screen share", () => testLayoutSwitch({ - hasScreenShares: " n--y", - expectedGridMode: "g--s", + hasScreenShares: "n--y", + expectedLayout: " g--s", })); test("can manually switch to grid when there is a screenshare", () => testLayoutSwitch({ - hasScreenShares: " n-y", - userSelection: " ---g", - expectedGridMode: "g-sg", + hasScreenShares: "n-y", + userSelection: " ---g", + expectedLayout: " 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", + hasScreenShares: "n-y-ny", + userSelection: " ---g", + expectedLayout: " 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", + hasScreenShares: "n--y--n", + expectedLayout: " g--s--g", })); test("auto-switches to spotlight again after first screen share ends", () => testLayoutSwitch({ - hasScreenShares: " nyny", - expectedGridMode: "gsgs", + hasScreenShares: "nyny", + expectedLayout: " 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", + hasScreenShares: "n-y", + userSelection: " -s-g", + expectedLayout: " 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", + windowMode: " nf--n", + userSelection: " --sg", + expectedLayout: "g-sg", })); test("switches to grid when in flat window mode even when there are screen shares", () => testLayoutSwitch({ - windowMode: " nf", - hasScreenShares: " y", - expectedGridMode: "sg", + windowMode: " nf", + hasScreenShares: "y", + expectedLayout: " sg", })); test("ignores screen share until window mode returns to normal", () => testLayoutSwitch({ - windowMode: " f-n", - hasScreenShares: " ny-n", - expectedGridMode: "g-sg", + windowMode: " f-n", + hasScreenShares: "ny-n", + expectedLayout: " g-sg", })); diff --git a/src/state/CallViewModel/LayoutSwitch.ts b/src/state/LayoutSwitchViewModel.ts similarity index 52% rename from src/state/CallViewModel/LayoutSwitch.ts rename to src/state/LayoutSwitchViewModel.ts index 6ad200f15..6e61abaaa 100644 --- a/src/state/CallViewModel/LayoutSwitch.ts +++ b/src/state/LayoutSwitchViewModel.ts @@ -11,15 +11,25 @@ import { Subject, startWith, skipWhile, - switchMap, + switchAll, } from "rxjs"; -import { type GridMode, type WindowMode } from "./CallViewModel.ts"; -import { constant, type Behavior } from "../Behavior.ts"; -import { type ObservableScope } from "../ObservableScope.ts"; +import { type WindowMode } from "./CallViewModel/CallViewModel.ts"; +import { constant, type Behavior } from "./Behavior.ts"; +import { type ObservableScope } from "./ObservableScope.ts"; + +export type LayoutMode = "spotlight" | "grid"; + +export interface LayoutSwitchViewModel { + /** + * The layout mode of the call's media tiles. + */ + layout$: Behavior; + setLayout: (value: LayoutMode) => void; +} /** - * Creates a layout mode switch that allows switching between grid and spotlight modes. + * Creates a layout mode switch that allows switching between grid and spotlight layouts. * The actual layout mode might switch automatically to spotlight if there is a * remote screen share active or if the window mode is flat. * @@ -27,31 +37,28 @@ import { type ObservableScope } from "../ObservableScope.ts"; * @param windowMode$ - The current window mode. * @param hasRemoteScreenShares$ - A behavior indicating if there are remote screen shares active. */ -export function createLayoutModeSwitch( +export function createLayoutSwitchViewModel( scope: ObservableScope, windowMode$: Behavior, hasRemoteScreenShares$: Behavior, -): { - gridMode$: Behavior; - setGridMode: (value: GridMode) => void; -} { - const userSelection$ = new Subject(); - // Callback to set the grid mode desired by the user. - // Notice that this is only a preference, the actual grid mode can be overridden +): LayoutSwitchViewModel { + const userSelection$ = new Subject(); + // Callback to set the layout desired by the user. + // Notice that this is only a preference, the actual layout can be overridden // if there is a remote screen share active. - const setGridMode = (value: GridMode): void => userSelection$.next(value); + const setLayout = (value: LayoutMode): void => userSelection$.next(value); /** - * The natural grid mode - the mode that the grid would prefer to be in, + * The natural layout - the layout that the interface would prefer to be in, * not accounting for the user's manual selections. */ - const naturalGridMode$ = scope.behavior( + const naturalLayout$ = scope.behavior( 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. + // grid 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. @@ -61,35 +68,32 @@ export function createLayoutModeSwitch( ); /** - * The layout mode of the media tile grid. + * The layout mode of the call's media tiles. */ - const gridMode$ = scope.behavior( + const layout$ = scope.behavior( // 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( + // The user has selected grid. Start by respecting their choice, but + // then follow the natural mode again as soon as it matches. + return naturalLayout$.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$ + // The user has selected spotlight. If this matches the natural layout, + // then follow the natural layout going forward. + return selection === naturalLayout$.value + ? naturalLayout$ : constant(selection); }), - // Initially the mode of behavior is to just follow the natural grid mode. - startWith(naturalGridMode$), + // Initially the mode of behavior is to just follow the natural layout. + startWith(naturalLayout$), // Switch between each mode of behavior. - switchMap((mode$) => mode$), + switchAll(), ), ); - return { - gridMode$, - setGridMode, - }; + return { layout$, setLayout }; } From 5a96d1769ed0a51699397b8e9b40e7fe56bbc57b Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 27 Jul 2026 18:19:01 +0200 Subject: [PATCH 2/3] Only show layout switch when it has an effect on the layout --- src/state/CallViewModel/CallViewModel.test.ts | 13 ++- src/state/CallViewModel/CallViewModel.ts | 82 ++++++++++++------- 2 files changed, 62 insertions(+), 33 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 0f77a2db9..7933039f3 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -536,11 +536,15 @@ describe.each([ withTestScheduler(({ behavior, expectObservable }) => { // Starts as a one-on-one call, then Alice shares her screen, then Bob // joins, and finally Alice stops sharing her screen - const participantInputMarbles = " a--b"; - const aliceSharingInputMarbles = "ny-n"; + const participantInputMarbles = " a--b"; + const aliceSharingInputMarbles = " ny-n"; // Starts in one-on-one mobile layout, then goes to spotlight layout for // the screen sharing and group call cases - const expectedLayoutMarbles = " ab-c"; + const expectedLayoutMarbles = " ab-c"; + // Whether the layout switch is visible. It should be hidden while in + // one-on-one layout. + const expectedLayoutSwitchMarbles = "ny--"; + withCallViewModel( { remoteParticipants$: behavior(participantInputMarbles, { @@ -579,6 +583,9 @@ describe.each([ }, }, ); + expectObservable( + vm.layoutSwitchVm$.pipe(map((vm) => vm !== null)), + ).toBe(expectedLayoutSwitchMarbles, yesNo); }, ); }); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 5a066453e..6fac5ee5a 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -1108,41 +1108,45 @@ export function createCallViewModel$( ), ); - const oneOnOneLayoutMedia$: Observable<{ + const oneOnOneLayoutMedia$: Behavior<{ local: LocalUserMediaViewModel; remote: UserMediaViewModel | RingingMediaViewModel; - } | null> = combineLatest([userMedia$, screenShares$]).pipe( - switchMap(([userMedia, screenShares]) => { - // One-on-one layout only supports 2 user media, no screen shares - if (userMedia.length <= 2 && screenShares.length === 0) { - const local = userMedia.find( - (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => - vm.type === "user" && vm.local, - ); - - if (local !== undefined) { - const remote = userMedia.find( - (vm): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => - vm.type === "user" && !vm.local, + } | null> = scope.behavior( + combineLatest([userMedia$, screenShares$]).pipe( + switchMap(([userMedia, screenShares]) => { + // One-on-one layout only supports 2 user media, no screen shares + if (userMedia.length <= 2 && screenShares.length === 0) { + const local = userMedia.find( + (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => + vm.type === "user" && vm.local, ); - if (remote !== undefined) return of({ local, remote }); - - // If there's no other user media in the call (could still happen in - // this branch due to the duplicate tiles option), we could possibly - // show ringing media instead - if (userMedia.length === 1) - return ringingMedia$.pipe( - map( - (ringingMedia) => - ringingMedia && { local, remote: ringingMedia }, - ), + if (local !== undefined) { + const remote = userMedia.find( + ( + vm, + ): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => + vm.type === "user" && !vm.local, ); - } - } - return of(null); - }), + if (remote !== undefined) return of({ local, remote }); + + // If there's no other user media in the call (could still happen in + // this branch due to the duplicate tiles option), we could possibly + // show ringing media instead + if (userMedia.length === 1) + return ringingMedia$.pipe( + map( + (ringingMedia) => + ringingMedia && { local, remote: ringingMedia }, + ), + ); + } + } + + return of(null); + }), + ), ); const oneOnOneDesktopLayoutMedia$: Observable = @@ -1358,6 +1362,22 @@ export function createCallViewModel$( layoutMedia$.pipe(map(({ edgeToEdge }) => edgeToEdge)), ); + // Only show the layout switch in cases where it has an effect on the layout + const showLayoutSwitch$ = windowMode$.pipe( + switchMap((windowMode) => { + switch (windowMode) { + case "normal": + return of(true); + case "flat": + return oneOnOneLayoutMedia$.pipe( + map((oneOnOne) => oneOnOne === null), + ); + default: + return of(false); + } + }), + ); + const screenTap$ = new Subject(); const controlsTap$ = new Subject(); const screenHover$ = new Subject(); @@ -1775,7 +1795,9 @@ export function createCallViewModel$( spotlightExpanded$: spotlightExpanded$, toggleSpotlightExpanded$: toggleSpotlightExpanded$, - layoutSwitchVm$: constant(layoutSwitchVm), + layoutSwitchVm$: scope.behavior( + showLayoutSwitch$.pipe(map((show) => (show ? layoutSwitchVm : null))), + ), layout$: layout$, localMatrixLivekitMember$, remoteMatrixLivekitMembers$: scope.behavior( From 2de7fdde10c66ffdab6b312bc8dbd54726ec6363 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 27 Jul 2026 17:37:57 +0200 Subject: [PATCH 3/3] Fix layout switches not showing their state on the combined docs page On the combined Storybook docs page for the call footer, the layout switches would not show the correct state until you hovered over them because they all shared the same input name and thus were interpreted as belonging to the same radio group. --- src/room/LayoutSwitch.tsx | 5 +++-- src/room/__snapshots__/InCallView.test.tsx.snap | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/room/LayoutSwitch.tsx b/src/room/LayoutSwitch.tsx index 80995a35c..91ba0654f 100644 --- a/src/room/LayoutSwitch.tsx +++ b/src/room/LayoutSwitch.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC } from "react"; +import { useId, type FC } from "react"; import { SpotlightViewIcon, GridIcon, @@ -24,10 +24,11 @@ interface Props { export const LayoutSwitch: FC = ({ vm, className }) => { const { t } = useTranslation(); const layout = useBehavior(vm.layout$); + const name = useId(); return ( - name="layout" + name={name} aria-label={t("layout_switch_label")} leftLabel={t("layout_spotlight_label")} leftValue="spotlight" diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 8a805668d..984e2b12e 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -432,8 +432,8 @@ exports[`InCallView > rendering > renders 1`] = ` data-size="lg" > @@ -452,9 +452,9 @@ exports[`InCallView > rendering > renders 1`] = ` />