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 { 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<FooterSnapshot, "layoutSwitchVm"> & {
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 (
<MediaDevicesContext value={mediaDevices}>
<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 = {
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<typeof CallFooterStoryWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
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: <Link>Back To Recents</Link>,
showLogo: false,
setLayoutMode: undefined,
layout: null,
toggleScreenSharing: undefined,
},
parameters: {
@@ -393,7 +402,7 @@ export const LobbyRecentButtonMobile: Story = {
...Default.args,
children: <Link>Back To Recents</Link>,
showLogo: false,
setLayoutMode: undefined,
layout: null,
toggleScreenSharing: undefined,
},
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 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<FooterProps> = ({
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<FooterProps> = ({
{(showLogo || debugTileLayout) && logoDebugContainer}
</div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
{!hideControls && setLayoutMode && layoutMode && (
<Switch<"spotlight", "grid">
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 && (
<LayoutSwitch vm={layoutSwitchVm} className={styles.layout} />
)}
</div>
);

View File

@@ -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,