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}})2>",
"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,
})}
>