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

@@ -16,10 +16,12 @@ import inCallViewStyles from "../room/InCallView.module.css";
import { useStaticViewModel } from "../state/ViewModel"; import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions"; import { type ReactionOption } from "../reactions";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices"; import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope"; import { globalScope } from "../state/ObservableScope";
import { constant } from "../state/Behavior";
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
// consts for tests // consts for tests
const reactionIdentifier = "@user:example.com:DEVICE"; const reactionIdentifier = "@user:example.com:DEVICE";
const reactionData = { const reactionData = {
@@ -32,6 +34,7 @@ const mediaDevices = new MediaDevices(globalScope);
/** /**
* A wrapper component that is used for: * 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) * - 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 * - Add additional react context
* The paraeters are all params from the FooterSnapshot, * 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. * 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({ function CallFooterStoryWrapper({
children, children,
layout,
setLayout,
...vmSnapshot ...vmSnapshot
}: FooterSnapshot & { }: Omit<FooterSnapshot, "layoutSwitchVm"> & {
children?: false | JSX.Element | JSX.Element[] | undefined; children?: false | JSX.Element | JSX.Element[] | undefined;
layout: LayoutMode | null;
setLayout: (value: LayoutMode) => void;
}): ReactNode { }): ReactNode {
const vm = useStaticViewModel(vmSnapshot); const vm = useStaticViewModel({
...vmSnapshot,
layoutSwitchVm: layout && { layout$: constant(layout), setLayout },
});
return ( return (
<MediaDevicesContext value={mediaDevices}> <MediaDevicesContext value={mediaDevices}>
<div className={inCallViewStyles.inRoom}> <div className={inCallViewStyles.inRoom}>
@@ -62,28 +72,50 @@ function CallFooterStoryWrapper({
); );
} }
const meta = {
component: CallFooterStoryWrapper,
} satisfies Meta<typeof CallFooterStoryWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
const fnArgType = { const fnArgType = {
control: { type: "select" as const }, control: { type: "select" as const },
options: ["MockedCallback", "undefined"], options: ["MockedCallback", "undefined"],
mapping: { MockedCallback: fn(), undefined: 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<typeof CallFooterStoryWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {
args: { args: {
showLogo: false, showLogo: false,
layoutMode: "grid", layout: "grid",
setLayout: fn(),
audioEnabled: true, audioEnabled: true,
audioBusy: false, audioBusy: false,
videoEnabled: true, videoEnabled: true,
videoBusy: false, videoBusy: false,
setLayoutMode: fn(),
openSettings: fn(), openSettings: fn(),
toggleAudio: fn(), toggleAudio: fn(),
toggleVideo: fn(), toggleVideo: fn(),
@@ -111,29 +143,6 @@ export const Default: Story = {
parameters: { parameters: {
layout: "fullscreen", 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 = { export const WithAudioAndVideoOptions: Story = {
@@ -194,7 +203,7 @@ export const AudioVideoEnabled: Story = {
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" }); const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
await userEvent.click(spotlightRadio); await userEvent.click(spotlightRadio);
await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight"); await expect(args.setLayout).toHaveBeenCalledWith("spotlight");
const micButtonMute = canvas.getByRole("switch", { const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone", name: "Mute microphone",
@@ -225,14 +234,14 @@ export const SpotlightMode: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
layoutMode: "spotlight", layout: "spotlight",
}, },
play: async ({ args, canvasElement }) => { play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" }); const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
await userEvent.click(spotlightRadio); 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: { args: {
...Default.args, ...Default.args,
buttonSize: "md", buttonSize: "md",
layoutMode: undefined, layout: null,
}, },
play: async ({ args, canvasElement }) => { play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
@@ -348,7 +357,7 @@ export const Lobby: Story = {
...Default.args, ...Default.args,
showLogo: false, showLogo: false,
openSettings: undefined, openSettings: undefined,
setLayoutMode: undefined, layout: null,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
}, },
parameters: { parameters: {
@@ -362,7 +371,7 @@ export const LobbyMobile: Story = {
...Default.args, ...Default.args,
showLogo: false, showLogo: false,
setLayoutMode: undefined, layout: null,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
}, },
globals: { globals: {
@@ -379,7 +388,7 @@ export const LobbyRecentButton: Story = {
...Default.args, ...Default.args,
children: <Link>Back To Recents</Link>, children: <Link>Back To Recents</Link>,
showLogo: false, showLogo: false,
setLayoutMode: undefined, layout: null,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
}, },
parameters: { parameters: {
@@ -393,7 +402,7 @@ export const LobbyRecentButtonMobile: Story = {
...Default.args, ...Default.args,
children: <Link>Back To Recents</Link>, children: <Link>Back To Recents</Link>,
showLogo: false, showLogo: false,
setLayoutMode: undefined, layout: null,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
}, },
globals: { globals: {

View File

@@ -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 { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames"; 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 LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react"; import LogoType from "../icons/LogoType.svg?react";
@@ -28,13 +22,14 @@ import {
type ReactionData, type ReactionData,
} from "../button"; } from "../button";
import styles from "./CallFooter.module.css"; import styles from "./CallFooter.module.css";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
import { import {
MediaMuteAndSwitchButton, MediaMuteAndSwitchButton,
type MenuOptions, type MenuOptions,
} from "./MediaMuteAndSwitchButton"; } from "./MediaMuteAndSwitchButton";
import { type ViewModel } from "../state/ViewModel"; import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
import { LayoutSwitch } from "../room/LayoutSwitch";
export interface AudioOutputSwitcher { export interface AudioOutputSwitcher {
targetOutput: string; targetOutput: string;
@@ -61,8 +56,6 @@ export interface FooterActions {
/** Also controls if the videoMute button is disabled */ /** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined; toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined; toggleBlur: (() => void) | undefined;
/** Also controls if the layout button is visible */
setLayoutMode: ((mode: GridMode) => void) | undefined;
toggleScreenSharing: (() => void) | undefined; toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */ /** Also controls if the settings button is visible */
openSettings: (() => void) | undefined; openSettings: (() => void) | undefined;
@@ -87,7 +80,8 @@ export interface FooterState {
buttonSize: "md" | "lg"; buttonSize: "md" | "lg";
showLogo: boolean; showLogo: boolean;
layoutMode: GridMode | undefined; /** Also controls if the layout switch is visible */
layoutSwitchVm: LayoutSwitchViewModel | null;
sharingScreen: boolean; sharingScreen: boolean;
@@ -126,8 +120,7 @@ export const CallFooter: FC<FooterProps> = ({
const asOverlay = useBehavior(vm.asOverlay$); const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$); const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$); const hideControls = useBehavior(vm.hideControls$);
const layoutMode = useBehavior(vm.layoutMode$); const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
const setLayoutMode = useBehavior(vm.setLayoutMode$);
const openSettings = useBehavior(vm.openSettings$); const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$); const audioEnabled = useBehavior(vm.audioEnabled$);
const audioBusy = useBehavior(vm.audioBusy$); const audioBusy = useBehavior(vm.audioBusy$);
@@ -317,20 +310,8 @@ export const CallFooter: FC<FooterProps> = ({
{(showLogo || debugTileLayout) && logoDebugContainer} {(showLogo || debugTileLayout) && logoDebugContainer}
</div> </div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>} {!hideControls && <div className={styles.buttons}>{buttons}</div>}
{!hideControls && setLayoutMode && layoutMode && ( {!hideControls && layoutSwitchVm && (
<Switch<"spotlight", "grid"> <LayoutSwitch vm={layoutSwitchVm} className={styles.layout} />
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}
/>
)} )}
</div> </div>
); );

View File

@@ -182,14 +182,7 @@ export function createCallFooterViewModel(
showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))), showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))),
layoutMode$: callModel.gridMode$, layoutSwitchVm$: callModel.layoutSwitchVm$,
setLayoutMode$: scope.behavior(
isPip$.pipe(
map((isPip) =>
!isPip && showControls ? callModel.setGridMode : undefined,
),
),
),
sharingScreen$: callModel.sharingScreen$, sharingScreen$: callModel.sharingScreen$,
toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined), toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined),
@@ -247,20 +240,18 @@ export function createLobbyFooterViewModel(
hideControls: false, hideControls: false,
asOverlay: false, asOverlay: false,
buttonSize: "lg", buttonSize: "lg",
showLayoutSwitcher: false,
openSettings, openSettings,
hangup, hangup,
debugTileLayout: false, debugTileLayout: false,
showFooter: true, showFooter: true,
toggleAudio: undefined, toggleAudio: undefined,
toggleVideo: undefined, toggleVideo: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
audioEnabled: undefined, audioEnabled: undefined,
audioBusy: false, audioBusy: false,
videoEnabled: undefined, videoEnabled: undefined,
videoBusy: false, videoBusy: false,
layoutMode: undefined, layoutSwitchVm: null,
sharingScreen: false, sharingScreen: false,
audioOutputSwitcher: undefined, audioOutputSwitcher: undefined,
reactionIdentifier: undefined, reactionIdentifier: undefined,

43
src/room/LayoutSwitch.tsx Normal file
View File

@@ -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<Props> = ({ vm, className }) => {
const { t } = useTranslation();
const layout = useBehavior(vm.layout$);
return (
<Switch<"spotlight", "grid">
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}
/>
);
};

View File

@@ -433,7 +433,7 @@ exports[`InCallView > rendering > renders 1`] = `
> >
<input <input
aria-labelledby="_r_19_" aria-labelledby="_r_19_"
name="layoutMode" name="layout"
type="radio" type="radio"
value="spotlight" value="spotlight"
/> />
@@ -454,7 +454,7 @@ exports[`InCallView > rendering > renders 1`] = `
<input <input
aria-labelledby="_r_1e_" aria-labelledby="_r_1e_"
checked="" checked=""
name="layoutMode" name="layout"
type="radio" type="radio"
value="grid" value="grid"
/> />

View File

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

View File

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

View File

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

View File

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