diff --git a/locales/en/app.json b/locales/en/app.json
index b51c6ed9..a14663e9 100644
--- a/locales/en/app.json
+++ b/locales/en/app.json
@@ -3,6 +3,7 @@
"user_menu": "User menu"
},
"action": {
+ "blur_background": "Blur background",
"close": "Close",
"copy_link": "Copy link",
"edit": "Edit",
diff --git a/playwright/reconnect.spec.ts b/playwright/reconnect.spec.ts
index 1a8f2c28..bd4dd199 100644
--- a/playwright/reconnect.spec.ts
+++ b/playwright/reconnect.spec.ts
@@ -54,6 +54,8 @@ test("can only interact with header and footer while reconnecting", async ({
page.getByRole("switch", { name: "Mute microphone" }),
).toBeFocused();
await page.keyboard.press("Tab");
+ await expect(page.getByRole("button", { name: "Microphone" })).toBeFocused();
+ await page.keyboard.press("Tab");
await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused();
// Most critically, we should be able to press the hangup button
await page.getByRole("button", { name: "End call" }).click();
diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx
index 7090a338..e9a7537c 100644
--- a/src/components/CallFooter.stories.tsx
+++ b/src/components/CallFooter.stories.tsx
@@ -5,18 +5,41 @@ 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 { useStaticViewModel } 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
+ * 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.
+ * `children` is used for the "Back to Recents" button in the lobby stories, but can be used for anything really.
+ * @returns A component that renders the CallFooter based on primitive snapshot params (not a view model). Which is what we want for storybook.
+ */
+function CallFooterStoryWrapper({
+ children,
+ ...vmSnapshot
+}: FooterSnapshot & {
+ children?: false | JSX.Element | JSX.Element[] | undefined;
+}): ReactNode {
+ const vm = useStaticViewModel(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,
@@ -61,13 +79,34 @@ export const Default: Story = {
toggleAudio: fn(),
toggleVideo: fn(),
toggleScreenSharing: fn(),
+ toggleBlur: fn(),
+ videoBlurEnabled: true,
hangup: fn(),
+ buttonSize: "lg",
+ showFooter: true,
+ hideControls: false,
+ asOverlay: false,
+ sharingScreen: false,
+ audioOutputSwitcher: undefined,
+ reactionIdentifier: undefined,
+ reactionData: undefined,
+ debugTileLayout: false,
+ tileStoreGeneration: undefined,
+ audioOptions: [],
+ videoOptions: [],
+ selectedAudio: undefined,
+ selectedVideo: undefined,
+ selectAudioButtonOption: undefined,
+ selectVideoButtonOption: undefined,
},
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"],
@@ -95,12 +134,12 @@ export const WithAudioAndVideoOptions: Story = {
audioEnabled: false,
videoEnabled: true,
audioOptions: [
- { label: "Microphone 1", id: "1" },
- { label: "Microphone 2", id: "2" },
+ { label: { type: "name", name: "Microphone 1" }, id: "1" },
+ { label: { type: "name", name: "Microphone 2" }, id: "2" },
],
videoOptions: [
- { label: "Camera 1", id: "1" },
- { label: "Camera 2", id: "2" },
+ { label: { type: "name", name: "Camera 1" }, id: "1" },
+ { label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedAudio: "2",
selectedVideo: "1",
@@ -110,7 +149,7 @@ export const WithLogo: Story = {
...Default,
args: {
...Default.args,
- hideLogo: false,
+ showLogo: true,
},
};
@@ -121,6 +160,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 +234,37 @@ export const Pip: Story = {
...Default,
args: {
...Default.args,
- asPip: true,
+ buttonSize: "md",
+ 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 +272,7 @@ export const NoControlsWithLogo: Story = {
args: {
...Default.args,
hideControls: true,
- hideLogo: false,
+ showLogo: true,
},
};
@@ -187,7 +301,7 @@ export const MobileLayout: Story = {
...Default,
args: {
...Default.args,
- hideLogo: true,
+ showLogo: false,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
},
@@ -203,7 +317,7 @@ export const Lobby: Story = {
...Default,
args: {
...Default.args,
- hideLogo: true,
+ showLogo: false,
openSettings: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -217,7 +331,7 @@ export const LobbyMobile: Story = {
...Default,
args: {
...Default.args,
- hideLogo: true,
+ showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -235,7 +349,7 @@ export const LobbyRecentButton: Story = {
args: {
...Default.args,
children: Back To Recents,
- hideLogo: true,
+ showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
@@ -249,7 +363,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..7dd68d88 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";
@@ -34,105 +33,123 @@ import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
+import { type ViewModel } from "../state/ViewModel";
+import { useBehavior } from "../useBehavior";
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;
-
- audioEnabled: boolean;
+/**
+ * The Snapshot combines all fields required to populate the view.
+ *
+ * It is a combination of Actions and State.
+ * All Actions and State will be wrappen in behaviors.
+ * This has the advantage, that actions can mutate.
+ * (example: a device gets disconnected, the swicht action is not possible anymore, the actions becomes undefined)
+ * With it being reactive we can use the existance of the action to update the rendering without
+ * requiring additional state.
+ *
+ * Comment: It might not make sense to seperate the two interfaces. Hence the seperation
+ * just happens on the syntax level with the `type = ... & ...` notation.
+ */
+export type FooterSnapshot = FooterActions & FooterState;
+export interface FooterActions {
/** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined;
- videoEnabled: boolean;
/** 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;
+ /** Also controls if the hangup button is visible */
+ hangup: (() => void) | undefined;
+}
+// we do not use any ? optional properties so that the vm type is including all fields.
+export interface FooterState {
+ audioEnabled: boolean;
+ videoEnabled: boolean;
+ videoBlurEnabled: boolean;
+ showFooter: boolean;
/* 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;
+ hideControls: boolean;
/** The footer should be used as an overlay.
- * (Over the Call Grid) This saves spaces on small screens.*/
- asOverlay?: boolean;
+ * (Over the Call Grid) This saves spaces on small screens. */
+ asOverlay: boolean;
- layoutMode?: GridMode;
- /** Also controls if the layout button is visible */
- setLayoutMode?: (mode: GridMode) => void;
+ buttonSize: "md" | "lg";
+ showLogo: boolean;
- sharingScreen?: boolean;
- toggleScreenSharing?: () => void;
+ layoutMode: GridMode | undefined;
- /** Also controls if the audio button is visible */
- audioOutputSwitcher?: AudioOutputSwitcher;
- /** Also controls if the settings button is visible */
- openSettings?: () => void;
- /** Also controls if the hangup button is visible */
- hangup?: () => void;
+ sharingScreen: boolean;
- reactionIdentifier?: string;
- reactionData?: ReactionData;
+ /** Also controls if the audio output button is visible */
+ audioOutputSwitcher: AudioOutputSwitcher | undefined;
+
+ reactionIdentifier: string | undefined;
+ reactionData: ReactionData | undefined;
- hideLogo?: boolean;
// debug stuff
- debugTileLayout?: boolean;
- tileStoreGeneration?: number;
+ debugTileLayout: boolean;
+ tileStoreGeneration: number | undefined;
- audioOptions?: MenuOptions[];
- videoOptions?: MenuOptions[];
- selectedAudio?: string;
- selectedVideo?: string;
- selectAudioDevice?: (deviceId: string) => void;
- selectVideoDevice?: (deviceId: string) => void;
+ /** 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 | undefined;
+ selectedVideo: string | undefined;
+ selectAudioButtonOption: ((deviceId: string) => void) | undefined;
+ selectVideoButtonOption: ((option: string) => void) | undefined;
}
-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 = useBehavior(vm.asOverlay$);
+ const showFooter = useBehavior(vm.showFooter$);
+ const hideControls = useBehavior(vm.hideControls$);
+ const layoutMode = useBehavior(vm.layoutMode$);
+ const setLayoutMode = useBehavior(vm.setLayoutMode$);
+ const openSettings = useBehavior(vm.openSettings$);
+ const audioEnabled = useBehavior(vm.audioEnabled$);
+ const videoEnabled = useBehavior(vm.videoEnabled$);
+ const toggleAudio = useBehavior(vm.toggleAudio$);
+ const toggleVideo = useBehavior(vm.toggleVideo$);
+ const sharingScreen = useBehavior(vm.sharingScreen$);
+ const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
+ const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
+ const reactionData = useBehavior(vm.reactionData$);
+ const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
+ const hangup = useBehavior(vm.hangup$);
+ const debugTileLayout = useBehavior(vm.debugTileLayout$);
+ const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
+ const videoOptions = useBehavior(vm.videoOptions$);
+ const selectedVideo = useBehavior(vm.selectedVideo$);
+ const audioOptions = useBehavior(vm.audioOptions$);
+ const selectedAudio = useBehavior(vm.selectedAudio$);
+ const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
+ const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
+ const toggleBlur = useBehavior(vm.toggleBlur$);
+ const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
+ const buttonSize = useBehavior(vm.buttonSize$);
+ const showLogo = useBehavior(vm.showLogo$);
- 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.
+
+ if (openSettings !== undefined) {
+ // 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 +186,7 @@ export const CallFooter: FC = ({
/>,
);
}
+
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
= ({
iconsAndLabels="video"
enabled={videoEnabled ?? false}
onMuteClick={toggleVideo}
- data-testid="incall_videomute"
options={videoOptions}
selectedOption={selectedVideo}
- onSelect={selectVideoDevice}
+ onSelect={selectVideoButtonOption}
+ videoBlurToggleClick={toggleBlur}
+ videoBlurEnabled={videoBlurEnabled}
/>,
);
} else {
@@ -213,12 +232,7 @@ export const CallFooter: FC = ({
buttons.push(
= ({
return (