Use ViewModel approach for the CallFooterView + interaction tests

(this is just imaginary. There is no view model yet.)
This commit is contained in:
Timo K
2026-05-11 17:36:54 +02:00
parent 64d5e8ca24
commit 7615e146a5
4 changed files with 185 additions and 93 deletions

View File

@@ -202,7 +202,6 @@
"camera_numbered": "Camera {{n}}",
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"handset": "Handset",
"loudspeaker": "Loudspeaker",
"microphone": "Microphone",

View File

@@ -26,10 +26,6 @@ Please see LICENSE in the repository root for full details.
);
}
.footer.hidden {
display: none;
}
.footer.overlay {
/* Note that the footer is still position: sticky in this case so that certain
tiles can move up out of the way of the footer when visible. */

View File

@@ -5,18 +5,40 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { fn } from "storybook/test";
import { expect, fn, userEvent, within } from "storybook/test";
import { BehaviorSubject } from "rxjs";
import { type ReactNode } from "react";
import { type JSX, type ReactNode } from "react";
import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterProps } from "./CallFooter";
import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css";
import { createMockedViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
// consts for tests
const reactionIdentifier = "@user:example.com:DEVICE";
const reactionData = {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
};
function CallFooterWrapper(props: FooterProps): ReactNode {
/**
* 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)
* - Add additional react context
* @param chilren used for the "Back to Recents" button in the lobby stories, but can be used for anything really
* @param vmSnapshot the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter.
* @returns
*/
function CallFooterStoryWrapper({
children,
...vmSnapshot
}: FooterSnapshot & {
children?: false | JSX.Element | JSX.Element[] | undefined;
}): ReactNode {
const vm = createMockedViewModel(vmSnapshot);
return (
<div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext
@@ -26,33 +48,28 @@ function CallFooterWrapper(props: FooterProps): ReactNode {
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
}}
>
<CallFooter {...props} />
<CallFooter vm={vm} />
</ReactionsSenderContext>
</div>
);
}
const meta = {
component: CallFooterWrapper,
} satisfies Meta<typeof CallFooterWrapper>;
component: CallFooterStoryWrapper,
} satisfies Meta<typeof CallFooterStoryWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
const reactionIdentifier = "@user:example.com:DEVICE";
const reactionData = {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
};
const fnArgType = {
control: { type: "select" as const },
options: ["MockedCallback", "undefined"],
mapping: { MockedCallback: fn(), undefined: undefined },
};
export const Default: Story = {
args: {
hideLogo: true,
showLogo: false,
layoutMode: "grid",
audioEnabled: true,
videoEnabled: true,
@@ -62,12 +79,16 @@ export const Default: Story = {
toggleVideo: fn(),
toggleScreenSharing: fn(),
hangup: fn(),
buttonSize: "lg",
},
parameters: {
layout: "fullscreen",
},
argTypes: {
layoutMode: { control: "radio", options: ["grid", "spotlight"] },
layoutMode: {
control: "radio",
options: ["grid", "spotlight"] satisfies GridMode[],
},
audioOutputSwitcher: {
control: "select",
options: ["NoOutputCallback", "speaker", "earpiece"],
@@ -110,7 +131,7 @@ export const WithLogo: Story = {
...Default,
args: {
...Default.args,
hideLogo: false,
showLogo: true,
},
};
@@ -121,6 +142,51 @@ export const AudioVideoEnabled: Story = {
audioEnabled: true,
videoEnabled: true,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
await userEvent.click(spotlightRadio);
await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight");
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
/** used to test switching to grid mode */
export const SpotlightMode: Story = {
...Default,
args: {
...Default.args,
layoutMode: "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");
},
};
export const WithAudioOutputSpeaker: Story = {
@@ -150,7 +216,38 @@ export const Pip: Story = {
...Default,
args: {
...Default.args,
asPip: true,
buttonSize: "md",
showSettingsButton: false,
layoutMode: undefined,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole("radio", { name: "Spotlight" }),
).not.toBeInTheDocument();
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
export const NoControlsWithLogo: Story = {
@@ -158,7 +255,7 @@ export const NoControlsWithLogo: Story = {
args: {
...Default.args,
hideControls: true,
hideLogo: false,
showLogo: true,
},
};
@@ -187,7 +284,7 @@ export const MobileLayout: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
},
@@ -203,7 +300,7 @@ export const Lobby: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
openSettings: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -217,7 +314,7 @@ export const LobbyMobile: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -235,7 +332,7 @@ export const LobbyRecentButton: Story = {
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
@@ -249,7 +346,7 @@ export const LobbyRecentButtonMobile: Story = {
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},

View File

@@ -7,13 +7,12 @@ 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 { BehaviorSubject } from "rxjs";
import { Switch } from "@vector-im/compound-web";
import { t } from "i18next";
import {
SpotlightIcon,
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";
@@ -33,35 +32,36 @@ import { type GridMode } from "../state/CallViewModel/CallViewModel";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
type ToggleOption,
} from "./MediaMuteAndSwitchButton";
import { type ViewModel, useViewModel } from "../state/ViewModel";
export interface AudioOutputSwitcher {
targetOutput: string;
switch: () => void;
}
export interface FooterProps {
ref?: Ref<HTMLDivElement>;
/** Children will only be visible if the component is wider than 5*/
children?: JSX.Element | JSX.Element[] | false;
export interface FooterSnapshot {
audioEnabled: boolean;
/** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined;
videoEnabled: boolean;
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
/* This is needed for WindowMode = "flat" */
hideControls?: boolean;
/** hide the entire footer*/
hidden?: boolean;
/** Pip controls buttonSize and hides: settings button, layout switcher and logo */
asPip?: boolean;
/** The footer should be used as an overlay.
* (Over the Call Grid) This saves spaces on small screens.*/
* (Over the Call Grid) This saves spaces on small screens. */
asOverlay?: boolean;
buttonSize: "md" | "lg";
showSettingsButton?: boolean;
showLayoutSwitcher?: boolean;
showLogoDebugContainer?: boolean;
showLogo?: boolean;
layoutMode?: GridMode;
/** Also controls if the layout button is visible */
setLayoutMode?: (mode: GridMode) => void;
@@ -69,7 +69,7 @@ export interface FooterProps {
sharingScreen?: boolean;
toggleScreenSharing?: () => void;
/** Also controls if the audio button is visible */
/** Also controls if the audio output button is visible */
audioOutputSwitcher?: AudioOutputSwitcher;
/** Also controls if the settings button is visible */
openSettings?: () => void;
@@ -79,60 +79,64 @@ export interface FooterProps {
reactionIdentifier?: string;
reactionData?: ReactionData;
hideLogo?: boolean;
// debug stuff
debugTileLayout?: boolean;
tileStoreGeneration?: number;
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions?: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions?: MenuOptions[];
selectedAudio?: string;
selectedVideo?: string;
selectAudioDevice?: (deviceId: string) => void;
selectVideoDevice?: (deviceId: string) => void;
selectAudioButtonOption?: (deviceId: string) => void;
selectVideoButtonOption?: (option: string) => void;
videoToggles?: ToggleOption[];
}
export const CallFooter: FC<FooterProps> = ({
ref,
children,
asOverlay,
hidden,
hideControls,
hideLogo,
asPip,
layoutMode,
setLayoutMode,
openSettings,
audioEnabled,
videoEnabled,
toggleAudio,
toggleVideo,
sharingScreen,
toggleScreenSharing,
reactionIdentifier,
reactionData,
audioOutputSwitcher,
hangup,
debugTileLayout,
tileStoreGeneration,
export interface FooterProps {
ref?: Ref<HTMLDivElement>;
children?: JSX.Element | JSX.Element[] | false;
vm: ViewModel<FooterSnapshot>;
}
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
const {
asOverlay,
hideControls,
layoutMode,
setLayoutMode,
openSettings,
audioEnabled,
videoEnabled,
toggleAudio,
toggleVideo,
sharingScreen,
toggleScreenSharing,
reactionIdentifier,
reactionData,
audioOutputSwitcher,
hangup,
debugTileLayout,
tileStoreGeneration,
videoOptions,
selectedVideo,
audioOptions,
selectedAudio,
selectAudioButtonOption,
selectVideoButtonOption,
videoToggles,
buttonSize,
showSettingsButton,
showLogoDebugContainer,
showLogo,
} = useViewModel(vm);
audioOptions,
videoOptions,
selectedAudio,
selectedVideo,
selectAudioDevice,
selectVideoDevice,
}) => {
const buttons: JSX.Element[] = [];
const buttonSize = asPip ? "md" : "lg";
const showSettingsButton =
openSettings !== undefined && !asPip && !hideControls;
const showLayoutSwitcher = !asPip && !hideControls;
const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout);
const showLogo = !hideLogo && !asPip;
if (showSettingsButton) {
// add the settings button to the center group of buttons, so it will be visible on small screens.
// On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible.
// Add the settings button to the center group so it's visible on small
// screens. On larger screens the SettingsIconButton with
// showForScreenWidth="wide" in the settingsLogoContainer is used instead.
buttons.push(
<SettingsButton
key="settings"
@@ -154,7 +158,7 @@ export const CallFooter: FC<FooterProps> = ({
data-testid="incall_mute"
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioDevice}
onSelect={selectAudioButtonOption}
/>,
);
} else {
@@ -169,6 +173,7 @@ export const CallFooter: FC<FooterProps> = ({
/>,
);
}
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
@@ -179,8 +184,9 @@ export const CallFooter: FC<FooterProps> = ({
onMuteClick={toggleVideo}
data-testid="incall_videomute"
options={videoOptions}
toggles={videoToggles}
selectedOption={selectedVideo}
onSelect={selectVideoDevice}
onSelect={selectVideoButtonOption}
/>,
);
} else {
@@ -213,12 +219,7 @@ export const CallFooter: FC<FooterProps> = ({
buttons.push(
<ReactionToggleButton
size={buttonSize}
reactionData={
reactionData ?? {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
}
}
reactionData={reactionData}
key="raise_hand"
className={styles.raiseHand}
identifier={reactionIdentifier}
@@ -271,7 +272,6 @@ export const CallFooter: FC<FooterProps> = ({
ref={ref}
className={classNames(styles.footer, {
[styles.overlay]: asOverlay,
[styles.hidden]: hidden,
})}
>
<div className={styles.settingsLogoContainer}>
@@ -288,7 +288,7 @@ export const CallFooter: FC<FooterProps> = ({
{showLogoDebugContainer && logoDebugContainer}
</div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
{setLayoutMode && layoutMode && showLayoutSwitcher && (
{setLayoutMode && layoutMode && (
<Switch<"spotlight", "grid">
name="layoutMode"
aria-label={t("layout_switch_label")}