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 20:02:56 +02:00
parent 64d5e8ca24
commit 7615e146a5
4 changed files with 185 additions and 93 deletions
-1
View File
@@ -202,7 +202,6 @@
"camera_numbered": "Camera {{n}}", "camera_numbered": "Camera {{n}}",
"change_device_button": "Change audio device", "change_device_button": "Change audio device",
"default": "Default", "default": "Default",
"default_named": "Default <2>({{name}})</2>",
"handset": "Handset", "handset": "Handset",
"loudspeaker": "Loudspeaker", "loudspeaker": "Loudspeaker",
"microphone": "Microphone", "microphone": "Microphone",
-4
View File
@@ -26,10 +26,6 @@ Please see LICENSE in the repository root for full details.
); );
} }
.footer.hidden {
display: none;
}
.footer.overlay { .footer.overlay {
/* Note that the footer is still position: sticky in this case so that certain /* 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. */ tiles can move up out of the way of the footer when visible. */
+120 -23
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. 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 { BehaviorSubject } from "rxjs";
import { type ReactNode } from "react"; import { type JSX, type ReactNode } from "react";
import { Link } from "@vector-im/compound-web"; import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite"; 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 inCallViewStyles from "../room/InCallView.module.css";
import { createMockedViewModel } 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";
// 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 ( return (
<div className={inCallViewStyles.inRoom}> <div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext <ReactionsSenderContext
@@ -26,33 +48,28 @@ function CallFooterWrapper(props: FooterProps): ReactNode {
sendReaction: async (reaction: ReactionOption) => Promise.resolve(), sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
}} }}
> >
<CallFooter {...props} /> <CallFooter vm={vm} />
</ReactionsSenderContext> </ReactionsSenderContext>
</div> </div>
); );
} }
const meta = { const meta = {
component: CallFooterWrapper, component: CallFooterStoryWrapper,
} satisfies Meta<typeof CallFooterWrapper>; } satisfies Meta<typeof CallFooterStoryWrapper>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
const reactionIdentifier = "@user:example.com:DEVICE";
const reactionData = {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
};
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 },
}; };
export const Default: Story = { export const Default: Story = {
args: { args: {
hideLogo: true, showLogo: false,
layoutMode: "grid", layoutMode: "grid",
audioEnabled: true, audioEnabled: true,
videoEnabled: true, videoEnabled: true,
@@ -62,12 +79,16 @@ export const Default: Story = {
toggleVideo: fn(), toggleVideo: fn(),
toggleScreenSharing: fn(), toggleScreenSharing: fn(),
hangup: fn(), hangup: fn(),
buttonSize: "lg",
}, },
parameters: { parameters: {
layout: "fullscreen", layout: "fullscreen",
}, },
argTypes: { argTypes: {
layoutMode: { control: "radio", options: ["grid", "spotlight"] }, layoutMode: {
control: "radio",
options: ["grid", "spotlight"] satisfies GridMode[],
},
audioOutputSwitcher: { audioOutputSwitcher: {
control: "select", control: "select",
options: ["NoOutputCallback", "speaker", "earpiece"], options: ["NoOutputCallback", "speaker", "earpiece"],
@@ -110,7 +131,7 @@ export const WithLogo: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
hideLogo: false, showLogo: true,
}, },
}; };
@@ -121,6 +142,51 @@ export const AudioVideoEnabled: Story = {
audioEnabled: true, audioEnabled: true,
videoEnabled: 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 = { export const WithAudioOutputSpeaker: Story = {
@@ -150,7 +216,38 @@ export const Pip: Story = {
...Default, ...Default,
args: { args: {
...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 = { export const NoControlsWithLogo: Story = {
@@ -158,7 +255,7 @@ export const NoControlsWithLogo: Story = {
args: { args: {
...Default.args, ...Default.args,
hideControls: true, hideControls: true,
hideLogo: false, showLogo: true,
}, },
}; };
@@ -187,7 +284,7 @@ export const MobileLayout: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
hideLogo: true, showLogo: false,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() }, audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
}, },
@@ -203,7 +300,7 @@ export const Lobby: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
hideLogo: true, showLogo: false,
openSettings: undefined, openSettings: undefined,
setLayoutMode: undefined, setLayoutMode: undefined,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
@@ -217,7 +314,7 @@ export const LobbyMobile: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
hideLogo: true, showLogo: false,
setLayoutMode: undefined, setLayoutMode: undefined,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
@@ -235,7 +332,7 @@ export const LobbyRecentButton: Story = {
args: { args: {
...Default.args, ...Default.args,
children: <Link>Back To Recents</Link>, children: <Link>Back To Recents</Link>,
hideLogo: true, showLogo: false,
setLayoutMode: undefined, setLayoutMode: undefined,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
}, },
@@ -249,7 +346,7 @@ export const LobbyRecentButtonMobile: Story = {
args: { args: {
...Default.args, ...Default.args,
children: <Link>Back To Recents</Link>, children: <Link>Back To Recents</Link>,
hideLogo: true, showLogo: false,
setLayoutMode: undefined, setLayoutMode: undefined,
toggleScreenSharing: undefined, toggleScreenSharing: undefined,
}, },
+65 -65
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 { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject } from "rxjs";
import { Switch } from "@vector-im/compound-web";
import { t } from "i18next";
import { import {
SpotlightIcon, SpotlightIcon,
GridIcon, GridIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } 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";
@@ -33,35 +32,36 @@ import { type GridMode } from "../state/CallViewModel/CallViewModel";
import { import {
MediaMuteAndSwitchButton, MediaMuteAndSwitchButton,
type MenuOptions, type MenuOptions,
type ToggleOption,
} from "./MediaMuteAndSwitchButton"; } from "./MediaMuteAndSwitchButton";
import { type ViewModel, useViewModel } from "../state/ViewModel";
export interface AudioOutputSwitcher { export interface AudioOutputSwitcher {
targetOutput: string; targetOutput: string;
switch: () => void; switch: () => void;
} }
export interface FooterProps { export interface FooterSnapshot {
ref?: Ref<HTMLDivElement>;
/** Children will only be visible if the component is wider than 5*/
children?: JSX.Element | JSX.Element[] | false;
audioEnabled: boolean; audioEnabled: boolean;
/** Also controls if the audioMute button is disabled */ /** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined; toggleAudio: (() => void) | undefined;
videoEnabled: boolean; videoEnabled: boolean;
/** Also controls if the videoMute button is disabled */ /** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined; toggleVideo: (() => void) | undefined;
/* This is needed for WindowMode = "flat" */ /* This is needed for WindowMode = "flat" */
hideControls?: boolean; 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. /** 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; asOverlay?: boolean;
buttonSize: "md" | "lg";
showSettingsButton?: boolean;
showLayoutSwitcher?: boolean;
showLogoDebugContainer?: boolean;
showLogo?: boolean;
layoutMode?: GridMode; layoutMode?: GridMode;
/** Also controls if the layout button is visible */ /** Also controls if the layout button is visible */
setLayoutMode?: (mode: GridMode) => void; setLayoutMode?: (mode: GridMode) => void;
@@ -69,7 +69,7 @@ export interface FooterProps {
sharingScreen?: boolean; sharingScreen?: boolean;
toggleScreenSharing?: () => void; toggleScreenSharing?: () => void;
/** Also controls if the audio button is visible */ /** Also controls if the audio output button is visible */
audioOutputSwitcher?: AudioOutputSwitcher; audioOutputSwitcher?: AudioOutputSwitcher;
/** Also controls if the settings button is visible */ /** Also controls if the settings button is visible */
openSettings?: () => void; openSettings?: () => void;
@@ -79,60 +79,64 @@ export interface FooterProps {
reactionIdentifier?: string; reactionIdentifier?: string;
reactionData?: ReactionData; reactionData?: ReactionData;
hideLogo?: boolean;
// debug stuff // debug stuff
debugTileLayout?: boolean; debugTileLayout?: boolean;
tileStoreGeneration?: number; tileStoreGeneration?: number;
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions?: MenuOptions[]; audioOptions?: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions?: MenuOptions[]; videoOptions?: MenuOptions[];
selectedAudio?: string; selectedAudio?: string;
selectedVideo?: string; selectedVideo?: string;
selectAudioDevice?: (deviceId: string) => void; selectAudioButtonOption?: (deviceId: string) => void;
selectVideoDevice?: (deviceId: string) => void; selectVideoButtonOption?: (option: string) => void;
videoToggles?: ToggleOption[];
} }
export const CallFooter: FC<FooterProps> = ({ export interface FooterProps {
ref, ref?: Ref<HTMLDivElement>;
children, children?: JSX.Element | JSX.Element[] | false;
asOverlay, vm: ViewModel<FooterSnapshot>;
hidden, }
hideControls, export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
hideLogo, const {
asPip, asOverlay,
layoutMode, hideControls,
setLayoutMode, layoutMode,
openSettings, setLayoutMode,
audioEnabled, openSettings,
videoEnabled, audioEnabled,
toggleAudio, videoEnabled,
toggleVideo, toggleAudio,
sharingScreen, toggleVideo,
toggleScreenSharing, sharingScreen,
reactionIdentifier, toggleScreenSharing,
reactionData, reactionIdentifier,
audioOutputSwitcher, reactionData,
hangup, audioOutputSwitcher,
debugTileLayout, hangup,
tileStoreGeneration, 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 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) { if (showSettingsButton) {
// add the settings button to the center group of buttons, so it will be visible on small screens. // Add the settings button to the center group so it's visible on small
// On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible. // screens. On larger screens the SettingsIconButton with
// showForScreenWidth="wide" in the settingsLogoContainer is used instead.
buttons.push( buttons.push(
<SettingsButton <SettingsButton
key="settings" key="settings"
@@ -154,7 +158,7 @@ export const CallFooter: FC<FooterProps> = ({
data-testid="incall_mute" data-testid="incall_mute"
options={audioOptions} options={audioOptions}
selectedOption={selectedAudio} selectedOption={selectedAudio}
onSelect={selectAudioDevice} onSelect={selectAudioButtonOption}
/>, />,
); );
} else { } else {
@@ -169,6 +173,7 @@ export const CallFooter: FC<FooterProps> = ({
/>, />,
); );
} }
if ((videoOptions?.length ?? 0) > 0) { if ((videoOptions?.length ?? 0) > 0) {
buttons.push( buttons.push(
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
@@ -179,8 +184,9 @@ export const CallFooter: FC<FooterProps> = ({
onMuteClick={toggleVideo} onMuteClick={toggleVideo}
data-testid="incall_videomute" data-testid="incall_videomute"
options={videoOptions} options={videoOptions}
toggles={videoToggles}
selectedOption={selectedVideo} selectedOption={selectedVideo}
onSelect={selectVideoDevice} onSelect={selectVideoButtonOption}
/>, />,
); );
} else { } else {
@@ -213,12 +219,7 @@ export const CallFooter: FC<FooterProps> = ({
buttons.push( buttons.push(
<ReactionToggleButton <ReactionToggleButton
size={buttonSize} size={buttonSize}
reactionData={ reactionData={reactionData}
reactionData ?? {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
}
}
key="raise_hand" key="raise_hand"
className={styles.raiseHand} className={styles.raiseHand}
identifier={reactionIdentifier} identifier={reactionIdentifier}
@@ -271,7 +272,6 @@ export const CallFooter: FC<FooterProps> = ({
ref={ref} ref={ref}
className={classNames(styles.footer, { className={classNames(styles.footer, {
[styles.overlay]: asOverlay, [styles.overlay]: asOverlay,
[styles.hidden]: hidden,
})} })}
> >
<div className={styles.settingsLogoContainer}> <div className={styles.settingsLogoContainer}>
@@ -288,7 +288,7 @@ export const CallFooter: FC<FooterProps> = ({
{showLogoDebugContainer && logoDebugContainer} {showLogoDebugContainer && logoDebugContainer}
</div> </div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>} {!hideControls && <div className={styles.buttons}>{buttons}</div>}
{setLayoutMode && layoutMode && showLayoutSwitcher && ( {setLayoutMode && layoutMode && (
<Switch<"spotlight", "grid"> <Switch<"spotlight", "grid">
name="layoutMode" name="layoutMode"
aria-label={t("layout_switch_label")} aria-label={t("layout_switch_label")}