diff --git a/.storybook/main.ts b/.storybook/main.ts index 3bb79035..977eca73 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,7 +1,14 @@ +/* +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 type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { - stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], addons: ["@storybook/addon-docs"], framework: "@storybook/react-vite", }; diff --git a/.storybook/manager.ts b/.storybook/manager.ts index 2aa8e054..1177be2f 100644 --- a/.storybook/manager.ts +++ b/.storybook/manager.ts @@ -1,3 +1,10 @@ +/* +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 { create } from "storybook/theming"; import { addons } from "storybook/manager-api"; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 74df1899..757c1f8a 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,3 +1,10 @@ +/* +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 type { Preview } from "@storybook/react-vite"; import { TooltipProvider } from "@vector-im/compound-web"; import i18n from "i18next"; diff --git a/playwright/mobile/create-call-mobile.spec.ts b/playwright/mobile/create-call-mobile.spec.ts index 45424611..b66ad6c4 100644 --- a/playwright/mobile/create-call-mobile.spec.ts +++ b/playwright/mobile/create-call-mobile.spec.ts @@ -103,7 +103,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/AppBar.tsx b/src/AppBar.tsx index 86d150ee..9939f950 100644 --- a/src/AppBar.tsx +++ b/src/AppBar.tsx @@ -19,6 +19,7 @@ import { import { Heading, IconButton, Tooltip } from "@vector-im/compound-web"; import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { useTranslation } from "react-i18next"; +import { logger } from "matrix-js-sdk/lib/logger"; import { Header, LeftNav, RightNav } from "./Header"; import { platform } from "./Platform"; @@ -49,7 +50,9 @@ export const AppBar: FC = ({ children }) => { const [title, setTitle] = useState(""); const [hidden, setHidden] = useState(false); - const [secondaryButton, setSecondaryButton] = useState(null); + const [secondaryButton, setSecondaryButton] = useState( + null, + ); const context = useMemo( () => ({ setTitle, setSecondaryButton, setHidden }), [setTitle, setHidden, setSecondaryButton], @@ -114,6 +117,10 @@ export function useAppBarHidden(hidden: boolean): void { if (setHidden !== undefined) { setHidden(hidden); return (): void => setHidden(false); + } else if (platform !== "desktop") { + logger.warn( + "[AppBar] useAppBarHidden called without AppBarContext provider, this will have no effect", + ); } }, [setHidden, hidden]); } @@ -129,6 +136,10 @@ export function useAppBarSecondaryButton(button: ReactNode): void { if (setSecondaryButton !== undefined) { setSecondaryButton(button); return (): void => setSecondaryButton(""); + } else if (platform !== "desktop") { + logger.warn( + "[AppBar] useAppBarSecondaryButton called without AppBarContext provider, this will have no effect", + ); } }, [button, setSecondaryButton]); } diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 11834506..73938794 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -22,9 +22,12 @@ import { ShareScreenSolidIcon, OverflowHorizontalIcon, OverflowVerticalIcon, + VolumeOnSolidIcon, + VolumeOffSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import styles from "./Button.module.css"; +import callFooterStyles from "../components/CallFooter.module.css"; import { platform } from "../Platform"; interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { @@ -130,32 +133,89 @@ export const EndCallButton: FC = ({ ); }; +interface LoudspeakerButtonProps extends ComponentPropsWithoutRef<"button"> { + size?: "sm" | "lg"; + loudspeakerModeEnabled: boolean; +} +export const LoudspeakerButton: FC = ({ + loudspeakerModeEnabled, + ...props +}) => { + const { t } = useTranslation(); + // if the target is the earpice, we are currently in loudspeaker mode. + const label = loudspeakerModeEnabled + ? t("settings.devices.loudspeaker") + : t("settings.devices.handset"); + return ( + + + + ); +}; + +function classNamesForScreenWidth( + className?: string, + forScreenWidth?: "wide" | "narrow", +): string { + return classNames(className, { + [callFooterStyles.settingsOnlyShowWide]: forScreenWidth === "wide", + [callFooterStyles.settingsOnlyShowNarrow]: forScreenWidth === "narrow", + }); +} + interface SettingsIconButtonProps extends ComponentPropsWithoutRef<"button"> { + /** If this buttons should be setup to be used in the app bar */ + showForScreenWidth?: "wide" | "narrow"; kind?: "secondary" | "primary"; } -export const SettingsIconButton: FC = (props) => { +export const SettingsIconButton: FC = ({ + showForScreenWidth, + className, + ...props +}) => { const { t } = useTranslation(); const Icon = platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon; return ( - + ); }; -// interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> { -// size?: "sm" | "lg"; -// } -// const SettingsButton: FC = (props) => { -// const { t } = useTranslation(); -// const Icon = -// platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon; -// return ( -// -// -// -// ); -// }; +interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> { + size?: "sm" | "lg"; + /** If this buttons should be setup to be used in the app bar */ + showForScreenWidth?: "wide" | "narrow"; +} +export const SettingsButton: FC = ({ + showForScreenWidth, + className, + ...props +}) => { + const { t } = useTranslation(); + return ( + + + + ); +}; diff --git a/src/button/ReactionToggleButton.test.tsx b/src/button/ReactionToggleButton.test.tsx index c6010562..5c8d375c 100644 --- a/src/button/ReactionToggleButton.test.tsx +++ b/src/button/ReactionToggleButton.test.tsx @@ -37,7 +37,13 @@ function TestComponent({ vm={vm} rtcSession={rtcSession.asMockedSession()} > - + ); diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 28163321..39804a5f 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -28,13 +28,14 @@ import classNames from "classnames"; import { useReactionsSender } from "../reactions/useReactionsSender"; import styles from "./ReactionToggleButton.module.css"; import { + type RaisedHandInfo, type ReactionOption, ReactionSet, ReactionsRowSize, } from "../reactions"; import { Modal } from "../Modal"; -import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { useBehavior } from "../useBehavior"; +import { type Behavior } from "../state/Behavior"; interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { raised: boolean; @@ -163,15 +164,22 @@ export function ReactionPopupMenu({ ); } +export interface ReactionData { + handsRaised$: Behavior>; + /** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/ + reactions$: Behavior>; +} + interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { + reactionData: ReactionData; identifier: string; - vm: CallViewModel; size?: "sm" | "lg"; + /** List of participants raising their hand */ } export function ReactionToggleButton({ identifier, - vm, + reactionData: { handsRaised$, reactions$ }, ...props }: ReactionToggleButtonProps): ReactNode { const { t } = useTranslation(); @@ -180,8 +188,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(handsRaised$)[identifier]; + const canReact = !useBehavior(reactions$)[identifier]; useEffect(() => { // Clear whenever the reactions menu state changes. diff --git a/src/components/CallFooter.mdx b/src/components/CallFooter.mdx new file mode 100644 index 00000000..b94131a0 --- /dev/null +++ b/src/components/CallFooter.mdx @@ -0,0 +1,37 @@ +{/** +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. +**/} + +{/** +This is a custom doc page overwriting the default autodocs tag. +This can be done by using the same filename as the component +With the help of Primary, Controls,Stories the overhead is minimal +**/} + +import { + Meta, + Primary, + Controls, + Stories, + Title, + Subtitle, +} from "@storybook/addon-docs/blocks"; +import * as CallFooterStories from "./CallFooter.stories"; + + + + 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/CallFooter.module.css b/src/components/CallFooter.module.css new file mode 100644 index 00000000..d2e54649 --- /dev/null +++ b/src/components/CallFooter.module.css @@ -0,0 +1,158 @@ +/* +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. +*/ + +.footer { + position: sticky; + inset-block-end: 0; + z-index: var(--call-view-header-footer-layer); + display: grid; + grid-template-columns: 1fr auto 1fr; + grid-template-areas: ". buttons layout"; + align-items: center; + gap: var(--cpd-space-3x); + padding: var(--cpd-space-10x) var(--cpd-space-6x); + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0) 0%, + var(--cpd-color-bg-canvas-default) 100% + ); +} + +.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. */ + opacity: 1; + transition: opacity 0.15s; +} + +.footer.overlay.hidden { + display: grid; + opacity: 0; + pointer-events: none; + /* Switch to position: absolute so the footer takes up no space in the layout + when hidden. */ + position: absolute; + inset-block-end: 0; + inset-inline: 0; +} + +.footer.overlay:has(:focus-visible) { + opacity: 1; + pointer-events: initial; +} + +.settingsLogoContainer { + display: flex; + align-items: center; + gap: var(--cpd-space-4x); + flex-direction: row; + flex-wrap: nowrap; +} + +.logo { + justify-self: start; + display: flex; + align-items: center; + gap: var(--cpd-space-2x); + padding-inline-start: var(--cpd-space-1x); +} + +.buttons { + grid-area: buttons; + justify-self: center; + display: flex; + gap: var(--cpd-space-3x); +} + +.layout { + grid-area: layout; + justify-self: end; +} + +/*First hide the logo*/ +@media (max-width: 750px) { + .logo { + display: none; + } +} + +.settingsOnlyShowNarrow { + display: none; +} +.settingsOnlyShowWide { + display: inherit; +} + +/* +With the logo hidden >500px is enough space to show overflow, buttons, layout. +Once we exceed 500 we hide everything except the buttons. +*/ +@media (max-width: 500px) { + .footer { + grid-template-areas: "buttons buttons buttons"; + } + + .settingsOnlyShowNarrow { + display: inherit; + } + .settingsOnlyShowWide { + display: none; + } + + .settingsLogoContainer { + display: none; + } + + .layout { + display: none !important; + } +} + +@media (max-height: 800px) { + .footer { + padding-block: var(--cpd-space-8x); + } +} + +@media (max-height: 400px) { + .footer { + padding-block: var(--cpd-space-4x); + } +} + +@media (max-width: 370px) { + .shareScreen { + display: none; + } + + /* PIP custom css */ + @media (max-height: 400px) { + .shareScreen { + display: flex; + } + .footer { + padding-block-start: var(--cpd-space-3x); + padding-block-end: var(--cpd-space-2x); + } + } +} + +@media (max-width: 320px) { + .raiseHand { + display: none; + } +} + +@media (min-width: 800px) { + .buttons { + gap: var(--cpd-space-4x); + } +} diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx new file mode 100644 index 00000000..6062d0bb --- /dev/null +++ b/src/components/CallFooter.stories.tsx @@ -0,0 +1,242 @@ +/* +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", + audioEnabled: true, + videoEnabled: true, + 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: "earpiece", switch: fn() }, + earpiece: { targetOutput: "speaker", 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 WithAudioOutputSpeaker: Story = { + ...Default, + args: { + ...Default.args, + audioOutputSwitcher: { targetOutput: "earpiece", switch: fn() }, + }, +}; + +export const WithAudioOutputEarpiece: 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/CallFooter.tsx b/src/components/CallFooter.tsx new file mode 100644 index 00000000..4e728d3b --- /dev/null +++ b/src/components/CallFooter.tsx @@ -0,0 +1,243 @@ +/* +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 { 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"; +import { + EndCallButton, + MicButton, + VideoButton, + ShareScreenButton, + SettingsButton, + ReactionToggleButton, + LoudspeakerButton, + SettingsIconButton, + type ReactionData, +} from "../button"; +import styles from "./CallFooter.module.css"; +import { LayoutToggle } from "../room/LayoutToggle"; +import { type GridMode } from "../state/CallViewModel/CallViewModel"; + +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; + /** 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.*/ + asOverlay?: boolean; + + layoutMode?: GridMode; + /** Also controls if the layout button is visible */ + setLayoutMode?: (mode: GridMode) => void; + + sharingScreen?: boolean; + toggleScreenSharing?: () => void; + + /** 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?: ReactionData; + + hideLogo?: boolean; + // debug stuff + debugTileLayout?: boolean; + tileStoreGeneration?: number; +} + +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, +}) => { + const buttons: JSX.Element[] = []; + const buttonSize = asPip ? "sm" : "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. + buttons.push( + , + ); + } + + buttons.push( + , + , + ); + + if (toggleScreenSharing !== undefined) { + buttons.push( + , + ); + } + + if (reactionIdentifier && reactionData) { + 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 === undefined) return null; + return ( + audioOutputSwitcher.switch()} + loudspeakerModeEnabled={audioOutputSwitcher.targetOutput === "earpiece"} + /> + ); + }, [audioOutputSwitcher, buttonSize]); + + if (audioOutputButton) buttons.push(audioOutputButton); + + if (hangup) + buttons.push( + , + ); + + const logoDebugContainer = ( +
+ {showLogo && ( + <> + + + + )} + {debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined} +
+ ); + + return ( +
+
+ {showSettingsButton && ( + + )} + {children} + {showLogoDebugContainer && logoDebugContainer} +
+ {!hideControls &&
{buttons}
} + {setLayoutMode && layoutMode && showLayoutSwitcher && ( + + )} +
+ ); +}; 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/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index a3d3a049..6ac08422 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -49,7 +49,6 @@ import { LazyEventEmitter } from "../LazyEventEmitter"; import { MatrixRTCTransportMissingError } from "../utils/errors"; import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { MediaDevicesContext } from "../MediaDevicesContext"; -import { HeaderStyle } from "../UrlParams"; import { constant } from "../state/Behavior"; import { type MuteStates } from "../state/MuteStates.ts"; @@ -173,7 +172,6 @@ function createGroupCallView( confineToRoom={false} preload={false} skipLobby={false} - header={HeaderStyle.Standard} rtcSession={rtcSession.asMockedSession()} muteStates={muteState} widget={widget} diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index dfd11ff3..95b77e73 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -93,7 +93,6 @@ interface Props { confineToRoom: boolean; preload: UrlParams["preload"]; skipLobby: UrlParams["skipLobby"]; - header: HeaderStyle; rtcSession: MatrixRTCSession; joined: boolean; setJoined: (value: boolean) => void; @@ -107,7 +106,6 @@ export const GroupCallView: FC = ({ confineToRoom, preload, skipLobby, - header, rtcSession, joined, setJoined, @@ -182,6 +180,7 @@ export const GroupCallView: FC = ({ perParticipantE2EE, returnToLobby, password: passwordFromUrl, + header, } = useUrlParams(); const e2eeSystem = useRoomEncryptionSystem(room.roomId); @@ -437,7 +436,7 @@ export const GroupCallView: FC = ({ muteStates={muteStates} onEnter={() => setJoined(true)} confineToRoom={confineToRoom} - hideHeader={header === HeaderStyle.None} + hideHeader={header !== HeaderStyle.Standard} participantCount={participantCount} onShareClick={onShareClick} /> @@ -463,7 +462,6 @@ export const GroupCallView: FC = ({ rtcSession={rtcSession as MatrixRTCSession} matrixRoom={room} onLeft={onLeft} - header={header} muteStates={muteStates} e2eeSystem={e2eeSystem} //otelGroupCallMembership={otelGroupCallMembership} diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 08fc89fb..390d6058 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -31,144 +31,12 @@ Please see LICENSE in the repository root for full details. background: none; } -.footer { - position: sticky; - inset-block-end: 0; - z-index: var(--call-view-header-footer-layer); - display: grid; - grid-template-columns: 1fr auto 1fr; - grid-template-areas: ". buttons layout"; - align-items: center; - gap: var(--cpd-space-3x); - padding: var(--cpd-space-10x) var(--cpd-space-6x); - background: linear-gradient( - 180deg, - rgba(0, 0, 0, 0) 0%, - var(--cpd-color-bg-canvas-default) 100% - ); -} - -.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. */ - opacity: 1; - transition: opacity 0.15s; -} - -.footer.overlay.hidden { - display: grid; - opacity: 0; - pointer-events: none; - /* Switch to position: absolute so the footer takes up no space in the layout - when hidden. */ - position: absolute; - inset-block-end: 0; - inset-inline: 0; -} - -.footer.overlay:has(:focus-visible) { - opacity: 1; - pointer-events: initial; -} - -.settingsLogoContainer { - display: flex; - gap: var(--cpd-space-4x); - flex-direction: row; - flex-wrap: nowrap; -} - -.logo { - justify-self: start; - display: flex; - align-items: center; - gap: var(--cpd-space-2x); - padding-inline-start: var(--cpd-space-1x); -} - -.buttons { - grid-area: buttons; - justify-self: center; - display: flex; - gap: var(--cpd-space-3x); -} - -.layout { - grid-area: layout; - justify-self: end; -} - -/*First hide the logo*/ -@media (max-width: 750px) { - .logo { - display: none; - } -} - -/* -With the logo hidden >500px is enough space to show overflow, buttons, layout. -Once we exceed 500 we hide everything except the buttons. -*/ -@media (max-width: 500px) { - .footer { - grid-template-areas: "buttons buttons buttons"; - } - - /*.settingsLogoContainer { - display: none; - }*/ - - .layout { - display: none !important; - } -} - -@media (max-height: 800px) { - .footer { - padding-block: var(--cpd-space-8x); - } -} - -@media (max-height: 400px) { - .footer { - padding-block: var(--cpd-space-4x); - } -} - -@media (max-width: 370px) { - .shareScreen { - display: none; - } - - /* PIP custom css */ - @media (max-height: 400px) { - .shareScreen { - display: flex; - } - .footer { - padding-block-start: var(--cpd-space-3x); - padding-block-end: var(--cpd-space-2x); - } - } -} - @media (max-width: 320px) { - .invite, - .raiseHand { + .invite { display: none; } } -@media (min-width: 800px) { - .buttons { - gap: var(--cpd-space-4x); - } -} - .fixedGrid { position: absolute; inline-size: 100%; diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 5ee8e288..c23a9dcb 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -17,7 +17,7 @@ import { import { render, type RenderResult } from "@testing-library/react"; import { type LocalParticipant } from "livekit-client"; import { BehaviorSubject, of } from "rxjs"; -import { BrowserRouter } from "react-router-dom"; +import { BrowserRouter, MemoryRouter } from "react-router-dom"; import { TooltipProvider } from "@vector-im/compound-web"; import { RoomContext, useLocalParticipant } from "@livekit/components-react"; import userEvent from "@testing-library/user-event"; @@ -34,13 +34,15 @@ import { } from "../utils/test"; import { E2eeType } from "../e2ee/e2eeType"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; +import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel"; import { alice, local } from "../utils/test-fixtures"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer"; import { MediaDevicesContext } from "../MediaDevicesContext"; -import { HeaderStyle } from "../UrlParams"; import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices"; +import { constant } from "../state/Behavior"; +import { AppBar } from "../AppBar"; import { initializeWidget } from "../widget"; initializeWidget(); @@ -97,6 +99,11 @@ beforeEach(() => { }); interface CreateInCallViewArgs { mediaDevices?: ECMediaDevices; + callViewModelOptions?: Partial; + /** If set, uses a MemoryRouter with this as the initial entry instead of BrowserRouter */ + initialRoute?: string; + /** If true, wraps the rendered tree in an AppBar provider */ + withAppBar?: boolean; } function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { rtcSession: MockRTCSession; @@ -115,47 +122,59 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { [local, alice], undefined, mediaDevices, - {}, + args.callViewModelOptions, ); rtcSession.joined = true; const room = rtcSession.room; const client = room.client; + + const Router = args.initialRoute + ? ({ children }: { children: React.ReactNode }): React.ReactNode => ( + + {children} + + ) + : BrowserRouter; + + const inCallView = ( + + ); + + const content = args.withAppBar ? {inCallView} : inCallView; + const renderResult = render( - + - - - + {content} - , + , ); return { ...renderResult, @@ -170,6 +189,57 @@ describe("InCallView", () => { expect(container).toMatchSnapshot(); }); }); + describe("settings button with AppBar header", () => { + it("mobile landscape, is accessible when showHeader is false", () => { + // windowSize with height <= 600 results in "flat" windowMode, + // which means showHeader$ emits false. + const { getAllByRole } = createInCallView({ + initialRoute: "/?header=app_bar", + withAppBar: true, + callViewModelOptions: { + // Set windowMode$ to "flat" (height <= 600) + windowSize$: constant({ width: 1000, height: 500 }), + }, + }); + // When showHeader is false, hideSettingsButton is false, + // so the settings button is visible in the footer. + const settingsBtn = getAllByRole("button", { name: "Settings" }); + // here we check for two settings buttons because there are two buttons in the bottom bar. One for the + // the narrow layout and another one for the wide layout. + // 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(settingsBtn.length).toBe(2); + expect(settingsBtn[0]).toHaveAttribute( + "data-testid", + "settings-bottom-left", + ); + expect(settingsBtn[0]).toBeVisible(); + }); + + it("mobile portrait, is accessible when showHeader is true", () => { + // windowSize with height > 600 and width > 600 results in "normal" windowMode, + // which means showHeader$ emits true. + const { getAllByRole } = createInCallView({ + initialRoute: "/?header=app_bar", + withAppBar: true, + callViewModelOptions: { + // Set windowMode$ to "normal" (height >= 600) + windowSize$: constant({ width: 1000, height: 800 }), + }, + }); + // When showHeader is true and headerStyle is AppBar, + // hideSettingsButton is true in the footer, but the settings + // button is rendered in the AppBar via useAppBarSecondaryButton. + const settingsBtns = getAllByRole("button", { name: "Settings" }); + + expect(settingsBtns.length).toBe(1); + expect(settingsBtns[0]).toHaveAttribute( + "data-testid", + "settings-app-bar", + ); + expect(settingsBtns[0]).toBeVisible(); + }); + }); describe("audioOutputSwitcher", () => { it("is visible and can be clicked", async () => { const user = userEvent.setup(); @@ -183,9 +253,10 @@ describe("InCallView", () => { ["earpiece-id", { type: "earpiece" }], ]), ); - const selected$ = new BehaviorSubject< - { id: string; virtualEarpiece: boolean } | undefined - >({ id: "speaker-id", virtualEarpiece: false }); + const selected$ = new BehaviorSubject({ + id: "speaker-id", + virtualEarpiece: false, + }); const mediaDevices = mockMediaDevices({ audioOutput: { @@ -197,8 +268,7 @@ describe("InCallView", () => { const { getByRole } = createInCallView({ mediaDevices }); // The button should be visible. When current output is "speaker", - // the switcher targets "earpiece", so the tooltip label is "Handset". - const audioOutputBtn = getByRole("button", { name: "Handset" }); + const audioOutputBtn = getByRole("button", { name: "Loudspeaker" }); expect(audioOutputBtn).toBeVisible(); await user.click(audioOutputBtn); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 14ad25e8..4940f4d8 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,8 @@ 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, - ReactionToggleButton, - SettingsIconButton, -} from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; @@ -55,7 +40,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 +90,8 @@ 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 { CallFooter } from "../components/CallFooter.tsx"; +import { SettingsIconButton } from "../button/Button.tsx"; const logger = rootLogger.getChild("[InCallView]"); @@ -185,7 +171,6 @@ export interface InCallViewProps { rtcSession: MatrixRTCSession; matrixRoom: MatrixRoom; muteStates: MuteStates; - header: HeaderStyle; onShareClick: (() => void) | null; } @@ -195,8 +180,6 @@ export const InCallView: FC = ({ matrixInfo, matrixRoom, muteStates, - - header: headerStyle, onShareClick, }) => { const { t } = useTranslation(); @@ -220,7 +203,7 @@ export const InCallView: FC = ({ // Merge the refs so they can attach to the same element const containerRef = useMergedRefs(containerRef1, containerRef2); - const { showControls } = useUrlParams(); + const { showControls, header: headerStyle } = useUrlParams(); const muteAllAudio = useBehavior(muteAllAudio$); @@ -378,7 +361,11 @@ export const InCallView: FC = ({ let header: ReactNode = null; if (showHeader) { switch (headerStyle) { - case "none": + case HeaderStyle.AppBar: { + // dont build a header here. The AppBar will take care of it. + break; + } + case HeaderStyle.None: // Cosmetic header to fill out space while still affecting the bounds of // the grid header = ( @@ -388,7 +375,7 @@ export const InCallView: FC = ({ /> ); break; - case "standard": + case HeaderStyle.Standard: header = (
= ({ 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); - + const settingsButtonInAppBar = + headerStyle === HeaderStyle.AppBar && showHeader; useAppBarSecondaryButton( - , - ); - - buttons.push( - , ); - const logo = ( -
- - - {/* Don't mind this odd placement, it's just a little debug label */} - {debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined} -
- ); - + // Only hide the settings button if we have an AppBar header and we are showing the header const footer = ( -
-
- {showControls && - // Settings button is also shown in the app bar if present - headerStyle !== HeaderStyle.AppBar && - layout.type !== "pip" && ( - - )} - - {headerStyle !== "none" && logo} -
- - {showControls &&
{buttons}
} - {showControls && ( - - )} -
+ hidden={!showFooter} + hideControls={!showControls} + asOverlay={windowMode === "flat"} + asPip={layout.type === "pip"} + // Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None. + hideLogo={headerStyle !== HeaderStyle.Standard} + layoutMode={gridMode} + setLayoutMode={setGridMode} + audioEnabled={audioEnabled} + toggleAudio={toggleAudio ?? undefined} + videoEnabled={videoEnabled} + toggleVideo={toggleVideo ?? undefined} + sharingScreen={sharingScreen} + toggleScreenSharing={vm.toggleScreenSharing ?? undefined} + reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`} + reactionData={supportsReactions ? vm : undefined} + audioOutputSwitcher={audioOutputSwitcher ?? undefined} + // Only pass the openSettings function if the settings button is not in the app bar. + // If there is no fn the button will be hidden in the footer. + openSettings={settingsButtonInAppBar ? undefined : openSettings} + hangup={vm.hangup} + //Debug props + debugTileLayout={debugTileLayout} + tileStoreGeneration={tileStoreGeneration} + /> ); - const allConnections = useBehavior(vm.allConnections$); return ( diff --git a/src/room/LayoutToggle.tsx b/src/room/LayoutToggle.tsx index 52341e42..98ed91d3 100644 --- a/src/room/LayoutToggle.tsx +++ b/src/room/LayoutToggle.tsx @@ -33,7 +33,7 @@ export const LayoutToggle: FC = ({ layout, setLayout, className }) => { ); return ( -
+
= ({ layout, setLayout, className }) => { /> -
+ ); }; diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 82d3b354..367dc8df 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -33,12 +33,6 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { type MatrixInfo, VideoPreview } from "./VideoPreview"; import { type MuteStates } from "../state/MuteStates"; import { InviteButton } from "../button/InviteButton"; -import { - EndCallButton, - MicButton, - SettingsIconButton, - VideoButton, -} from "../button/Button"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; import { useMediaQuery } from "../useMediaQuery"; import { E2eeType } from "../e2ee/e2eeType"; @@ -52,6 +46,7 @@ import { import { usePageTitle } from "../usePageTitle"; import { getValue } from "../utils/observable"; import { useBehavior } from "../useBehavior"; +import { CallFooter } from "../components/CallFooter"; interface Props { client: MatrixClient; @@ -226,23 +221,18 @@ export const LobbyView: FC = ({ {!recentsButtonInFooter && recentsButton} -
+ {recentsButtonInFooter && recentsButton} - -
- - - {!confineToRoom && } -
-
+ {client && ( { confineToRoom={confineToRoom} preload={preload} skipLobby={skipLobby || wasInWaitForInviteState.current} - header={header} muteStates={muteStates} /> ) diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 955c061e..be2fd99c 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -170,8 +170,9 @@ exports[`InCallView > rendering > renders 1`] = ` > -
rendering > renders 1`] = ` /> rendering > renders 1`] = ` d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z" /> -
+ diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index d33392e6..e298bcfd 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -82,7 +82,7 @@ import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { type MuteStates } from "../MuteStates"; -import { getUrlParams } from "../../UrlParams"; +import { getUrlParams, HeaderStyle } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { ElementWidgetActions, widget } from "../../widget"; import { @@ -180,6 +180,8 @@ export interface CallViewModelOptions { connectionFactory?: ConnectionFactory; /** The version & compatibility mode of MatrixRTC that we should use. */ matrixRTCMode$?: Behavior; + /** Optional behavior overriding for the screensharing, for testing */ + toggleScreensharing?: () => void; } // Do not play any sounds if the participant count has exceeded this @@ -1326,7 +1328,11 @@ export function createCallViewModel$( windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")), ); - const showFooter$ = scope.behavior( + const urlParams = getUrlParams(); + const showFooterUrlParams = !( + urlParams.header === HeaderStyle.None && urlParams.showControls === false + ); + const showFooterLayout$ = scope.behavior( windowMode$.pipe( switchMap((mode) => { switch (mode) { @@ -1380,7 +1386,11 @@ export function createCallViewModel$( }), ), ); - + const showFooter$ = scope.behavior( + showFooterLayout$.pipe( + map((showFooter) => showFooter && showFooterUrlParams), + ), + ); /** * Whether audio is currently being output through the earpiece. */ @@ -1507,7 +1517,8 @@ export function createCallViewModel$( * Callback to toggle screen sharing. If null, screen sharing is not possible. */ // reassigned here to make it publicly accessible - const toggleScreenSharing = localMembership.toggleScreenSharing; + const toggleScreenSharing = + options.toggleScreensharing ?? localMembership.toggleScreenSharing; const errors$ = scope.behavior<{ transportError?: ElementCallError;