From dd002265262ef65e7beee9c6abc511f5d87ec7d3 Mon Sep 17 00:00:00 2001 From: fkwp Date: Tue, 22 Sep 2026 14:56:47 +0200 Subject: [PATCH] Show speakers and microphones in the quick audio menu - Two labelled sections, speaker above microphone, with radio selection in both, the pattern the settings list already uses. - A device that cannot be chosen is shown disabled rather than hidden, so the menu keeps one shape across browsers; where a platform lists no output at all, a disabled Default stands in that section instead of leaving a bare heading. - The list is bounded by the measured call area rather than the window, so it stays usable where Element Call is a component in a corner of a host's page, and follows that area when the host resizes it. - It scrolls with its section headings pinned and the meter held at its foot, and keeps both clear of whichever row the keyboard has just reached. - The focus ring is shown only when the keyboard moved the focus, tracked on the menu rather than the document, so two calls on one page cannot answer for each other. - While a requested device has not arrived, nothing in either section can be picked, so a second request cannot overtake the first. That holds only while the device is still offered and only within the menu it was asked in: one removed before the switch lands never arrives, and waiting for it would leave every device unselectable for the rest of the call. Co-Authored-By: Claude Opus 5 --- .../MediaMuteAndSwitchButton.module.css | 100 +++ .../MediaMuteAndSwitchButton.test.tsx | 572 +++++++++++++++++- src/components/MediaMuteAndSwitchButton.tsx | 494 +++++++++++---- 3 files changed, 1043 insertions(+), 123 deletions(-) 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]; +}