mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Show a default speaker where the platform lists none
- Safari enumerates no output devices at all, so the list arrived empty — and an empty array is truthy, so the Speaker heading rendered with nothing under it, which reads as a broken feature. - Show one entry instead, named as the default, disabled, and marked as the selection: audio is going somewhere, and an unchecked lone row reads as nothing being chosen. - D2 already says a row that cannot be chosen is shown disabled, never hidden. - Covered by its condition rather than by the browser, so Linux CI checks it: one test that the view model hands over an empty list rather than none, one that the menu then draws the default.
This commit is contained in:
@@ -126,6 +126,46 @@ describe("createCallFooterViewModel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("audioOutputOptions", () => {
|
||||||
|
it("is an empty list, not absent, where the platform enumerates no outputs", () => {
|
||||||
|
platformMock.mockReturnValue("desktop");
|
||||||
|
outputSelectionMock.mockReturnValue(true);
|
||||||
|
|
||||||
|
const vm = createCallFooterViewModel(
|
||||||
|
testScope(),
|
||||||
|
buildMinimalCallViewModel(gridLayout),
|
||||||
|
mockMuteStates(),
|
||||||
|
mockMediaDevices({
|
||||||
|
audioInput: {
|
||||||
|
available$: constant(
|
||||||
|
new Map<string, DeviceLabel>([
|
||||||
|
["mic1", { type: "name", name: "Microphone 1" }],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
selected$: constant(undefined),
|
||||||
|
select: vi.fn(),
|
||||||
|
},
|
||||||
|
// Safari enumerates no output devices whatsoever. Reproduced by the
|
||||||
|
// condition rather than by the browser, so it is checked on the
|
||||||
|
// Linux CI runners that have no Safari to check it with.
|
||||||
|
audioOutput: {
|
||||||
|
available$: constant(new Map<string, DeviceLabel>()),
|
||||||
|
selected$: constant(undefined),
|
||||||
|
select: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/* reactionIdentifier */ undefined,
|
||||||
|
{ showControls: true, header: HeaderStyle.Standard },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Empty rather than undefined: undefined means this menu has no notion
|
||||||
|
// of outputs at all, as the camera menu has none, and hides the section.
|
||||||
|
// Empty means there are none to list, and the menu still shows the
|
||||||
|
// section with a default in it, disabled.
|
||||||
|
expect(vm.audioOutputOptions$.value).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("audioOptions and videoOptions", () => {
|
describe("audioOptions and videoOptions", () => {
|
||||||
function checkEmptyFor(platform: string, layout: Layout): void {
|
function checkEmptyFor(platform: string, layout: Layout): void {
|
||||||
platformMock.mockReturnValue(platform);
|
platformMock.mockReturnValue(platform);
|
||||||
|
|||||||
@@ -197,3 +197,34 @@ export const OnlyOneDevice: Story = {
|
|||||||
await expect(only).toHaveAttribute("aria-disabled", "true");
|
await expect(only).toHaveAttribute("aria-disabled", "true");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A platform that enumerates no output devices and offers no way to choose one
|
||||||
|
* — Safari. The section still names where audio is going, disabled, rather than
|
||||||
|
* leaving a heading with nothing under it.
|
||||||
|
*/
|
||||||
|
export const OutputNotEnumerated: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
title: "Microphone",
|
||||||
|
iconsAndLabels: "audio",
|
||||||
|
enabled: true,
|
||||||
|
options: [
|
||||||
|
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
|
||||||
|
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
|
||||||
|
],
|
||||||
|
selectedOption: "mic1",
|
||||||
|
outputOptions: [],
|
||||||
|
selectedOutputOption: undefined,
|
||||||
|
onSelectOutput: undefined,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
|
||||||
|
|
||||||
|
const speakers = await within(document.body).findByRole("menuitemradio", {
|
||||||
|
name: "Default",
|
||||||
|
});
|
||||||
|
await expect(speakers).toHaveAttribute("aria-disabled", "true");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ 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 { axe } from "vitest-axe";
|
import { axe } from "vitest-axe";
|
||||||
import { act, render, screen, type RenderResult } from "@testing-library/react";
|
import {
|
||||||
|
act,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
within,
|
||||||
|
type RenderResult,
|
||||||
|
} from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { type JSX, useState, type ReactNode } from "react";
|
import { type JSX, useState, type ReactNode } from "react";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
@@ -613,6 +619,46 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
expect(only).toHaveAttribute("aria-disabled", "true");
|
expect(only).toHaveAttribute("aria-disabled", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows a default speaker where the platform lists none", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
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={vi.fn()}
|
||||||
|
// Safari enumerates no output devices at all, and offers no way to
|
||||||
|
// choose one.
|
||||||
|
outputOptions={[]}
|
||||||
|
selectedOutputOption={undefined}
|
||||||
|
onSelectOutput={undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(getByRole("button", { name: "Microphone" }));
|
||||||
|
|
||||||
|
// A heading with nothing under it says the feature is broken. Audio is
|
||||||
|
// playing somewhere, so the section names that somewhere and disables it.
|
||||||
|
const speakers = screen
|
||||||
|
.getAllByRole("group")
|
||||||
|
.find((group) => group.getAttribute("aria-label") === "Speaker")!;
|
||||||
|
const entries = within(speakers).getAllByRole("menuitemradio");
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]).toHaveAccessibleName("Default");
|
||||||
|
expect(entries[0]).toHaveAttribute("aria-disabled", "true");
|
||||||
|
// And marked as the selection: it is where audio is going, so an unchecked
|
||||||
|
// lone entry would read as nothing being chosen at all.
|
||||||
|
expect(entries[0]).toHaveAttribute("aria-checked", "true");
|
||||||
|
expect(
|
||||||
|
within(entries[0]).getByRole("radio", { hidden: true }),
|
||||||
|
).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
test("shows the speaker section disabled when output selection is unsupported", async () => {
|
test("shows the speaker section disabled when output selection is unsupported", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { getByRole } = renderComponent(
|
const { getByRole } = renderComponent(
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ export interface MediaMuteAndSwitchButtonProps {
|
|||||||
|
|
||||||
const BLUR_ID = "blur";
|
const BLUR_ID = "blur";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stands for wherever the platform is sending audio, where it will not say.
|
||||||
|
*
|
||||||
|
* Not a device id the browser would recognise: nothing can be selected on a
|
||||||
|
* platform that lists no outputs, so this is only ever shown, never sent.
|
||||||
|
*/
|
||||||
|
const DEFAULT_OUTPUT_ID = "default";
|
||||||
|
|
||||||
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||||
title,
|
title,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -251,6 +259,21 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
? selectedOutputOption
|
? selectedOutputOption
|
||||||
: selectedOption);
|
: selectedOption);
|
||||||
|
|
||||||
|
// Safari enumerates no output devices at all, and offers no way to choose
|
||||||
|
// one, so the list arrives empty. The section is shown all the same — audio
|
||||||
|
// is playing somewhere — naming that somewhere and disabling it like any
|
||||||
|
// single entry. A heading with nothing beneath it reads as a broken feature,
|
||||||
|
// and leaves the menu a different shape on one browser.
|
||||||
|
const noOutputsListed = outputOptions?.length === 0;
|
||||||
|
const speakerOptions: MenuOptions[] | undefined = noOutputsListed
|
||||||
|
? [{ id: DEFAULT_OUTPUT_ID, label: { type: "default", name: null } }]
|
||||||
|
: outputOptions;
|
||||||
|
// And it is the selection, not merely the only row: it is where audio is
|
||||||
|
// going. An unchecked lone entry reads as nothing being chosen at all.
|
||||||
|
const selectedSpeaker = noOutputsListed
|
||||||
|
? DEFAULT_OUTPUT_ID
|
||||||
|
: selectedOutputOption;
|
||||||
|
|
||||||
const deviceItems = (
|
const deviceItems = (
|
||||||
kind: "input" | "output",
|
kind: "input" | "output",
|
||||||
items: MenuOptions[] | undefined,
|
items: MenuOptions[] | undefined,
|
||||||
@@ -356,7 +379,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{iconsAndLabels === "audio" && outputOptions && (
|
{iconsAndLabels === "audio" && speakerOptions && (
|
||||||
<>
|
<>
|
||||||
{/* A menu may only contain items, separators and groups, so each
|
{/* A menu may only contain items, separators and groups, so each
|
||||||
heading belongs to a group rather than sitting beside the
|
heading belongs to a group rather than sitting beside the
|
||||||
@@ -369,8 +392,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{deviceItems(
|
{deviceItems(
|
||||||
"output",
|
"output",
|
||||||
outputOptions,
|
speakerOptions,
|
||||||
selectedOutputOption,
|
selectedSpeaker,
|
||||||
onSelectOutput,
|
onSelectOutput,
|
||||||
(n) => t("settings.devices.speaker_numbered", { n }),
|
(n) => t("settings.devices.speaker_numbered", { n }),
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user