diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 20c7c4c0..7090a338 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -88,6 +88,24 @@ export const Default: Story = { }, }; +export const WithAudioAndVideoOptions: Story = { + ...Default, + args: { + ...Default.args, + audioEnabled: false, + videoEnabled: true, + audioOptions: [ + { label: "Microphone 1", id: "1" }, + { label: "Microphone 2", id: "2" }, + ], + videoOptions: [ + { label: "Camera 1", id: "1" }, + { label: "Camera 2", id: "2" }, + ], + selectedAudio: "2", + selectedVideo: "1", + }, +}; export const WithLogo: Story = { ...Default, args: { diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index 9d59d2d1..afc5bdc9 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -30,6 +30,10 @@ import { } from "../button"; import styles from "./CallFooter.module.css"; import { type GridMode } from "../state/CallViewModel/CallViewModel"; +import { + MediaMuteAndSwitchButton, + type MenuOptions, +} from "./MediaMuteAndSwitchButton"; export interface AudioOutputSwitcher { targetOutput: string; @@ -79,6 +83,13 @@ export interface FooterProps { // debug stuff debugTileLayout?: boolean; tileStoreGeneration?: number; + + audioOptions?: MenuOptions[]; + videoOptions?: MenuOptions[]; + selectedAudio?: string; + selectedVideo?: string; + selectAudioDevice?: (deviceId: string) => void; + selectVideoDevice?: (deviceId: string) => void; } export const CallFooter: FC = ({ @@ -104,6 +115,13 @@ export const CallFooter: FC = ({ hangup, debugTileLayout, tileStoreGeneration, + + audioOptions, + videoOptions, + selectedAudio, + selectedVideo, + selectAudioDevice, + selectVideoDevice, }) => { const buttons: JSX.Element[] = []; const buttonSize = asPip ? "md" : "lg"; @@ -125,24 +143,58 @@ export const CallFooter: FC = ({ ); } - buttons.push( - , - , - ); + if ((audioOptions?.length ?? 0) > 0) { + buttons.push( + , + ); + } else { + buttons.push( + , + ); + } + if ((videoOptions?.length ?? 0) > 0) { + buttons.push( + , + ); + } else { + buttons.push( + , + ); + } if (toggleScreenSharing !== undefined) { buttons.push( diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css new file mode 100644 index 00000000..e5bba238 --- /dev/null +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -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. +*/ + +.container { + display: flex; + flex-direction: row; + align-items: center; + background-color: var(--cpd-color-bg-subtle-secondary); + border-radius: 32px; + transition: background-color 0.2s ease-in-out; +} +.containerOpen { + background-color: var(--cpd-color-bg-action-primary-pressed); +} +.chevronIconOpen > svg { + color: var(--cpd-color-icon-on-solid-primary); +} +.menuButton { + width: 40px; + background-color: transparent !important; +} +.itemIcon { + color: var(--cpd-color-text-secondary); +} + +.rotate { + animation: spinner 1.5s linear infinite; +} +@keyframes spinner { + to { + transform: rotate(360deg); + } +} diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx new file mode 100644 index 00000000..bbf9f159 --- /dev/null +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -0,0 +1,117 @@ +/* +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 { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { fn, userEvent, within, expect } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; + +const meta = { + component: MediaMuteAndSwitchButton, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "SomeMenu", + iconsAndLabels: { + IconEnabled: AdvancedSettingsIcon, + IconDisabled: AdvancedSettingsIcon, + enabledLabel: "Enabled", + disabledLabel: "Disabled", + optionsButtonLabel: "Options", + }, + enabled: true, + options: [ + { label: "option 1", id: "1" }, + { label: "option 2", id: "2" }, + ], + selectedOption: "1", + onMuteClick: fn(), + onSelect: fn(), + }, +}; + +export const AudioMute: Story = { + args: { + ...Default.args, + title: "Microphone", + iconsAndLabels: "audio", + enabled: false, + options: [ + { label: "Microphone 1", id: "1" }, + { label: "Microphone 2", id: "2" }, + ], + toggles: [ + { + label: "example toggle", + id: "t0", + enabled: true, + }, + ], + selectedOption: "2", + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + // Both the mute button and the chevron trigger currently share the aria-label "Edit" + // (both are TODO placeholders in the component). The mute button is first in the DOM. + const muteButton = canvas.getByLabelText("Unmute microphone"); + await userEvent.click(muteButton); + await expect(args.onMuteClick).toHaveBeenCalled(); + }, +}; + +export const AudioUnmute: Story = { + args: { + title: "Microphone", + iconsAndLabels: "audio", + enabled: true, + options: [ + { label: "Microphone 1", id: "1" }, + { label: "Microphone 2", id: "2" }, + ], + toggles: [], + selectedOption: "2", + }, +}; + +export const VideoMute: Story = { + args: { + title: "Camera", + iconsAndLabels: "video", + enabled: false, + options: [ + { label: "Camera 1", id: "1" }, + { label: "Camera 2", id: "2" }, + ], + toggles: [], + selectedOption: "1", + }, +}; + +export const VideoUnmute: Story = { + args: { + title: "Camera", + iconsAndLabels: "video", + enabled: true, + options: [ + { label: "Camera 1", id: "1" }, + { label: "Camera 2", id: "2" }, + ], + toggles: [ + { + label: "Blur Background", + id: "background_blurring", + enabled: false, + }, + ], + selectedOption: "2", + }, +}; diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx new file mode 100644 index 00000000..42a8d970 --- /dev/null +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -0,0 +1,226 @@ +/* +Copyright 2023, 2024 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 { describe, expect, test, vi } from "vitest"; +import { act, render, screen, type RenderResult } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { type JSX, useState } from "react"; + +import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; + +describe("MediaMuteAndSwitchButton", () => { + test("renders", () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + test("renders correct audio and video labels", () => { + const renderLabels = ( + type: "video" | "audio", + enabled: boolean, + ): RenderResult => { + return render( + , + ); + }; + const renderAudioEndabled = renderLabels("audio", true); + const renderAudioDisabled = renderLabels("audio", false); + const renderVideoEnabled = renderLabels("video", true); + const renderVideoDisabled = renderLabels("video", false); + + expect( + renderAudioEndabled.getByRole("button", { name: "Mute microphone" }), + ).toBeInTheDocument(); + expect( + renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }), + ).toBeInTheDocument(); + expect( + renderVideoEnabled.getByRole("button", { name: "Start video" }), + ).toBeInTheDocument(); + expect( + renderVideoDisabled.getByRole("button", { name: "Stop video" }), + ).toBeInTheDocument(); + }); + + test("calls mute on mute press", async () => { + const user = userEvent.setup(); + const onMute = vi.fn(); + const { getByRole } = render( + , + ); + + await user.click(getByRole("button", { name: "Mute microphone" })); + + expect(onMute).toHaveBeenCalled(); + }); + + test("calls select callback on menu click", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + const { getByRole } = render( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + await user.click(screen.getByRole("menuitem", { name: "Microphone 2" })); + + expect(onSelect).toHaveBeenCalledWith("mic2"); + }); + test("does not call select callback on already selected menu click", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + const { getByRole } = render( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + await user.click(screen.getByRole("menuitem", { name: "Microphone 1" })); + + expect(onSelect).not.toHaveBeenCalled(); + }); + + test("renders menu spinner until selection updates for the component", async () => { + const user = userEvent.setup(); + const { promise, resolve } = Promise.withResolvers(); + const onSelectPressed = vi.fn(); + const onOptionUpdated = vi.fn(); + function Wrapper(): JSX.Element { + const [selectedOption, setSelectedOption] = useState("mic1"); + return ( + { + onSelectPressed(); + void promise.then(() => { + setSelectedOption(id); + onOptionUpdated(); + }); + }} + /> + ); + } + + const { getByRole } = render(); + + await user.click(getByRole("button", { name: "Microphone" })); + await user.click(screen.getByRole("menuitem", { name: "Microphone 2" })); + + expect(onSelectPressed).toHaveBeenCalled(); + expect(onOptionUpdated).not.toHaveBeenCalled(); + // After clicking, plannedSelection="mic2" but selectedOption is still "mic1", + // so a spinner should appear on the mic2 item + const mic2Item = screen.getByRole("menuitem", { name: "Microphone 2" }); + expect(mic2Item.querySelector(".rotate")).toBeTruthy(); + + // The currently-selected mic1 item should not have a spinner + const mic1Item = screen.getByRole("menuitem", { name: "Microphone 1" }); + expect(mic1Item.querySelector(".rotate")).toBeNull(); + await act(async () => { + // resolve the promise that acutally updates the select option. + resolve(); + await promise; + }); + + expect(onOptionUpdated).toHaveBeenCalled(); + // Spinner should now be gone since the selection has caught up + const mic2ItemAfter = screen.getByRole("menuitem", { + name: "Microphone 2", + }); + expect(mic2ItemAfter.querySelector(".rotate")).toBeNull(); + }); + + test("renders menu with toggle control and calls toggle callback", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + const { getByRole } = render( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + + const toggle = screen.getByRole("menuitemcheckbox", { + name: "Background blur", + }); + expect(toggle).toBeInTheDocument(); + expect(toggle).toHaveAttribute("aria-checked", "false"); + + await user.click(toggle); + + expect(onSelect).toHaveBeenCalledWith("bg_blur"); + }); + + test("renders check icon to mark the selected menu item", async () => { + const user = userEvent.setup(); + const { getByRole } = render( + , + ); + + // open menu + await user.click(getByRole("button", { name: "Microphone" })); + + // The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG + const mic1Item = screen.getByRole("menuitem", { name: "Microphone 2" }); + expect(mic1Item.querySelectorAll("svg").length).toBe(2); + + // The unselected item (mic1) only renders its IconOptions SVG + const mic2Item = screen.getByRole("menuitem", { name: "Microphone 1" }); + expect(mic2Item.querySelectorAll("svg").length).toBe(1); + }); +}); diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx new file mode 100644 index 00000000..7e38c7c6 --- /dev/null +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -0,0 +1,230 @@ +/* +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 ComponentType, useState, type FC } from "react"; +import { + Button, + Menu, + MenuItem, + ToggleMenuItem, +} from "@vector-im/compound-web"; +import { t } from "i18next"; +import { + CheckIcon, + ChevronUpIcon, + ChevronDownIcon, + MicOffSolidIcon, + MicOnIcon, + MicOnSolidIcon, + SpinnerIcon, + VideoCallIcon, + VideoCallOffSolidIcon, + VideoCallSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; +import classNames from "classnames"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import styles from "./MediaMuteAndSwitchButton.module.css"; + +export interface MenuOptions { + label: string; + id: string; +} +export interface ToggleOption { + label: string; + enabled: boolean; + id: string; +} + +export interface IconsAndLabels { + /** The Icon used if the mute button is enabled */ + IconEnabled: ComponentType>; + /** The Icon used if the mute button is disabled */ + IconDisabled: ComponentType>; + /** The icon used for the different options */ + IconOptions?: ComponentType>; + enabledLabel: string; + disabledLabel: string; + optionsButtonLabel: string; +} + +export interface MediaMuteAndSwitchButtonProps { + /** The title used in the Switcher modal. */ + title: string; + /** If the Mute button is enabled */ + enabled?: boolean; + /** Callback if the mute button is clicked */ + onMuteClick?: () => void; + iconsAndLabels?: "video" | "audio" | IconsAndLabels; + /** The options available for the media device selector modal */ + options?: MenuOptions[]; + /** The option that will currently be rendered as the selected option */ + selectedOption?: string; + /** + * The available toggles (including there current state) + * The toggle state is not stored by this component. + * It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled` + */ + toggles?: ToggleOption[]; + /** + * For any toggle and option this method will be called. + * So toggles need to be implemented by listening here and setting the right toggle item to `enabled` + */ + onSelect?: (id: string) => void; +} + +export const MediaMuteAndSwitchButton: FC = ({ + title, + enabled, + onMuteClick, + iconsAndLabels: iconsAndLabelsWithDefaultCases, + options, + selectedOption, + toggles, + onSelect, +}) => { + const [plannedSelection, setPlannedSelection] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + let iconsAndLabels: IconsAndLabels | undefined; + switch (iconsAndLabelsWithDefaultCases) { + case "video": + iconsAndLabels = { + IconEnabled: VideoCallSolidIcon, + IconDisabled: VideoCallOffSolidIcon, + IconOptions: VideoCallIcon, + disabledLabel: t("stop_video_button_label"), + enabledLabel: t("start_video_button_label"), + optionsButtonLabel: t("settings.devices.microphone"), + }; + break; + case "audio": + iconsAndLabels = { + IconEnabled: MicOnSolidIcon, + IconDisabled: MicOffSolidIcon, + IconOptions: MicOnIcon, + disabledLabel: t("mute_microphone_button_label"), + enabledLabel: t("unmute_microphone_button_label"), + optionsButtonLabel: t("settings.devices.microphone"), + }; + break; + default: + iconsAndLabels = iconsAndLabelsWithDefaultCases; + break; + } + const { + IconEnabled, + IconDisabled, + IconOptions, + disabledLabel, + enabledLabel, + optionsButtonLabel, + } = iconsAndLabels ?? { + IconEnabled: undefined, + IconDisabled: undefined, + IconOptions: undefined, + disabledLabel: undefined, + enabledLabel: undefined, + optionsButtonLabel: undefined, + }; + { + logger.info( + "RENDER WITH: selectedOption !== option.id && plannedSelection === option.id", + selectedOption, + " !==", + "option.id", + " && ", + plannedSelection, + " === ", + "option.id", + ); + } + return ( +
+ {/* The mute button lives inside */} +
+ ); +}; diff --git a/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap b/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap new file mode 100644 index 00000000..84ea220a --- /dev/null +++ b/src/components/__snapshots__/MediaMuteAndSwitchButton.test.tsx.snap @@ -0,0 +1,43 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MediaMuteAndSwitchButton > renders 1`] = ` +
+
+ +
+
+`;