From 5da7dd64137f249b649893f607de0db293ad107c Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 9 Apr 2026 15:49:09 +0200 Subject: [PATCH] Move footer to storybook --- src/button/Button.tsx | 31 ++++ src/button/ReactionToggleButton.tsx | 8 +- src/components/InCallFooter.stories.tsx | 132 ++++++++++++++++ src/components/InCallFooter.tsx | 202 ++++++++++++++++++++++++ src/room/InCallView.tsx | 163 ++++--------------- 5 files changed, 396 insertions(+), 140 deletions(-) create mode 100644 src/components/InCallFooter.stories.tsx create mode 100644 src/components/InCallFooter.tsx diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 407b74cb..175cb22d 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -18,6 +18,7 @@ import { ShareScreenSolidIcon, OverflowHorizontalIcon, OverflowVerticalIcon, + VolumeOnSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import styles from "./Button.module.css"; @@ -126,6 +127,36 @@ export const EndCallButton: FC = ({ ); }; +interface LoudspeakerButtonProps extends ComponentPropsWithoutRef<"button"> { + size?: "sm" | "lg"; + /** The button will be rendered: + * true: currently in loudspeaker mode, pressing will switch to earpiece (rendered as enabled) + * false: currently in earpiece mode, pressing will switch to loudspeaker (rendered as disabled) + */ + isEarpieceTarget: boolean; +} + +export const LoudspeakerButton: FC = (props) => { + const { t } = useTranslation(); + const label = props.isEarpieceTarget + ? t("settings.devices.handset") + : t("settings.devices.loudspeaker"); + // if the target is the earpice, we are currently in loudspeaker mode. + const enabled = props.isEarpieceTarget; + return ( + + + + ); +}; + interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> { size?: "sm" | "lg"; } diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 28163321..6fe52308 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -165,13 +165,13 @@ export function ReactionPopupMenu({ interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { identifier: string; - vm: CallViewModel; + reactionData: Pick; size?: "sm" | "lg"; } export function ReactionToggleButton({ identifier, - vm, + reactionData, ...props }: ReactionToggleButtonProps): ReactNode { const { t } = useTranslation(); @@ -180,8 +180,8 @@ export function ReactionToggleButton({ const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [errorText, setErrorText] = useState(); - const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier]; - const canReact = !useBehavior(vm.reactions$)[identifier]; + const isHandRaised = !!useBehavior(reactionData.handsRaised$)[identifier]; + const canReact = !useBehavior(reactionData.reactions$)[identifier]; useEffect(() => { // Clear whenever the reactions menu state changes. diff --git a/src/components/InCallFooter.stories.tsx b/src/components/InCallFooter.stories.tsx new file mode 100644 index 00000000..336884a4 --- /dev/null +++ b/src/components/InCallFooter.stories.tsx @@ -0,0 +1,132 @@ +/* +Copyright 2025 New Vector 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, + showSettingsButton: true, + showLogo: false, + 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, + showLogo: true, + }, +}; +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, + showLogo: true, + }, +}; + +export const DebugData: Story = { + ...Default, + args: { + ...Default.args, + debugTileLayout: true, + tileStoreGeneration: 74, + audioOutputSwitcher: null, + }, +}; +export const MobileLayout: Story = { + ...Default, + args: { + ...Default.args, + showLogo: true, + debugTileLayout: true, + tileStoreGeneration: 74, + audioOutputSwitcher: null, + }, + globals: { + viewport: { value: "mobile2", isRotated: false }, + }, + parameters: { + ...Default.parameters, + }, +}; diff --git a/src/components/InCallFooter.tsx b/src/components/InCallFooter.tsx new file mode 100644 index 00000000..f5ca656c --- /dev/null +++ b/src/components/InCallFooter.tsx @@ -0,0 +1,202 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +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 LogoMark from "../icons/LogoMark.svg?react"; +import LogoType from "../icons/LogoType.svg?react"; +import { + EndCallButton, + MicButton, + VideoButton, + ShareScreenButton, + SettingsButton, + ReactionToggleButton, + LoudspeakerButton, +} from "../button"; +import styles from "../room/InCallView.module.css"; +import { LayoutToggle } from "../room/LayoutToggle"; +import { + type CallViewModel, + type GridMode, +} from "../state/CallViewModel/CallViewModel"; +import { useAppBarSecondaryButton } from "../AppBar"; + +export interface AudioOutputSwitcher { + targetOutput: string; + switch: () => void; +} + +export interface InCallFooterProps { + ref?: Ref; + /* This is needed for WindowMode = "flat" */ + asOverlay: boolean; + showFooter: boolean; + showControls: boolean; + showSettingsButton: boolean; + showLogo: boolean; + asPip: boolean; + gridMode: GridMode; + setGridMode: (mode: GridMode) => void; + openSettings: () => void; + audioEnabled: boolean; + videoEnabled: boolean; + toggleAudio?: () => void; + toggleVideo?: () => void; + sharingScreen: boolean; + toggleScreenSharing?: () => void; + supportsReactions: boolean; + reactionIdentifier: string; + reactionData: Pick; + audioOutputSwitcher: AudioOutputSwitcher | null; + hangup: () => void; + debugTileLayout: boolean; + tileStoreGeneration: number; +} + +export const InCallFooter: FC = ({ + ref, + asOverlay, + showFooter, + showControls, + showSettingsButton, + showLogo, + asPip, + gridMode, + setGridMode, + openSettings, + audioEnabled, + videoEnabled, + toggleAudio, + toggleVideo, + sharingScreen, + toggleScreenSharing, + supportsReactions, + reactionIdentifier, + reactionData, + audioOutputSwitcher, + hangup, + debugTileLayout, + tileStoreGeneration, +}) => { + const buttons: JSX.Element[] = []; + const buttonSize = asPip ? "sm" : "lg"; + + buttons.push( + , + , + ); + + if (toggleScreenSharing !== null) { + buttons.push( + , + ); + } + + if (supportsReactions) { + buttons.push( + , + ); + } + + // 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; + return ( + + ); + }, [audioOutputSwitcher, buttonSize]); + + if (audioOutputButton) buttons.push(audioOutputButton); + + useAppBarSecondaryButton( + , + ); + + buttons.push( + , + ); + + const logo = ( +
+ {showLogo && ( + <> + + + + )} + {debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined} +
+ ); + + return ( +
+
+ {showControls && showSettingsButton && !asPip && ( + + )} + + {(!asPip || (!showLogo && !debugTileLayout)) && logo} +
+ + {showControls &&
{buttons}
} + {showControls && !asPip && ( + + )} +
+ ); +}; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index cf445170..12374d05 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { IconButton, Tooltip } from "@vector-im/compound-web"; import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk"; import { type FC, @@ -25,22 +24,9 @@ import classNames from "classnames"; import { BehaviorSubject, map } from "rxjs"; import { useObservable } from "observable-hooks"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; -import { - VoiceCallSolidIcon, - VolumeOnSolidIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; import { useTranslation } from "react-i18next"; -import LogoMark from "../icons/LogoMark.svg?react"; -import LogoType from "../icons/LogoType.svg?react"; -import { - EndCallButton, - MicButton, - VideoButton, - ShareScreenButton, - SettingsButton, - ReactionToggleButton, -} from "../button"; +import { SettingsButton } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; @@ -55,7 +41,6 @@ import { useMergedRefs } from "../useMergedRefs"; import { type MuteStates } from "../state/MuteStates"; import { type MatrixInfo } from "./VideoPreview"; import { InviteButton } from "../button/InviteButton"; -import { LayoutToggle } from "./LayoutToggle"; import { type CallViewModel, createCallViewModel$, @@ -106,6 +91,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"; const logger = rootLogger.getChild("[InCallView]"); @@ -575,133 +561,38 @@ export const InCallView: FC = ({ matrixRoom.roomId, ); - const buttons: JSX.Element[] = []; - - const buttonSize = layout.type === "pip" ? "sm" : "lg"; - buttons.push( - , - , - ); - if (vm.toggleScreenSharing !== null) { - buttons.push( - , - ); - } - if (supportsReactions) { - buttons.push( - , - ); - } - - // In this PR we just move the button ot the bottom bar. We do not yet update its apperance - const audioOutputButton = useMemo(() => { - if (audioOutputSwitcher === null) return null; - const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece"; - const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon; - const label = isEarpieceTarget - ? t("settings.devices.handset") - : t("settings.devices.loudspeaker"); - - return ( - - { - audioOutputSwitcher.switch(); - }} - > - - - - ); - }, [t, audioOutputSwitcher]); - if (audioOutputButton) buttons.push(audioOutputButton); - useAppBarSecondaryButton( , ); - buttons.push( - , - ); - - const logo = ( -
- - - {/* Don't mind this odd placement, it's just a little debug label */} - {debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined} -
- ); - const footer = ( -
-
- {showControls && - headerStyle !== HeaderStyle.AppBar && - layout.type !== "pip" && ( - - )} - - {headerStyle !== "none" && logo} -
- - {showControls &&
{buttons}
} - {showControls && ( - - )} -
+ asOverlay={windowMode === "flat"} + // TODO this should be computed in the view model! + showFooter={!showFooter || (!showControls && headerStyle === "none")} + showControls={showControls} + showLogo={headerStyle !== HeaderStyle.None} + showSettingsButton={headerStyle !== HeaderStyle.AppBar} + asPip={layout.type === "pip"} + gridMode={gridMode} + setGridMode={setGridMode} + openSettings={openSettings} + audioEnabled={audioEnabled} + videoEnabled={videoEnabled} + toggleAudio={toggleAudio ?? undefined} + toggleVideo={toggleVideo ?? undefined} + sharingScreen={sharingScreen} + toggleScreenSharing={vm.toggleScreenSharing ?? undefined} + supportsReactions={supportsReactions} + reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`} + reactionData={vm} + audioOutputSwitcher={audioOutputSwitcher} + hangup={vm.hangup} + debugTileLayout={debugTileLayout} + tileStoreGeneration={tileStoreGeneration} + /> ); - const allConnections = useBehavior(vm.allConnections$); return (