Merge pull request #4003 from element-hq/device-switch-fixes

Show the right labels in device switcher menus
This commit is contained in:
Robin
2026-06-02 11:29:28 +02:00
committed by GitHub
5 changed files with 188 additions and 100 deletions

View File

@@ -17,6 +17,9 @@ import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions"; import { type ReactionOption } from "../reactions";
import { type GridMode } from "../state/CallViewModel/CallViewModel"; import { type GridMode } from "../state/CallViewModel/CallViewModel";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
// consts for tests // consts for tests
const reactionIdentifier = "@user:example.com:DEVICE"; const reactionIdentifier = "@user:example.com:DEVICE";
const reactionData = { const reactionData = {
@@ -24,6 +27,8 @@ const reactionData = {
reactions$: new BehaviorSubject({}), reactions$: new BehaviorSubject({}),
}; };
const mediaDevices = new MediaDevices(globalScope);
/** /**
* A wrapper component that is used for: * 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) * - 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 { }): ReactNode {
const vm = useStaticViewModel(vmSnapshot); const vm = useStaticViewModel(vmSnapshot);
return ( return (
<div className={inCallViewStyles.inRoom}> <MediaDevicesContext value={mediaDevices}>
<ReactionsSenderContext <div className={inCallViewStyles.inRoom}>
value={{ <ReactionsSenderContext
supportsReactions: false, value={{
toggleRaisedHand: async () => Promise.resolve(), supportsReactions: false,
sendReaction: async (reaction: ReactionOption) => Promise.resolve(), toggleRaisedHand: async () => Promise.resolve(),
}} sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
> }}
<CallFooter vm={vm} /> >
</ReactionsSenderContext> <CallFooter vm={vm} />
</div> </ReactionsSenderContext>
</div>
</MediaDevicesContext>
); );
} }

View File

@@ -6,12 +6,25 @@ Please see LICENSE in the repository root for full details.
*/ */
import { fn, userEvent, within, expect } from "storybook/test"; import { fn, userEvent, within, expect } from "storybook/test";
import { type JSX } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; 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 = { const meta = {
component: MediaMuteAndSwitchButton, component: MediaMuteAndSwitchButton,
decorators: [
(Story): JSX.Element => (
<MediaDevicesContext value={mediaDevices}>
<Story />
</MediaDevicesContext>
),
],
} satisfies Meta<typeof MediaMuteAndSwitchButton>; } satisfies Meta<typeof MediaMuteAndSwitchButton>;
export default meta; export default meta;

View File

@@ -8,14 +8,35 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { act, render, screen, type RenderResult } from "@testing-library/react"; import { act, render, screen, type RenderResult } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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 { TooltipProvider } from "@vector-im/compound-web";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; 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<RenderOptions> = {},
): RenderResult {
return render(
<TooltipProvider>
<MediaDevicesContext
value={{ requestDeviceNames } as unknown as MediaDevices}
>
{component}
</MediaDevicesContext>
</TooltipProvider>,
);
}
describe("MediaMuteAndSwitchButton", () => { describe("MediaMuteAndSwitchButton", () => {
test("renders", () => { test("renders", () => {
const { container } = render( const { container } = renderComponent(
<TooltipProvider> <TooltipProvider>
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} /> <MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
</TooltipProvider>, </TooltipProvider>,
@@ -28,14 +49,12 @@ describe("MediaMuteAndSwitchButton", () => {
type: "video" | "audio", type: "video" | "audio",
enabled: boolean, enabled: boolean,
): RenderResult => { ): RenderResult => {
return render( return renderComponent(
<TooltipProvider> <MediaMuteAndSwitchButton
<MediaMuteAndSwitchButton title={"Switcher"}
title={"Switcher"} iconsAndLabels={type}
iconsAndLabels={type} enabled={enabled}
enabled={enabled} />,
/>
</TooltipProvider>,
); );
}; };
const renderAudioEndabled = renderLabels("audio", true); const renderAudioEndabled = renderLabels("audio", true);
@@ -60,15 +79,13 @@ describe("MediaMuteAndSwitchButton", () => {
test("calls mute on mute press", async () => { test("calls mute on mute press", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onMute = vi.fn(); const onMute = vi.fn();
const { getByRole } = render( const { getByRole } = renderComponent(
<TooltipProvider> <MediaMuteAndSwitchButton
<MediaMuteAndSwitchButton title={"Switcher"}
title={"Switcher"} onMuteClick={onMute}
onMuteClick={onMute} iconsAndLabels="audio"
iconsAndLabels="audio" enabled={true}
enabled={true} />,
/>
</TooltipProvider>,
); );
await user.click(getByRole("switch", { name: "Mute microphone" })); await user.click(getByRole("switch", { name: "Mute microphone" }));
@@ -76,23 +93,74 @@ describe("MediaMuteAndSwitchButton", () => {
expect(onMute).toHaveBeenCalled(); expect(onMute).toHaveBeenCalled();
}); });
test("calls select callback on menu click", async () => { test("requests device names when opened", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSelect = vi.fn(); const requestDeviceNames = vi.fn();
const { getByRole } = render( renderComponent(
<TooltipProvider> <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled
/>,
{ 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();
renderComponent(
<>
<MediaMuteAndSwitchButton <MediaMuteAndSwitchButton
title="Switcher" title="Switcher"
iconsAndLabels="audio" iconsAndLabels="audio"
enabled={true} enabled
options={[ options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }, { label: { type: "number", number: 1 }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" }, { label: { type: "number", number: 2 }, id: "mic2" },
]} ]}
selectedOption="mic1" selectedOption="mic1"
onSelect={onSelect}
/> />
</TooltipProvider>, <MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video"
enabled
options={[
{ label: { type: "number", number: 1 }, id: "cam1" },
{ label: { type: "number", number: 2 }, id: "cam2" },
]}
selectedOption="cam1"
/>
</>,
);
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();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
); );
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
@@ -103,20 +171,18 @@ describe("MediaMuteAndSwitchButton", () => {
test("does not call select callback on already selected menu click", async () => { test("does not call select callback on already selected menu click", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSelect = vi.fn(); const onSelect = vi.fn();
const { getByRole } = render( const { getByRole } = renderComponent(
<TooltipProvider> <MediaMuteAndSwitchButton
<MediaMuteAndSwitchButton title="Switcher"
title="Switcher" iconsAndLabels="audio"
iconsAndLabels="audio" enabled={true}
enabled={true} options={[
options={[ { label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }, { label: { type: "name", name: "Microphone 2" }, id: "mic2" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" }, ]}
]} selectedOption="mic1"
selectedOption="mic1" onSelect={onSelect}
onSelect={onSelect} />,
/>
</TooltipProvider>,
); );
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
@@ -133,29 +199,27 @@ describe("MediaMuteAndSwitchButton", () => {
function Wrapper(): JSX.Element { function Wrapper(): JSX.Element {
const [selectedOption, setSelectedOption] = useState("mic1"); const [selectedOption, setSelectedOption] = useState("mic1");
return ( return (
<TooltipProvider> <MediaMuteAndSwitchButton
<MediaMuteAndSwitchButton title="Switcher"
title="Switcher" iconsAndLabels="audio"
iconsAndLabels="audio" enabled={true}
enabled={true} options={[
options={[ { label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }, { label: { type: "name", name: "Microphone 2" }, id: "mic2" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" }, ]}
]} selectedOption={selectedOption}
selectedOption={selectedOption} onSelect={(id) => {
onSelect={(id) => { onSelectPressed();
onSelectPressed(); void promise.then(() => {
void promise.then(() => { setSelectedOption(id);
setSelectedOption(id); onOptionUpdated();
onOptionUpdated(); });
}); }}
}} />
/>
</TooltipProvider>
); );
} }
const { getByRole } = render(<Wrapper />); const { getByRole } = renderComponent(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" })); await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" })); await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
@@ -188,16 +252,14 @@ describe("MediaMuteAndSwitchButton", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSelect = vi.fn(); const onSelect = vi.fn();
const onVideoBlurToggle = vi.fn(); const onVideoBlurToggle = vi.fn();
const { getByRole } = render( const { getByRole } = renderComponent(
<TooltipProvider> <MediaMuteAndSwitchButton
<MediaMuteAndSwitchButton title="Switcher"
title="Switcher" iconsAndLabels="video"
iconsAndLabels="video" enabled={true}
enabled={true} videoBlurToggleClick={onVideoBlurToggle}
videoBlurToggleClick={onVideoBlurToggle} onSelect={onSelect}
onSelect={onSelect} />,
/>
</TooltipProvider>,
); );
await user.click(getByRole("button", { name: "Camera" })); await user.click(getByRole("button", { name: "Camera" }));
@@ -215,19 +277,17 @@ describe("MediaMuteAndSwitchButton", () => {
test("renders check icon to mark the selected menu item", async () => { test("renders check icon to mark the selected menu item", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole } = render( const { getByRole } = renderComponent(
<TooltipProvider> <MediaMuteAndSwitchButton
<MediaMuteAndSwitchButton title="Switcher"
title="Switcher" iconsAndLabels="audio"
iconsAndLabels="audio" enabled={true}
enabled={true} options={[
options={[ { label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" }, { label: { type: "name", name: "Microphone 2" }, id: "mic2" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" }, ]}
]} selectedOption="mic2"
selectedOption="mic2" />,
/>
</TooltipProvider>,
); );
// open menu // open menu

View File

@@ -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. 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 { import {
Button, Button,
Menu, Menu,
@@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next";
import styles from "./MediaMuteAndSwitchButton.module.css"; import styles from "./MediaMuteAndSwitchButton.module.css";
import { MicButton, VideoButton } from "../button"; import { MicButton, VideoButton } from "../button";
import { type DeviceLabel } from "../state/MediaDevices"; import { type DeviceLabel } from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
export interface MenuOptions { export interface MenuOptions {
label: DeviceLabel; label: DeviceLabel;
@@ -69,6 +70,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
const [plannedSelection, setPlannedSelection] = useState<string | null>(null); const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const devices = useMediaDevices();
useEffect(() => {
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]);
let button; let button;
let toggles: { label: string; enabled: boolean; id: string }[] = []; let toggles: { label: string; enabled: boolean; id: string }[] = [];
switch (iconsAndLabels) { switch (iconsAndLabels) {
@@ -119,15 +126,16 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
IconOptions = VideoCallIcon; IconOptions = VideoCallIcon;
optionsButtonLabel = t("settings.devices.camera"); optionsButtonLabel = t("settings.devices.camera");
numberedLabel = (n): string => numberedLabel = (n): string =>
t("settings.devices.microphone_numbered", { n }); t("settings.devices.camera_numbered", { n });
break; break;
case "audio": case "audio":
IconOptions = MicOnIcon; IconOptions = MicOnIcon;
optionsButtonLabel = t("settings.devices.microphone"); optionsButtonLabel = t("settings.devices.microphone");
numberedLabel = (n): string => numberedLabel = (n): string =>
t("settings.devices.camera_numbered", { n }); t("settings.devices.microphone_numbered", { n });
break; break;
} }
return ( return (
<div <div
className={classNames({ className={classNames({

View File

@@ -100,7 +100,7 @@ export const SettingsModal: FC<Props> = ({
const devices = useMediaDevices(); const devices = useMediaDevices();
useEffect(() => { useEffect(() => {
if (open) devices.requestDeviceNames(); if (open) devices.requestDeviceNames(); // No-op after the first call
}, [open, devices]); }, [open, devices]);
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting); const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);