diff --git a/locales/en/app.json b/locales/en/app.json index b51c6ed9..1d5eaa19 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -202,7 +202,6 @@ "camera_numbered": "Camera {{n}}", "change_device_button": "Change audio device", "default": "Default", - "default_named": "Default <2>({{name}})", "handset": "Handset", "loudspeaker": "Loudspeaker", "microphone": "Microphone", diff --git a/src/components/CallFooter.module.css b/src/components/CallFooter.module.css index adff99d5..228d7654 100644 --- a/src/components/CallFooter.module.css +++ b/src/components/CallFooter.module.css @@ -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. */ diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 7090a338..129c2b3a 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -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 (
Promise.resolve(), }} > - +
); } const meta = { - component: CallFooterWrapper, -} satisfies Meta; + component: CallFooterStoryWrapper, +} satisfies Meta; export default meta; type Story = StoryObj; -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: Back To Recents, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, }, @@ -249,7 +346,7 @@ export const LobbyRecentButtonMobile: Story = { args: { ...Default.args, children: Back To Recents, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, }, diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index afc5bdc9..bbb8f7e2 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -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; - /** 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 = ({ - 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; + children?: JSX.Element | JSX.Element[] | false; + vm: ViewModel; +} +export const CallFooter: FC = ({ 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( = ({ data-testid="incall_mute" options={audioOptions} selectedOption={selectedAudio} - onSelect={selectAudioDevice} + onSelect={selectAudioButtonOption} />, ); } else { @@ -169,6 +173,7 @@ export const CallFooter: FC = ({ />, ); } + if ((videoOptions?.length ?? 0) > 0) { buttons.push( = ({ 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 = ({ buttons.push( = ({ ref={ref} className={classNames(styles.footer, { [styles.overlay]: asOverlay, - [styles.hidden]: hidden, })} >
@@ -288,7 +288,7 @@ export const CallFooter: FC = ({ {showLogoDebugContainer && logoDebugContainer}
{!hideControls &&
{buttons}
} - {setLayoutMode && layoutMode && showLayoutSwitcher && ( + {setLayoutMode && layoutMode && ( name="layoutMode" aria-label={t("layout_switch_label")}