From 164765cd271cfc883ad63943a714b694b00988e7 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 2 Jun 2026 10:15:40 +0200 Subject: [PATCH 1/3] Show the right fallback labels in device switcher menus --- .../MediaMuteAndSwitchButton.test.tsx | 36 +++++++++++++++++++ src/components/MediaMuteAndSwitchButton.tsx | 4 +-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index 80ee0254..60be336a 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -76,6 +76,42 @@ describe("MediaMuteAndSwitchButton", () => { expect(onMute).toHaveBeenCalled(); }); + test("shows numbered devices correctly", async () => { + const user = userEvent.setup(); + render( + + + + , + ); + + await user.click(screen.getByRole("button", { name: "Microphone" })); + screen.getByRole("menuitem", { name: "Microphone 1" }); + screen.getByRole("menuitem", { name: "Microphone 2" }); + await user.keyboard("[Escape]"); + await user.click(screen.getByRole("button", { name: "Camera" })); + screen.getByRole("menuitem", { name: "Camera 1" }); + screen.getByRole("menuitem", { name: "Camera 2" }); + }); + test("calls select callback on menu click", async () => { const user = userEvent.setup(); const onSelect = vi.fn(); diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 44bdf5e6..80cb02a3 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -119,13 +119,13 @@ export const MediaMuteAndSwitchButton: FC = ({ IconOptions = VideoCallIcon; optionsButtonLabel = t("settings.devices.camera"); numberedLabel = (n): string => - t("settings.devices.microphone_numbered", { n }); + t("settings.devices.camera_numbered", { n }); break; case "audio": IconOptions = MicOnIcon; optionsButtonLabel = t("settings.devices.microphone"); numberedLabel = (n): string => - t("settings.devices.camera_numbered", { n }); + t("settings.devices.microphone_numbered", { n }); break; } return ( From 5fed562db23346632aaa4172ae498861c1ae8ce9 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 2 Jun 2026 10:36:57 +0200 Subject: [PATCH 2/3] Request full device names when device switchers are open --- .../MediaMuteAndSwitchButton.stories.tsx | 13 ++ .../MediaMuteAndSwitchButton.test.tsx | 210 ++++++++++-------- src/components/MediaMuteAndSwitchButton.tsx | 10 +- 3 files changed, 139 insertions(+), 94 deletions(-) diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index b014cf9b..89c12392 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -6,12 +6,25 @@ Please see LICENSE in the repository root for full details. */ import { fn, userEvent, within, expect } from "storybook/test"; +import { type JSX } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; +import { MediaDevicesContext } from "../MediaDevicesContext"; +import { MediaDevices } from "../state/MediaDevices"; +import { globalScope } from "../state/ObservableScope"; + +const mediaDevices = new MediaDevices(globalScope); const meta = { component: MediaMuteAndSwitchButton, + decorators: [ + (Story): JSX.Element => ( + + + + ), + ], } satisfies Meta; export default meta; diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index 60be336a..ac6540e0 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -8,14 +8,35 @@ 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 { type JSX, useState, type ReactNode } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; +import { MediaDevicesContext } from "../MediaDevicesContext"; +import { type MediaDevices } from "../state/MediaDevices"; + +interface RenderOptions { + requestDeviceNames: () => void; +} + +function renderComponent( + component: ReactNode, + { requestDeviceNames = (): void => {} }: Partial = {}, +): RenderResult { + return render( + + + {component} + + , + ); +} describe("MediaMuteAndSwitchButton", () => { test("renders", () => { - const { container } = render( + const { container } = renderComponent( , @@ -28,14 +49,12 @@ describe("MediaMuteAndSwitchButton", () => { type: "video" | "audio", enabled: boolean, ): RenderResult => { - return render( - - - , + return renderComponent( + , ); }; const renderAudioEndabled = renderLabels("audio", true); @@ -60,15 +79,13 @@ describe("MediaMuteAndSwitchButton", () => { test("calls mute on mute press", async () => { const user = userEvent.setup(); const onMute = vi.fn(); - const { getByRole } = render( - - - , + const { getByRole } = renderComponent( + , ); await user.click(getByRole("switch", { name: "Mute microphone" })); @@ -76,10 +93,27 @@ describe("MediaMuteAndSwitchButton", () => { expect(onMute).toHaveBeenCalled(); }); + test("requests device names when opened", async () => { + const user = userEvent.setup(); + const requestDeviceNames = vi.fn(); + renderComponent( + , + { requestDeviceNames }, + ); + + expect(requestDeviceNames).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Microphone" })); + expect(requestDeviceNames).toHaveBeenCalled(); + }); + test("shows numbered devices correctly", async () => { const user = userEvent.setup(); - render( - + renderComponent( + <> { ]} selectedOption="cam1" /> - , + , ); await user.click(screen.getByRole("button", { name: "Microphone" })); @@ -115,20 +149,18 @@ describe("MediaMuteAndSwitchButton", () => { test("calls select callback on menu click", async () => { const user = userEvent.setup(); const onSelect = vi.fn(); - const { getByRole } = render( - - - , + const { getByRole } = renderComponent( + , ); await user.click(getByRole("button", { name: "Microphone" })); @@ -139,20 +171,18 @@ describe("MediaMuteAndSwitchButton", () => { test("does not call select callback on already selected menu click", async () => { const user = userEvent.setup(); const onSelect = vi.fn(); - const { getByRole } = render( - - - , + const { getByRole } = renderComponent( + , ); await user.click(getByRole("button", { name: "Microphone" })); @@ -169,29 +199,27 @@ describe("MediaMuteAndSwitchButton", () => { function Wrapper(): JSX.Element { const [selectedOption, setSelectedOption] = useState("mic1"); return ( - - { - onSelectPressed(); - void promise.then(() => { - setSelectedOption(id); - onOptionUpdated(); - }); - }} - /> - + { + onSelectPressed(); + void promise.then(() => { + setSelectedOption(id); + onOptionUpdated(); + }); + }} + /> ); } - const { getByRole } = render(); + const { getByRole } = renderComponent(); await user.click(getByRole("button", { name: "Microphone" })); await user.click(screen.getByRole("menuitem", { name: "Microphone 2" })); @@ -224,16 +252,14 @@ describe("MediaMuteAndSwitchButton", () => { const user = userEvent.setup(); const onSelect = vi.fn(); const onVideoBlurToggle = vi.fn(); - const { getByRole } = render( - - - , + const { getByRole } = renderComponent( + , ); await user.click(getByRole("button", { name: "Camera" })); @@ -251,19 +277,17 @@ describe("MediaMuteAndSwitchButton", () => { test("renders check icon to mark the selected menu item", async () => { const user = userEvent.setup(); - const { getByRole } = render( - - - , + const { getByRole } = renderComponent( + , ); // open menu diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 80cb02a3..b5c07b94 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -5,7 +5,7 @@ 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 { type ComponentType, useState, type FC, useEffect } from "react"; import { Button, Menu, @@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next"; import styles from "./MediaMuteAndSwitchButton.module.css"; import { MicButton, VideoButton } from "../button"; import { type DeviceLabel } from "../state/MediaDevices"; +import { useMediaDevices } from "../MediaDevicesContext"; export interface MenuOptions { label: DeviceLabel; @@ -69,6 +70,12 @@ export const MediaMuteAndSwitchButton: FC = ({ const [plannedSelection, setPlannedSelection] = useState(null); const [menuOpen, setMenuOpen] = useState(false); const { t } = useTranslation(); + const devices = useMediaDevices(); + + useEffect(() => { + if (menuOpen) devices.requestDeviceNames(); + }, [menuOpen, devices]); + let button; let toggles: { label: string; enabled: boolean; id: string }[] = []; switch (iconsAndLabels) { @@ -128,6 +135,7 @@ export const MediaMuteAndSwitchButton: FC = ({ t("settings.devices.microphone_numbered", { n }); break; } + return (
Date: Tue, 2 Jun 2026 11:14:47 +0200 Subject: [PATCH 3/3] Fix footer stories --- src/components/CallFooter.stories.tsx | 29 +++++++++++++-------- src/components/MediaMuteAndSwitchButton.tsx | 2 +- src/settings/SettingsModal.tsx | 2 +- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index e9a7537c..7006d809 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -17,6 +17,9 @@ import { useStaticViewModel } from "../state/ViewModel"; import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { type ReactionOption } from "../reactions"; import { type GridMode } from "../state/CallViewModel/CallViewModel"; +import { MediaDevicesContext } from "../MediaDevicesContext"; +import { MediaDevices } from "../state/MediaDevices"; +import { globalScope } from "../state/ObservableScope"; // consts for tests const reactionIdentifier = "@user:example.com:DEVICE"; const reactionData = { @@ -24,6 +27,8 @@ const reactionData = { reactions$: new BehaviorSubject({}), }; +const mediaDevices = new MediaDevices(globalScope); + /** * A wrapper component that is used for: * - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm) @@ -41,17 +46,19 @@ function CallFooterStoryWrapper({ }): ReactNode { const vm = useStaticViewModel(vmSnapshot); return ( -
- Promise.resolve(), - sendReaction: async (reaction: ReactionOption) => Promise.resolve(), - }} - > - - -
+ +
+ Promise.resolve(), + sendReaction: async (reaction: ReactionOption) => Promise.resolve(), + }} + > + + +
+
); } diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index b5c07b94..e2309e1a 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -73,7 +73,7 @@ export const MediaMuteAndSwitchButton: FC = ({ const devices = useMediaDevices(); useEffect(() => { - if (menuOpen) devices.requestDeviceNames(); + if (menuOpen) devices.requestDeviceNames(); // No-op after the first call }, [menuOpen, devices]); let button; diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 30ac3618..4eb1efdd 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -100,7 +100,7 @@ export const SettingsModal: FC = ({ const devices = useMediaDevices(); useEffect(() => { - if (open) devices.requestDeviceNames(); + if (open) devices.requestDeviceNames(); // No-op after the first call }, [open, devices]); const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);