From 400259207e805276dd0e12d7adb67b6c90616cc6 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 14 Apr 2026 13:25:33 +0200 Subject: [PATCH] Also use CallFooter for lobby --- .storybook/main.ts | 4 +- playwright/mobile/create-call-mobile.spec.ts | 2 +- src/button/Button.tsx | 6 +- src/components/CallFooter.mdx | 31 +++ ...ooter.module.css => CallFooter.module.css} | 1 + src/components/CallFooter.stories.tsx | 232 ++++++++++++++++++ .../{InCallFooter.tsx => CallFooter.tsx} | 131 ++++++---- src/components/InCallFooter.stories.tsx | 132 ---------- src/reactions/useReactionsSender.tsx | 2 +- src/room/InCallView.test.tsx | 6 +- src/room/InCallView.tsx | 23 +- src/room/LobbyView.tsx | 30 +-- 12 files changed, 370 insertions(+), 230 deletions(-) create mode 100644 src/components/CallFooter.mdx rename src/components/{InCallFooter.module.css => CallFooter.module.css} (99%) create mode 100644 src/components/CallFooter.stories.tsx rename src/components/{InCallFooter.tsx => CallFooter.tsx} (63%) delete mode 100644 src/components/InCallFooter.stories.tsx diff --git a/.storybook/main.ts b/.storybook/main.ts index 9be41676..9a3f0b53 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details. import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { - stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], - addons: ["@storybook/addon-docs"], + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: ["@storybook/addon-docs", "@storybook/addon-vitest"], framework: "@storybook/react-vite", }; export default config; diff --git a/playwright/mobile/create-call-mobile.spec.ts b/playwright/mobile/create-call-mobile.spec.ts index de1e65d1..c208c6b5 100644 --- a/playwright/mobile/create-call-mobile.spec.ts +++ b/playwright/mobile/create-call-mobile.spec.ts @@ -109,7 +109,7 @@ mobileTest( .click(); // dismiss settings - await guestPage.locator("#root").getByLabel("Settings").press("Escape"); + await guestPage.locator("#root").press("Escape"); await guestPage.pause(); await expect( diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 261c013c..98b377ce 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -22,7 +22,7 @@ import { } from "@vector-im/compound-design-tokens/assets/web/icons"; import styles from "./Button.module.css"; -import inCallFooterStyles from "../components/InCallFooter.module.css"; +import callFooterStyles from "../components/CallFooter.module.css"; import { platform } from "../Platform"; interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { @@ -177,9 +177,9 @@ export const SettingsButton: FC = ({ + + Call Footer + +The footer compoentn contains all main interactions needed for a call. + + Mobile layouts + +This component is reactive. To properly check the mobile layout, you will need to click on the stories in the left sidebar to see the +component on a mobile screen. +The story summary here does not render the mobile layouts correctly. + + + + diff --git a/src/components/InCallFooter.module.css b/src/components/CallFooter.module.css similarity index 99% rename from src/components/InCallFooter.module.css rename to src/components/CallFooter.module.css index 563d27d2..79682ce8 100644 --- a/src/components/InCallFooter.module.css +++ b/src/components/CallFooter.module.css @@ -51,6 +51,7 @@ Please see LICENSE in the repository root for full details. .settingsLogoContainer { display: flex; + align-items: center; gap: var(--cpd-space-4x); flex-direction: row; } diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx new file mode 100644 index 00000000..fcf0be3d --- /dev/null +++ b/src/components/CallFooter.stories.tsx @@ -0,0 +1,232 @@ +/* +Copyright 2026 Element Creations Ltd. + +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 { BehaviorSubject } from "rxjs"; +import { 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 inCallViewStyles from "../room/InCallView.module.css"; +import { ReactionsSenderContext } from "../reactions/useReactionsSender"; +import { type ReactionOption } from "../reactions"; + +function CallFooterWrapper(props: FooterProps): ReactNode { + return ( +
+ Promise.resolve(), + sendReaction: async (reaction: ReactionOption) => Promise.resolve(), + }} + > + + +
+ ); +} + +const meta = { + component: CallFooterWrapper, +} 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, + layoutMode: "grid", + setLayoutMode: fn(), + openSettings: fn(), + toggleAudio: fn(), + toggleVideo: fn(), + toggleScreenSharing: fn(), + hangup: fn(), + }, + parameters: { + layout: "fullscreen", + }, + argTypes: { + layoutMode: { control: "radio", options: ["grid", "spotlight"] }, + audioOutputSwitcher: { + control: "select", + options: ["NoOutputCallback", "speaker", "earpiece"], + table: { defaultValue: { summary: "NoOutputCallback" } }, + mapping: { + NoOutputCallback: undefined, + // This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one. + speaker: { targetOutput: "speaker", switch: fn() }, + earpiece: { targetOutput: "earpiece", switch: fn() }, + }, + }, + toggleScreenSharing: fnArgType, + setLayoutMode: fnArgType, + openSettings: fnArgType, + toggleAudio: fnArgType, + toggleVideo: fnArgType, + hangup: fnArgType, + }, +}; + +export const WithLogo: Story = { + ...Default, + args: { + ...Default.args, + hideLogo: false, + }, +}; + +export const AudioVideoEnabled: Story = { + ...Default, + args: { + ...Default.args, + audioEnabled: true, + videoEnabled: true, + }, +}; +export const WithAudioOutput: Story = { + ...Default, + args: { + ...Default.args, + audioOutputSwitcher: { targetOutput: "speaker", switch: fn() }, + }, +}; +export const WithReactions: Story = { + ...Default, + args: { + ...Default.args, + reactionIdentifier, + reactionData, + }, +}; +export const Pip: Story = { + ...Default, + args: { + ...Default.args, + asPip: true, + }, +}; + +export const NoControlsWithLogo: Story = { + ...Default, + args: { + ...Default.args, + hideControls: true, + hideLogo: false, + }, +}; + +export const DebugData: Story = { + ...Default, + args: { + ...Default.args, + debugTileLayout: true, + tileStoreGeneration: 74, + }, +}; + +export const UnavailableMediaDevices: Story = { + ...Default, + args: { + ...Default.args, + toggleAudio: undefined, + toggleVideo: undefined, + audioOutputSwitcher: undefined, + }, +}; + +export const MobileLayout: Story = { + ...Default, + args: { + ...Default.args, + hideLogo: true, + + audioOutputSwitcher: { targetOutput: "speaker", switch: fn() }, + }, + globals: { + viewport: { value: "mobile2", isRotated: false }, + }, + parameters: { + ...Default.parameters, + }, +}; + +export const Lobby: Story = { + ...Default, + args: { + ...Default.args, + hideLogo: true, + openSettings: undefined, + setLayoutMode: undefined, + toggleScreenSharing: undefined, + }, + parameters: { + ...Default.parameters, + }, +}; + +export const LobbyMobile: Story = { + ...Default, + args: { + ...Default.args, + hideLogo: true, + + setLayoutMode: undefined, + toggleScreenSharing: undefined, + }, + globals: { + viewport: { value: "mobile2", isRotated: false }, + }, + parameters: { + ...Default.parameters, + }, +}; + +export const LobbyRecentButton: Story = { + ...Default, + args: { + ...Default.args, + children: Back To Recents, + hideLogo: true, + setLayoutMode: undefined, + toggleScreenSharing: undefined, + }, + parameters: { + ...Default.parameters, + }, +}; + +export const LobbyRecentButtonMobile: Story = { + ...Default, + args: { + ...Default.args, + children: Back To Recents, + hideLogo: true, + setLayoutMode: undefined, + toggleScreenSharing: undefined, + }, + globals: { + viewport: { value: "mobile2", isRotated: false }, + }, + parameters: { + ...Default.parameters, + }, +}; diff --git a/src/components/InCallFooter.tsx b/src/components/CallFooter.tsx similarity index 63% rename from src/components/InCallFooter.tsx rename to src/components/CallFooter.tsx index 00bf937c..3bc3e34b 100644 --- a/src/components/InCallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -7,6 +7,7 @@ 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 LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -19,7 +20,7 @@ import { ReactionToggleButton, LoudspeakerButton, } from "../button"; -import styles from "./InCallFooter.module.css"; +import styles from "./CallFooter.module.css"; import { LayoutToggle } from "../room/LayoutToggle"; import { type CallViewModel, @@ -32,44 +33,60 @@ export interface AudioOutputSwitcher { switch: () => void; } -export interface InCallFooterProps { +export interface FooterProps { ref?: Ref; + /** Children will only be visible if the component is wider than 5*/ + children?: JSX.Element | JSX.Element[] | false; /* This is needed for WindowMode = "flat" */ - asOverlay: boolean; - showFooter: boolean; - showControls: boolean; - hideSettingsButton: boolean; - hideLogo: boolean; + hideControls?: boolean; + /** hide the entire footer*/ + hidden?: boolean; /** Pip controls buttonSize and hides: settings button, layout switcher and logo */ - asPip: boolean; - gridMode: GridMode; - setGridMode: (mode: GridMode) => void; - openSettings: () => void; - audioEnabled: boolean; - videoEnabled: boolean; + asPip?: boolean; + /** The footer should be used as an overlay. + * (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; + + audioEnabled?: boolean; + /** Also controls if the audioMute button is disabled */ toggleAudio?: () => void; + videoEnabled?: boolean; + /** Also controls if the videoMute button is disabled */ toggleVideo?: () => void; - sharingScreen: boolean; + + sharingScreen?: boolean; toggleScreenSharing?: () => void; - supportsReactions: boolean; - reactionIdentifier: string; - reactionData: Pick; - audioOutputSwitcher: AudioOutputSwitcher | null; - hangup: () => void; - debugTileLayout: boolean; - tileStoreGeneration: number; + + /** 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; + + reactionIdentifier?: string; + reactionData?: Pick; + + hideLogo?: boolean; + // debug stuff + debugTileLayout?: boolean; + tileStoreGeneration?: number; } -export const InCallFooter: FC = ({ +export const CallFooter: FC = ({ ref, + children, asOverlay, - showFooter, - showControls, - hideSettingsButton, + hidden, + hideControls, hideLogo, asPip, - gridMode, - setGridMode, + layoutMode, + setLayoutMode, openSettings, audioEnabled, videoEnabled, @@ -77,7 +94,6 @@ export const InCallFooter: FC = ({ toggleVideo, sharingScreen, toggleScreenSharing, - supportsReactions, reactionIdentifier, reactionData, audioOutputSwitcher, @@ -87,8 +103,9 @@ export const InCallFooter: FC = ({ }) => { const buttons: JSX.Element[] = []; const buttonSize = asPip ? "sm" : "lg"; - const showSettingsButton = !hideSettingsButton && !asPip && showControls; - const showLayoutSwitcher = !asPip && showControls; + const showSettingsButton = + openSettings !== undefined && !asPip && !hideControls; + const showLayoutSwitcher = !asPip && !hideControls; const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout); const showLogo = !hideLogo && !asPip; if (showSettingsButton) { @@ -108,39 +125,45 @@ export const InCallFooter: FC = ({ , , ); - if (toggleScreenSharing !== null) { + if (toggleScreenSharing !== undefined) { buttons.push( , ); } - if (supportsReactions) { + if (reactionIdentifier) { buttons.push( ) + } key="raise_hand" className={styles.raiseHand} identifier={reactionIdentifier} @@ -150,7 +173,7 @@ export const InCallFooter: FC = ({ // In this PR we just move the button to the bottom bar. We do not yet update its appearance const audioOutputButton = useMemo(() => { - if (audioOutputSwitcher === null) return null; + if (audioOutputSwitcher === undefined) return null; return ( = ({ , ); - buttons.push( - , - ); + if (hangup) + buttons.push( + , + ); const logoDebugContainer = (
@@ -196,7 +220,7 @@ export const InCallFooter: FC = ({ ref={ref} className={classNames(styles.footer, { [styles.overlay]: asOverlay, - [styles.hidden]: !showFooter, + [styles.hidden]: hidden, })} >
@@ -207,16 +231,15 @@ export const InCallFooter: FC = ({ onClick={openSettings} /> )} - + {children} {showLogoDebugContainer && logoDebugContainer}
- - {showControls &&
{buttons}
} - {showLayoutSwitcher && ( + {!hideControls &&
{buttons}
} + {setLayoutMode && layoutMode && showLayoutSwitcher && ( )}
diff --git a/src/components/InCallFooter.stories.tsx b/src/components/InCallFooter.stories.tsx deleted file mode 100644 index f73184c0..00000000 --- a/src/components/InCallFooter.stories.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/* -Copyright 2026 Element Creations Ltd. - -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 { BehaviorSubject } from "rxjs"; -import { type ReactNode } from "react"; - -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { InCallFooter, type InCallFooterProps } from "./InCallFooter"; -import inCallViewStyles from "../room/InCallView.module.css"; - -function InCallFooterWrapper(props: InCallFooterProps): ReactNode { - return ( -
- -
- ); -} - -const meta = { - component: InCallFooterWrapper, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - asOverlay: false, - showFooter: true, - showControls: true, - hideSettingsButton: false, - hideLogo: true, - asPip: false, - gridMode: "grid", - audioEnabled: true, - videoEnabled: true, - sharingScreen: false, - supportsReactions: false, - audioOutputSwitcher: null, - debugTileLayout: false, - tileStoreGeneration: 0, - reactionData: { - handsRaised$: new BehaviorSubject({}), - reactions$: new BehaviorSubject({}), - }, - reactionIdentifier: "@user:example.com:DEVICE", - setGridMode: fn(), - openSettings: fn(), - toggleAudio: fn(), - toggleVideo: fn(), - toggleScreenSharing: fn(), - hangup: fn(), - }, - parameters: { - layout: "fullscreen", - }, - argTypes: { - gridMode: { control: "radio", options: ["grid", "spotlight"] }, - audioOutputSwitcher: { - control: "radio", - options: ["noOutputSwitcher", "earpiece", "speaker"], - mapping: { - noOutputSwitcher: null, - // This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one. - earpiece: { targetOutput: "speaker", switch: fn() }, - speaker: { targetOutput: "earpiece", switch: fn() }, - }, - }, - }, -}; - -export const WithLogo: Story = { - ...Default, - args: { - ...Default.args, - hideLogo: false, - }, -}; -export const WithAudioOutput: Story = { - ...Default, - args: { - ...Default.args, - audioOutputSwitcher: { targetOutput: "speaker", switch: fn() }, - }, -}; -export const Pip: Story = { - ...Default, - args: { - ...Default.args, - asPip: true, - }, -}; - -export const NoControlsWithLogo: Story = { - ...Default, - args: { - ...Default.args, - showControls: false, - hideLogo: false, - }, -}; - -export const DebugData: Story = { - ...Default, - args: { - ...Default.args, - debugTileLayout: true, - tileStoreGeneration: 74, - audioOutputSwitcher: null, - }, -}; -export const MobileLayout: Story = { - ...Default, - args: { - ...Default.args, - hideLogo: true, - debugTileLayout: true, - tileStoreGeneration: 74, - audioOutputSwitcher: null, - }, - globals: { - viewport: { value: "mobile2", isRotated: false }, - }, - parameters: { - ...Default.parameters, - }, -}; diff --git a/src/reactions/useReactionsSender.tsx b/src/reactions/useReactionsSender.tsx index afb9b789..1b7e099a 100644 --- a/src/reactions/useReactionsSender.tsx +++ b/src/reactions/useReactionsSender.tsx @@ -29,7 +29,7 @@ interface ReactionsSenderContextType { sendReaction: (reaction: ReactionOption) => Promise; } -const ReactionsSenderContext = createContext< +export const ReactionsSenderContext = createContext< ReactionsSenderContextType | undefined >(undefined); diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 5748397a..e37ad27c 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -44,7 +44,7 @@ import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices"; import { constant } from "../state/Behavior"; import { AppBar } from "../AppBar"; import { initializeWidget } from "../widget"; -import inCallFooterStyles from "../components/InCallFooter.module.css"; +import callFooterStyles from "../components/CallFooter.module.css"; initializeWidget(); vi.hoisted( @@ -211,9 +211,9 @@ describe("InCallView", () => { // Their visibility uses @media css queries, which cannot be tested in JSDOM, // but we can at least check that both buttons are rendered and have the correct classes. expect(btnA).toBeInTheDocument(); - expect(btnA).toHaveClass(inCallFooterStyles.settingsOnlyShowWide); + expect(btnA).toHaveClass(callFooterStyles.settingsOnlyShowWide); expect(btnB).toBeInTheDocument(); - expect(btnB).toHaveClass(inCallFooterStyles.settingsOnlyShowNarrow); + expect(btnB).toHaveClass(callFooterStyles.settingsOnlyShowNarrow); }); it("is accessible when showHeader is true", () => { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 87be4464..5ad1634a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -90,7 +90,7 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t import { type Layout } from "../state/layout-types.ts"; import { ObservableScope } from "../state/ObservableScope.ts"; import { useLatest } from "../useLatest.ts"; -import { InCallFooter } from "../components/InCallFooter.tsx"; +import { CallFooter } from "../components/CallFooter.tsx"; const logger = rootLogger.getChild("[InCallView]"); @@ -562,30 +562,29 @@ export const InCallView: FC = ({ matrixRoom.roomId, ); + // Only hide the settings button if we have an AppBar header and we are showing the header + const hideSettings = headerStyle === HeaderStyle.AppBar && showHeader; const footer = ( -