diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index e5bba2383..4faa52ee1 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -35,3 +35,103 @@ Please see LICENSE in the repository root for full details. transform: rotate(360deg); } } + +.menu { + display: flex; + flex-direction: column; +} + +/* Only the device lists scroll; the level meter stays put beneath them. + The bound comes from the measured height of the call area, set by the + component: the menu is portalled outside the root, so neither a container + query nor a viewport unit describes the space it is allowed to fill. */ +.deviceList { + overflow-y: auto; + min-block-size: 0; + max-block-size: var(--device-list-max-height); + /* The heading stands over the head of this list and the level meter over its + foot. Scrolling a row flush to either edge would put it underneath one of + them, which is how a row reached by the keyboard ends up half-readable; + this keeps both their heights clear for anything the browser scrolls to. + Set by the component, which is the only thing that can measure them. */ + scroll-padding-block: var(--device-list-scroll-padding-start, 0) + var(--device-list-scroll-padding-end, 0); +} + +/* Each section is headed by its own rule, which Compound draws under a menu + heading already. The heading spans the whole menu — the menu has no padding + of its own — so that rule runs edge to edge, stopping only where the frame + is, rather than inset as a separator between the sections would be. + + Compound's spacing around it is the design's too, bar one thing: the design + leaves a section's first device further below the rule than Compound does — + measured off the mock, 27px from the rule to the top of the control, against + the 16px that Compound's 8px margin and the row's own 8px padding give. The + difference goes below the rule, so the rule stays tight under its own text + where Compound put it. */ +.menu h3 { + margin-block-end: var(--cpd-space-5x); +} + +/* One section is set much further from the one above it than Compound's 8px + heading margin allows: measured off the mock, 41px from the last device's + control to the next heading's text, against the 22px we had. Only between + sections — the first heading keeps Compound's spacing, because the menu's + own padding is already above it. */ +.deviceList [role="group"] + [role="group"] h3 { + margin-block-start: var(--cpd-space-7x); +} + +/* Each section's heading stays at the top of the scrollport while any of that + section is still in view, so a long list never leaves you wondering which + kind of device you are looking at. It leaves with its own section, because + sticky only holds while the group it belongs to is in view. + + Opaque, and held a border width clear of the sides, for the same reason as + the meter below: this is a positioned element, so it paints above the + outline the menu draws its frame with, and would swallow it. */ +.sectionHeading { + position: sticky; + inset-block-start: 0; + background: var(--cpd-color-bg-canvas-default); + margin-inline: var(--cpd-border-width-1); + margin-block-start: var(--cpd-border-width-1); +} + +/* The meter belongs to the microphone section: it stays at the bottom of the + scrollport while that section is in view, and leaves with it when the list + is scrolled up to the speakers. The wrapper is deliberately unpositioned — + a positioned one paints above the menu's outline and swallows the frame + along this whole section. Sticky is resolved against the scroll container, + so it does not need one. */ + +.stickyMeter { + position: sticky; + inset-block-end: 0; + /* Opaque, so the list does not show through it as it scrolls past. The menu + draws its frame as an outline inset by one border width, and the device + rows are transparent at rest, so this is the only thing that can cover it: + hold it clear on the sides and the bottom. */ + background: var(--cpd-color-bg-canvas-default); + margin-inline: var(--cpd-border-width-1); + margin-block-end: var(--cpd-border-width-1); +} + +/* Radix focuses whatever the pointer is over, so the browser's own ring marks + the item under the mouse. It cannot distinguish the two modalities here, so + it is suppressed and replaced by one that can. + + Every kind of item the menu can focus, not only the device rows: the camera + menu's blur toggle is a checkbox item and a child of the menu rather than of + the list, and left out it kept the browser's own ring and followed the + pointer with it. */ +.menu [role^="menuitem"]:focus, +.menu [role^="menuitem"]:focus-visible { + outline: none; +} + +/* Shown only when the keyboard is what moved the focus. */ +.menu[data-focus-modality="keyboard"] [role^="menuitem"]:focus { + outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused); + outline-offset: calc(-1 * var(--cpd-border-width-2)); +} diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index fcc9f0b4e..05f77ca3c 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -5,36 +5,66 @@ 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 { afterEach, describe, expect, test, vi } from "vitest"; +import { axe } from "vitest-axe"; +import { + act, + render, + screen, + within, + type RenderResult, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { type JSX, useState, type ReactNode } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; -import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; -import { MediaDevicesContext } from "../MediaDevicesContext"; +import { + MediaMuteAndSwitchButton, + type MenuOptions, +} from "./MediaMuteAndSwitchButton"; +import { MediaDevicesContext, useMediaDevices } from "../MediaDevicesContext"; import { type MediaDevices } from "../state/MediaDevices"; +import { restoreAudioCapture, stubAudioCapture } from "../utils/test"; +import type * as MediaDevicesContextModule from "../MediaDevicesContext"; + +// The menu reads the devices once per render and the meter never does, so +// counting these calls counts how often the menu itself re-rendered. +vi.mock("../MediaDevicesContext", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useMediaDevices: vi.fn(actual.useMediaDevices) }; +}); interface RenderOptions { requestDeviceNames: () => void; } -function renderComponent( +function withProviders( component: ReactNode, { requestDeviceNames = (): void => {} }: Partial = {}, -): RenderResult { - return render( +): JSX.Element { + return ( {component} - , + ); } +function renderComponent( + component: ReactNode, + options: Partial = {}, +): RenderResult { + return render(withProviders(component, options)); +} + describe("MediaMuteAndSwitchButton", () => { + // Only one test stubs the capture, but leaving it stubbed would follow + // every later test in the run. + afterEach(restoreAudioCapture); + test("renders", () => { const { container } = renderComponent( @@ -327,7 +357,7 @@ describe("MediaMuteAndSwitchButton", () => { expect(onVideoBlurToggle).toHaveBeenCalled(); }); - test("renders check icon to mark the selected menu item", async () => { + test("marks the selected menu item as checked", async () => { const user = userEvent.setup(); const { getByRole } = renderComponent( { />, ); - // 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("menuitemradio", { - name: "Microphone 2", - }); - expect(mic1Item.querySelectorAll("svg").length).toBe(2); + screen.getByRole("menuitemradio", { name: "Microphone 2", checked: true }); + screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false }); + }); - // The unselected item (mic1) only renders its IconOptions SVG - const mic2Item = screen.getByRole("menuitemradio", { + test("disables every device while a selection is settling", async () => { + const user = userEvent.setup(); + const { promise, resolve } = Promise.withResolvers(); + function Wrapper(): JSX.Element { + const [selectedOption, setSelectedOption] = useState("mic1"); + return ( + { + void promise.then(() => setSelectedOption(id)); + }} + outputOptions={[ + { label: { type: "name", name: "Speakers" }, id: "spk1" }, + { label: { type: "name", name: "Headset" }, id: "spk2" }, + ]} + selectedOutputOption="spk1" + onSelectOutput={vi.fn()} + /> + ); + } + + const { getByRole } = renderComponent(); + await user.click(getByRole("button", { name: "Microphone" })); + await user.click( + screen.getByRole("menuitemradio", { name: "Microphone 2" }), + ); + + // In flight: nothing else can be picked, in either section, so a second + // request cannot overtake the first. + for (const name of ["Microphone 1", "Speakers", "Headset"]) { + expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute( + "aria-disabled", + "true", + ); + } + + await act(async () => { + resolve(); + await promise; + }); + + // Settled: choosable again. + expect( + screen.getByRole("menuitemradio", { name: "Microphone 1" }), + ).not.toHaveAttribute("aria-disabled", "true"); + expect( + screen.getByRole("menuitemradio", { name: "Headset" }), + ).not.toHaveAttribute("aria-disabled", "true"); + }); + + test("lets go of a device switch that never arrives", async () => { + const user = userEvent.setup(); + // onSelect that never reports back is what a device removed mid-switch + // looks like from here: the selection falls back to the default, so what + // was asked for never becomes the selection. + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + await user.click( + screen.getByRole("menuitemradio", { name: "Microphone 2" }), + ); + await user.keyboard("{Escape}"); + await user.click(getByRole("button", { name: "Microphone" })); + + // Otherwise every device, in both sections, stays unselectable for the + // rest of the call. + for (const name of ["Microphone 1", "Speakers", "Headset"]) { + expect(screen.getByRole("menuitemradio", { name })).not.toHaveAttribute( + "aria-disabled", + "true", + ); + } + }); + + test("lets go of a device switch whose device is unplugged", async () => { + const user = userEvent.setup(); + const mics: MenuOptions[] = [ + { label: { type: "name", name: "Microphone 1" }, id: "mic1" }, + { label: { type: "name", name: "Microphone 2" }, id: "mic2" }, + ]; + const menu = (options: MenuOptions[]): JSX.Element => ( + + ); + + const { getByRole, rerender } = renderComponent(menu(mics)); + await user.click(getByRole("button", { name: "Microphone" })); + await user.click( + screen.getByRole("menuitemradio", { name: "Microphone 2" }), + ); + + // The second microphone is unplugged before the switch to it lands, so it + // is never going to become the selection. + rerender(withProviders(menu(mics.slice(0, 1)))); + + // The speakers are choosable again without the menu being closed: a + // request for a device that is gone is not in flight, it is over. + expect( + screen.getByRole("menuitemradio", { name: "Headset" }), + ).not.toHaveAttribute("aria-disabled", "true"); + }); + + test("redraws the meter and not the device rows around it", async () => { + // A level arrives many times a second. Held in the menu it would reconcile + // every device row on its way to the bars, so it is held in the meter. + const capture = stubAudioCapture(); + const user = userEvent.setup(); + const menuRenders = vi.mocked(useMediaDevices); + + const { getByRole } = renderComponent( + <> + + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + capture.grant(); + const meter = await screen.findByRole("meter"); + const settled = menuRenders.mock.calls.length; + + // The meter follows elapsed time rather than frames, so hand-driven frames + // need a clock to move at all: a frame every 16ms, from where the capture + // started. + let elapsed = performance.now(); + const clock = vi + .spyOn(performance, "now") + .mockImplementation(() => (elapsed += 16)); + + // One frame per task, as a browser delivers them: drawing them all inside + // one act would let React batch what it would not batch in a browser. + for (let step = 1; step <= 8; step++) { + capture.speak(step / 8); + await act(async () => { + capture.drawFrames(1); + await Promise.resolve(); + }); + } + clock.mockRestore(); + + // The level moved... + expect(meter.getAttribute("aria-valuenow")).not.toBe("0"); + // ...and the menu around it did not render once on the way. + expect(menuRenders.mock.calls.length - settled).toBe(0); + }); + + test("camera menu uses the same selection pattern and keeps the blur toggle", async () => { + const user = userEvent.setup(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Camera" })); + + // Same selection pattern as the microphone menu. + screen.getByRole("menuitemradio", { name: "Camera 1", checked: true }); + screen.getByRole("menuitemradio", { name: "Camera 2", checked: false }); + // And background blur is still reachable from here. + expect( + screen.getByRole("menuitemcheckbox", { name: "Blur background" }), + ).toBeInTheDocument(); + }); + + test("marks focus as keyboard-driven only when the keyboard moved it", async () => { + const user = userEvent.setup(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + const list = screen + .getByRole("menuitemradio", { name: "Microphone 1" }) + .closest("[data-focus-modality]"); + + // The menu focuses whatever the pointer is over, so focus alone says + // nothing about how someone is navigating. + expect(list).toHaveAttribute("data-focus-modality", "pointer"); + + await user.keyboard("{ArrowDown}"); + expect(list).toHaveAttribute("data-focus-modality", "keyboard"); + + await user.pointer({ + target: screen.getByRole("menuitemradio", { name: "Microphone 2" }), + coords: { clientX: 10, clientY: 10 }, + }); + expect(list).toHaveAttribute("data-focus-modality", "pointer"); + }); + + test("marks the selected device with the accent fill", async () => { + const user = userEvent.setup(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + + const selected = screen + .getByRole("menuitemradio", { name: "Microphone 2" }) + .querySelector("input[type=radio]"); + expect(selected).toBeChecked(); + // A read-only control is painted muted, which loses the accent fill that + // marks the selection and makes the menu differ from settings. + expect(selected).not.toHaveAttribute("readonly"); + }); + + test("the open menu has no accessibility violations", async () => { + const user = userEvent.setup(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + + // Includes the menu's own structure: wrappers between the menu and its + // items break the relationship the roles describe. + const menu = document.querySelector('[role="menu"]'); + expect(await axe(menu as HTMLElement)).toHaveNoViolations(); + }); + + test("puts the speaker section above the microphone section", async () => { + const user = userEvent.setup(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + + const speakers = screen.getByRole("menuitemradio", { name: "Speakers" }); + const microphone = screen.getByRole("menuitemradio", { name: "Microphone 1", }); - expect(mic2Item.querySelectorAll("svg").length).toBe(1); + expect( + speakers.compareDocumentPosition(microphone) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + test("lists speaker and microphone sections", async () => { + const user = userEvent.setup(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + + screen.getByRole("menuitemradio", { + name: "Default (Built-in Output)", + checked: true, + }); + screen.getByRole("menuitemradio", { name: "Headset", checked: false }); + screen.getByRole("menuitemradio", { name: "Microphone 1", checked: true }); + screen.getByRole("menuitemradio", { name: "Microphone 2", checked: false }); + }); + + test("calls the output select callback on speaker click", async () => { + const user = userEvent.setup(); + const onSelectOutput = vi.fn(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + await user.click(screen.getByRole("menuitemradio", { name: "Headset" })); + + expect(onSelectOutput).toHaveBeenCalledWith("spk2"); + }); + + test("shows a single device entry disabled", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + + // Shown rather than hidden, so the menu keeps its shape, but not choosable. + const only = screen.getByRole("menuitemradio", { name: "Microphone 1" }); + expect(only).toHaveAttribute("aria-disabled", "true"); + }); + + test("shows a default speaker where the platform lists none", async () => { + const user = userEvent.setup(); + const { getByRole } = renderComponent( + , + ); + + 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 () => { + const user = userEvent.setup(); + const { getByRole } = renderComponent( + , + ); + + await user.click(getByRole("button", { name: "Microphone" })); + + expect( + screen.getByRole("menuitemradio", { name: "Speakers" }), + ).toHaveAttribute("aria-disabled", "true"); + expect( + screen.getByRole("menuitemradio", { name: "Headset" }), + ).toHaveAttribute("aria-disabled", "true"); + // The microphone section is unaffected. + expect( + screen.getByRole("menuitemradio", { name: "Microphone 2" }), + ).not.toHaveAttribute("aria-disabled", "true"); }); }); diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 3e344dd3a..bdbaf3a80 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -5,37 +5,53 @@ 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, useEffect } from "react"; +import { + useCallback, + useState, + type CSSProperties, + type FC, + useEffect, + type ReactElement, +} from "react"; import { Button, Menu, MenuItem, + MenuTitle, + RadioInput, ToggleMenuItem, } from "@vector-im/compound-web"; import { - CheckIcon, ChevronUpIcon, ChevronDownIcon, - MicOnIcon, SpinnerIcon, - VideoCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import classNames from "classnames"; import { useTranslation } from "react-i18next"; +import { distinctUntilChanged, map } from "rxjs"; import styles from "./MediaMuteAndSwitchButton.module.css"; import { MicButton, VideoButton } from "../button"; -import { type DeviceLabel } from "../state/MediaDevices"; +import { + type AudioOutputDeviceLabel, + type DeviceLabel, +} from "../state/MediaDevices"; import { useMediaDevices } from "../MediaDevicesContext"; +import { useRootElement } from "../RootElementContext"; +import { observeElementSize$ } from "../utils/elementSize"; +import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter"; export interface MenuOptions { - label: DeviceLabel; + label: DeviceLabel | AudioOutputDeviceLabel; id: string; } export interface MediaMuteAndSwitchButtonProps { - /** The title used in the Switcher modal. */ - title: string; + /** + * The accessible name of the menu. Defaults to a translated name for the + * media kind. Never shown: each section carries its own heading. + */ + title?: string; /** If the Mute button is enabled */ enabled?: boolean; /** Callback if the mute button is clicked */ @@ -47,6 +63,18 @@ export interface MediaMuteAndSwitchButtonProps { options?: MenuOptions[]; /** The option that will currently be rendered as the selected option */ selectedOption?: string; + /** + * Output (speaker) devices, shown as their own section above the input + * section. Audio menu only; omitted entirely for video. + */ + outputOptions?: MenuOptions[]; + /** The output option currently rendered as selected */ + selectedOutputOption?: string; + /** + * Called when an output device is picked. Undefined means no output can be + * chosen here, and the section renders disabled. + */ + onSelectOutput?: (id: string) => void; videoBlurToggleClick?: () => void; videoBlurEnabled?: boolean; /** @@ -58,6 +86,31 @@ export interface MediaMuteAndSwitchButtonProps { 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"; + +/** + * The share of the call area the device list may fill. + * + * The menu carries its headings and the level meter as well, and a list that + * took the whole call would hide the call it belongs to. + */ +const LIST_SHARE_OF_CALL = 0.6; + +/** + * The shortest the device list may be, whatever the call measures. + * + * A share alone collapses in a small call to a list that shows one device and + * gives no sign that there are others. Scrolling a short list is the better + * failure. + */ +const MIN_LIST_HEIGHT = 160; + export const MediaMuteAndSwitchButton: FC = ({ title, enabled, @@ -66,82 +119,270 @@ export const MediaMuteAndSwitchButton: FC = ({ iconsAndLabels, options, selectedOption, + outputOptions, + selectedOutputOption, + onSelectOutput, videoBlurEnabled, videoBlurToggleClick, onSelect, }) => { - const [plannedSelection, setPlannedSelection] = useState(null); + // Which device we have asked for but not yet been given. Carries the kind as + // well as the id, because an input and an output can share an id: "default" + // names both on Chrome. + const [plannedSelection, setPlannedSelection] = useState<{ + kind: "input" | "output"; + id: string; + } | null>(null); const [menuOpen, setMenuOpen] = useState(false); + const onOpenChange = useCallback((open: boolean): void => { + setMenuOpen(open); + // A request that never arrived does not outlive the menu it was made in. + if (!open) setPlannedSelection(null); + }, []); const isBusy = busy ?? false; const { t } = useTranslation(); const devices = useMediaDevices(); + /** + * Tracks which modality moved the focus, for as long as the list is mounted. + * + * - Ours to track, because Radix focuses whatever the pointer is over, so + * `:focus-visible` answers for the pointer: Chromium says yes to anything + * after a key press, Firefox says no to programmatic focus. + * - A ref, not an effect on `menuOpen`: that state is ours and the open menu + * is Radix's, and an effect keyed on ours can run before Radix has mounted + * the content. The list existing is the honest signal. + * - On the menu, not the document: Element Call can be mounted twice in a + * host's page and this menu is portalled out of the call root, so a + * document listener would answer for the other instance too. + * - On the menu, not the list, because the first arrow key arrives while the + * menu itself holds focus — and because the blur toggle is the menu's + * child, not the list's, and has to answer to it as well. + * - In a dataset rather than state: which modality someone is using changes + * nothing that has to be rendered again. + */ + const trackFocusModality = useCallback( + (list: HTMLDivElement | null): (() => void) | undefined => { + const menu = list?.closest('[role="menu"]'); + if (menu === null || menu === undefined) return; + // Each opening starts over: the modality belongs to whoever is using this + // menu now, not to whoever last used it. + const record = (modality: "keyboard" | "pointer"): void => { + menu.dataset.focusModality = modality; + }; + record("pointer"); + const usedKeyboard = (): void => record("keyboard"); + const usedPointer = (): void => record("pointer"); + menu.addEventListener("keydown", usedKeyboard, true); + menu.addEventListener("pointermove", usedPointer, true); + return (): void => { + menu.removeEventListener("keydown", usedKeyboard, true); + menu.removeEventListener("pointermove", usedPointer, true); + }; + }, + [], + ); + + // The menu is portalled outside the call root, so nothing in the stylesheets + // can size it against the call. Measure the call area rather than the window, + // or the menu is wrong wherever Element Call is not the whole page. + const rootElement = useRootElement(); + const [listMaxHeight, setListMaxHeight] = useState(); + useEffect(() => { + if (!menuOpen) return; + // Followed rather than measured once: a host can resize the space Element + // Call is drawn in while the menu is open — a panel animating, a window + // dragged, a phone turned — and a bound taken on opening then describes a + // call area that no longer exists. Quantised before it reaches React, so a + // resize re-renders only when the bound itself moves. + const subscription = observeElementSize$(rootElement) + .pipe( + map(({ height }) => + Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)), + ), + distinctUntilChanged(), + ) + .subscribe(setListMaxHeight); + return (): void => subscription.unsubscribe(); + }, [menuOpen, rootElement]); + + // The meter sits over the foot of the scrolling list, so the list has to + // keep that much of itself clear: a row scrolled to by the keyboard would + // otherwise arrive underneath it, half-read. Its own height, measured, + // because the failure states are two lines where a level is one. + const [meterHeight, meter] = useMeasuredHeight(); + + // The headings stand over the head of the list, so it has to keep their + // height clear too — the same bargain as the meter, at the other end. One + // measurement serves both: the sections are headed alike. + const [headingHeight, heading] = useMeasuredHeight(); + useEffect(() => { if (menuOpen) devices.requestDeviceNames(); // No-op after the first call }, [menuOpen, devices]); - let button; - let toggles: { label: string; enabled: boolean; id: string }[] = []; - switch (iconsAndLabels) { - case "video": - button = ( - { - onMuteClick?.(); - e.preventDefault(); - e.stopPropagation(); - }} - disabled={isBusy || onMuteClick === undefined} - data-testid="incall_videomute" - /> - ); - if (videoBlurToggleClick !== undefined) { - toggles = [ + // The mute control differs between the two only in which button it is and + // what it is called; how it behaves is the same, and was worth saying once. + const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton; + const button = ( + { + onMuteClick?.(); + e.preventDefault(); + e.stopPropagation(); + }} + disabled={isBusy || onMuteClick === undefined} + data-testid={ + iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute" + } + /> + ); + + // Only the camera menu carries a toggle, and only when the caller offers one. + const toggles = + iconsAndLabels === "video" && videoBlurToggleClick !== undefined + ? [ { label: t("action.blur_background"), enabled: videoBlurEnabled ?? false, id: BLUR_ID, }, - ]; - } - break; - case "audio": - button = ( - { - onMuteClick?.(); - e.preventDefault(); - e.stopPropagation(); - }} - disabled={isBusy || onMuteClick === undefined} - data-testid="incall_mute" - /> - ); - break; - } + ] + : []; - let IconOptions: ComponentType> | undefined; let optionsButtonLabel: string; + let defaultMenuTitle: string; let numberedLabel: (number: number) => string; switch (iconsAndLabels) { case "video": - IconOptions = VideoCallIcon; optionsButtonLabel = t("settings.devices.camera"); + defaultMenuTitle = t("settings.devices.camera_source"); numberedLabel = (n): string => t("settings.devices.camera_numbered", { n }); break; case "audio": - IconOptions = MicOnIcon; optionsButtonLabel = t("settings.devices.microphone"); + defaultMenuTitle = t("settings.devices.mic_source"); numberedLabel = (n): string => t("settings.devices.microphone_numbered", { n }); break; } + /** The text shown for a device, whichever kind of label it carries. */ + const labelText = ( + label: MenuOptions["label"], + numbered: (n: number) => string, + ): string => { + switch (label.type) { + case "name": + return label.name; + case "number": + return numbered(label.number); + case "default": + return label.name === null + ? t("settings.devices.default") + : t("settings.devices.default_named_plain", { name: label.name }); + case "speaker": + return t("settings.devices.loudspeaker"); + case "earpiece": + return t("settings.devices.handset"); + } + }; + + // A device we asked for that has not arrived yet. Until it does, nothing in + // the menu can be picked, so a second request cannot overtake the first. + // + // A request only counts as in flight while the device is still on offer. One + // that is removed before it takes effect never arrives — the selection falls + // back to the default instead — and waiting for it would leave every device + // in both sections unselectable for the rest of the call. + const settling = + plannedSelection !== null && + plannedSelection.id !== + (plannedSelection.kind === "output" + ? selectedOutputOption + : selectedOption) && + (plannedSelection.kind === "output" ? outputOptions : options)?.some( + ({ id }) => id === plannedSelection.id, + ) === true; + + // 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 = ( + kind: "input" | "output", + items: MenuOptions[] | undefined, + selected: string | undefined, + select: ((id: string) => void) | undefined, + numbered: (n: number) => string, + ): ReactElement[] => { + const list = items ?? []; + // Shown but not choosable when nothing can be picked here, or when there is + // only one device. The entry stays visible so the menu keeps the same shape + // on every platform. + const disabled = select === undefined || list.length <= 1 || settling; + return list.map(({ label, id }) => ( + + {}} + /> + + } + onSelect={(e) => { + e.preventDefault(); + if (id === selected) return; + setPlannedSelection({ kind, id }); + select?.(id); + }} + key={id} + role="menuitemradio" + aria-checked={selected === id} + > + {selected !== id && + plannedSelection?.kind === kind && + plannedSelection.id === id && ( + + )} + + )); + }; + return (
= ({ {/* The mute button lives inside */} {button} = ({ /> } > - {options?.map(({ label, id }) => { - let labelText: string; - switch (label.type) { - case "name": - labelText = label.name; - break; - case "number": - labelText = numberedLabel(label.number); - break; +
- ) - } - onSelect={(e) => { - e.preventDefault(); - if (id === selectedOption) return; - setPlannedSelection(id); - onSelect?.(id); - }} - key={id} - role="menuitemradio" - aria-checked={selectedOption === id} - > - {selectedOption === id && ( - + {iconsAndLabels === "audio" && speakerOptions && ( + <> + {/* A menu may only contain items, separators and groups, so each + heading belongs to a group rather than sitting beside the + items it names. */} +
+ {/* The heading is decoration: the group carries the name, and + a menu may only contain items, separators and groups. */} +
+ +
+ {deviceItems( + "output", + speakerOptions, + selectedSpeaker, + onSelectOutput, + (n) => t("settings.devices.speaker_numbered", { n }), + )} +
+ + )} +
+
+ +
+ {/* The heading sits outside, so the meter can never ride up over it: + sticky only holds while this block is in view. */} +
+ {deviceItems( + "input", + options, + selectedOption, + onSelect, + numberedLabel, + )} + {iconsAndLabels === "audio" && ( + )} - {selectedOption !== id && plannedSelection === id && ( - - )} - - ); - })} - {(toggles?.length ?? 0) > 0 &&
} - {toggles?.map((toggle) => ( +
+
+
+ {toggles.length > 0 &&
} + {toggles.map((toggle) => ( { videoBlurToggleClick?.(); e.preventDefault(); }} - checked={toggle.enabled ?? false} + checked={toggle.enabled} key={toggle.id} /> ))} @@ -239,3 +496,30 @@ export const MediaMuteAndSwitchButton: FC = ({
); }; + +/** + * Follows an element's height, for the two pieces of chrome that stand over the + * scrolling device list. Both have to keep their own height clear of it, and + * neither height is knowable in advance: a heading wraps, and the meter's + * failure states are two lines where a level is one. + */ +function useMeasuredHeight(): [ + number | undefined, + (element: HTMLElement | null) => (() => void) | undefined, +] { + const [height, setHeight] = useState(); + const ref = useCallback( + (element: HTMLElement | null): (() => void) | undefined => { + if (element === null) return; + const subscription = observeElementSize$(element) + .pipe( + map((size) => size.height), + distinctUntilChanged(), + ) + .subscribe(setHeight); + return (): void => subscription.unsubscribe(); + }, + [], + ); + return [height, ref]; +}