diff --git a/locales/en/app.json b/locales/en/app.json
index 103d74238..c324ff11d 100644
--- a/locales/en/app.json
+++ b/locales/en/app.json
@@ -24,6 +24,11 @@
},
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy2> and our <6>Cookie Policy6>.",
"audio_menu": {
+ "mic_level_active": "Picking up sound",
+ "mic_level_label": "Microphone level",
+ "mic_level_silent": "No sound detected",
+ "mic_permission_denied": "Microphone access is blocked. Allow it in your browser settings.",
+ "mic_unavailable": "Microphone unavailable",
"title": "Audio controls"
},
"call_ended_view": {
diff --git a/src/components/AudioLevelMeter.module.css b/src/components/AudioLevelMeter.module.css
new file mode 100644
index 000000000..3098c46ae
--- /dev/null
+++ b/src/components/AudioLevelMeter.module.css
@@ -0,0 +1,76 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+.meter {
+ display: flex;
+ align-items: center;
+ gap: var(--cpd-space-3x);
+ padding-block: var(--cpd-space-2x);
+ padding-inline-start: var(--cpd-space-4x);
+ /* Stop the bars where the device labels stop rather than at the menu edge:
+ a menu item keeps a 4x margin after its label plus a 2x column reserved
+ for the chevron, on top of the menu's own 4x padding. */
+ padding-inline-end: calc(2 * var(--cpd-space-4x) + var(--cpd-space-2x));
+ border-radius: var(--cpd-radius-pill-effect);
+}
+
+.meter:focus-visible {
+ outline: 2px solid var(--cpd-color-border-focused);
+ outline-offset: -2px;
+}
+
+.icon {
+ color: var(--cpd-color-icon-secondary);
+ flex-shrink: 0;
+}
+
+.bars {
+ display: flex;
+ align-items: center;
+ gap: var(--cpd-space-2x);
+ flex: 1;
+}
+
+.bar {
+ /* The bars share out whatever the gaps leave over, so the gap above and the
+ bar count together set how thick a bar is. Across the inset row that lands
+ near 6px per bar against the 8px gap, as in the design. */
+ flex: 1;
+ height: 20px;
+ border-radius: var(--cpd-radius-pill-effect);
+ background-color: var(--cpd-color-bg-subtle-primary);
+ transition: background-color 60ms linear;
+}
+
+.lit {
+ background-color: var(--cpd-color-icon-accent-primary);
+}
+
+.unavailable .icon,
+.unavailable .bar {
+ opacity: 0.5;
+}
+
+.message {
+ display: flex;
+ align-items: center;
+ gap: var(--cpd-space-3x);
+ padding-block: var(--cpd-space-2x);
+ padding-inline-start: var(--cpd-space-4x);
+ padding-inline-end: calc(2 * var(--cpd-space-4x) + var(--cpd-space-2x));
+ color: var(--cpd-color-text-secondary);
+ font: var(--cpd-font-body-sm-regular);
+}
+
+.srOnly {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
diff --git a/src/components/AudioLevelMeter.stories.tsx b/src/components/AudioLevelMeter.stories.tsx
new file mode 100644
index 000000000..106b88fc7
--- /dev/null
+++ b/src/components/AudioLevelMeter.stories.tsx
@@ -0,0 +1,67 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { expect, within } from "storybook/test";
+import { type JSX } from "react";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { AudioLevelMeter } from "./AudioLevelMeter";
+
+const meta = {
+ component: AudioLevelMeter,
+ // The meter is only ever seen inside the audio menu, so give it a comparable
+ // width to judge the bar spacing against.
+ decorators: [
+ (Story): JSX.Element => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Silent: Story = {
+ args: { state: { type: "active", level: 0 } },
+ play: async ({ canvasElement }) => {
+ const meter = within(canvasElement).getByRole("meter");
+ await expect(meter).toHaveAttribute("aria-valuetext", "No sound detected");
+ },
+};
+
+export const Speaking: Story = {
+ args: { state: { type: "active", level: 0.45 } },
+ play: async ({ canvasElement }) => {
+ const meter = within(canvasElement).getByRole("meter");
+ await expect(meter).toHaveAttribute("aria-valuetext", "Picking up sound");
+ },
+};
+
+export const Loud: Story = {
+ args: { state: { type: "active", level: 0.95 } },
+};
+
+export const Unavailable: Story = {
+ args: { state: { type: "unavailable" } },
+ play: async ({ canvasElement }) => {
+ const meter = within(canvasElement).getByRole("meter");
+ await expect(meter).toHaveAttribute("data-unavailable", "true");
+ },
+};
+
+export const PermissionDenied: Story = {
+ args: { state: { type: "denied" } },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.queryByRole("meter")).toBe(null);
+ await expect(
+ canvas.getByText(/Microphone access is blocked/),
+ ).toBeInTheDocument();
+ },
+};
diff --git a/src/components/AudioLevelMeter.test.tsx b/src/components/AudioLevelMeter.test.tsx
new file mode 100644
index 000000000..ee3fde33a
--- /dev/null
+++ b/src/components/AudioLevelMeter.test.tsx
@@ -0,0 +1,58 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+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 } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import { AudioLevelMeter } from "./AudioLevelMeter";
+
+describe("AudioLevelMeter", () => {
+ test("a denied microphone renders a hint instead of the meter", () => {
+ render();
+ expect(screen.getByTestId("mic_level_denied")).toHaveTextContent(
+ /microphone access is blocked/i,
+ );
+ // A hint replaces the meter rather than sitting next to a dead one.
+ expect(screen.queryByRole("meter")).toBe(null);
+ });
+
+ test("level indicator greys out when the microphone is unavailable", () => {
+ render();
+ const meter = screen.getByRole("meter");
+ expect(meter).toHaveAttribute("data-unavailable", "true");
+ expect(meter).toHaveAttribute("aria-valuetext", "Microphone unavailable");
+ });
+
+ test("level indicator reports its level to assistive technology", () => {
+ const { rerender } = render(
+ ,
+ );
+ const meter = screen.getByRole("meter");
+ expect(meter).toHaveAttribute("aria-valuenow", "0");
+ expect(meter).toHaveAttribute("aria-valuetext", "No sound detected");
+
+ rerender();
+ expect(screen.getByRole("meter")).toHaveAttribute(
+ "aria-valuetext",
+ "Picking up sound",
+ );
+ });
+
+ test("level indicator announces its state only while focused", async () => {
+ const user = userEvent.setup();
+ render();
+ const meter = screen.getByRole("meter");
+
+ // Nothing is announced until the user puts the meter in focus, so the
+ // level does not talk over the rest of the menu.
+ expect(meter.textContent).not.toContain("Picking up sound");
+ await user.tab();
+ expect(meter).toHaveFocus();
+ expect(meter.textContent).toContain("Picking up sound");
+ });
+});
diff --git a/src/components/AudioLevelMeter.tsx b/src/components/AudioLevelMeter.tsx
new file mode 100644
index 000000000..3a2607673
--- /dev/null
+++ b/src/components/AudioLevelMeter.tsx
@@ -0,0 +1,98 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { type FC, useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ MicOnIcon,
+ MicOffIcon,
+} from "@vector-im/compound-design-tokens/assets/web/icons";
+import classNames from "classnames";
+
+import styles from "./AudioLevelMeter.module.css";
+import { type MicrophoneLevelState } from "./useMicrophoneLevel";
+
+// The bars share the row evenly, so the count is what sets their width: more
+// bars means thinner ones, and a finer-grained reading of the level.
+const BAR_COUNT = 18;
+/**
+ * Level below which the meter reads as silent. Above the noise floor of a
+ * typical desk microphone, so an idle room does not announce itself as sound.
+ */
+const SPEECH_THRESHOLD = 0.06;
+
+export interface AudioLevelMeterProps {
+ state: MicrophoneLevelState;
+}
+
+/**
+ * A live indicator of the signal level at the selected microphone.
+ *
+ * Focusable, so that a screen reader user can put it in focus and hear whether
+ * the microphone is picking anything up; the bars alone carry that information
+ * for everyone else.
+ */
+export const AudioLevelMeter: FC = ({ state }) => {
+ const { t } = useTranslation();
+ const [focused, setFocused] = useState(false);
+
+ if (state.type === "denied")
+ return (
+
+
+ {t("audio_menu.mic_permission_denied")}
+
+ );
+
+ const unavailable = state.type === "unavailable";
+ const level = state.type === "active" ? state.level : 0;
+ const litBars = unavailable ? 0 : Math.round(level * BAR_COUNT);
+ const speaking = !unavailable && level >= SPEECH_THRESHOLD;
+ const stateText = unavailable
+ ? t("audio_menu.mic_unavailable")
+ : speaking
+ ? t("audio_menu.mic_level_active")
+ : t("audio_menu.mic_level_silent");
+
+ return (
+ setFocused(true)}
+ onBlur={() => setFocused(false)}
+ aria-label={t("audio_menu.mic_level_label")}
+ aria-valuemin={0}
+ aria-valuemax={1}
+ aria-valuenow={level}
+ aria-valuetext={stateText}
+ >
+
+
+ {Array.from({ length: BAR_COUNT }, (_, i) => (
+
+ ))}
+
+ {/* Only announces while the meter holds focus, so the level does not
+ interrupt a screen reader reading the rest of the menu. */}
+
+ {focused ? stateText : ""}
+
+
+ );
+};
diff --git a/src/components/useMicrophoneLevel.test.tsx b/src/components/useMicrophoneLevel.test.tsx
new file mode 100644
index 000000000..9b40ce4cf
--- /dev/null
+++ b/src/components/useMicrophoneLevel.test.tsx
@@ -0,0 +1,234 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { act, renderHook, waitFor } from "@testing-library/react";
+
+import { useMicrophoneLevel } from "./useMicrophoneLevel";
+
+describe("useMicrophoneLevel", () => {
+ test("level indicator follows the microphone signal level", async () => {
+ const { stream } = fakeStream();
+ const getUserMedia = vi.fn().mockResolvedValue(stream);
+ vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
+
+ const { result } = renderHook(() => useMicrophoneLevel("mic-1", true));
+ await waitFor(() => expect(result.current.type).toBe("active"));
+ expect(result.current).toEqual({ type: "active", level: 0 });
+
+ // Speech arrives.
+ amplitude = 0.2;
+ tick(4);
+ expect(result.current.type).toBe("active");
+ const loud = result.current as { type: "active"; level: number };
+ expect(loud.level).toBeGreaterThan(0.3);
+
+ // ...and stops. The level eases back down rather than snapping to zero.
+ amplitude = 0;
+ tick(30);
+ const quiet = result.current as { type: "active"; level: number };
+ expect(quiet.level).toBeLessThan(loud.level);
+ });
+
+ test("metering resumes a capture the browser starts suspended", async () => {
+ const { stream } = fakeStream();
+ vi.stubGlobal("navigator", {
+ mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
+ });
+
+ const { result } = renderHook(() => useMicrophoneLevel("mic-1", true));
+ await waitFor(() => expect(result.current.type).toBe("active"));
+
+ // A suspended context reports silence however loud the microphone is,
+ // which is what Firefox hands us when the page has no user activation.
+ expect(resumed).toBe(1);
+ });
+
+ test("level indicator stays idle for a silent microphone", async () => {
+ const { stream } = fakeStream();
+ vi.stubGlobal("navigator", {
+ mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
+ });
+
+ const { result } = renderHook(() => useMicrophoneLevel("mic-1", true));
+ await waitFor(() => expect(result.current.type).toBe("active"));
+ tick(10);
+ expect(result.current).toEqual({ type: "active", level: 0 });
+ });
+
+ test("capture is re-pointed when the microphone changes", async () => {
+ const first = fakeStream();
+ const second = fakeStream();
+ const getUserMedia = vi
+ .fn()
+ .mockResolvedValueOnce(first.stream)
+ .mockResolvedValueOnce(second.stream);
+ vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
+
+ const { result, rerender } = renderHook(
+ ({ id }: { id: string }) => useMicrophoneLevel(id, true),
+ { initialProps: { id: "mic-1" } },
+ );
+ await waitFor(() => expect(result.current.type).toBe("active"));
+ expect(getUserMedia).toHaveBeenLastCalledWith({
+ audio: { deviceId: { exact: "mic-1" } },
+ });
+
+ rerender({ id: "mic-2" });
+ await waitFor(() =>
+ expect(getUserMedia).toHaveBeenLastCalledWith({
+ audio: { deviceId: { exact: "mic-2" } },
+ }),
+ );
+ // The capture of the microphone we left must not survive the switch.
+ expect(first.stop).toHaveBeenCalled();
+ expect(second.stop).not.toHaveBeenCalled();
+ });
+
+ test("menu capture is released when closed during a device switch", async () => {
+ const { stream, stop } = fakeStream();
+ let resolveCapture: (s: MediaStream) => void = () => {};
+ const capture = new Promise((resolve) => {
+ resolveCapture = resolve;
+ });
+ const getUserMedia = vi.fn().mockReturnValue(capture);
+ vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
+
+ const { result, rerender } = renderHook(
+ ({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on),
+ { initialProps: { on: true } },
+ );
+
+ // The menu closes before the microphone finishes opening.
+ rerender({ on: false });
+ await act(async () => {
+ resolveCapture(stream);
+ await capture;
+ });
+
+ expect(stop).toHaveBeenCalled();
+ expect(result.current).toEqual({ type: "inactive" });
+ expect(frames).toHaveLength(0);
+ });
+
+ test("microphone capture is closed when metering stops", async () => {
+ const { stream, stop } = fakeStream();
+ vi.stubGlobal("navigator", {
+ mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) },
+ });
+
+ const { result, rerender } = renderHook(
+ ({ on }: { on: boolean }) => useMicrophoneLevel("mic-1", on),
+ { initialProps: { on: true } },
+ );
+ await waitFor(() => expect(result.current.type).toBe("active"));
+
+ rerender({ on: false });
+ expect(stop).toHaveBeenCalled();
+ expect(closed).toBe(1);
+ expect(result.current).toEqual({ type: "inactive" });
+ });
+
+ test("a denied microphone is distinguished from one that cannot be opened", async () => {
+ const denied = Object.assign(new Error("no"), { name: "NotAllowedError" });
+ const busy = Object.assign(new Error("busy"), { name: "NotReadableError" });
+ const getUserMedia = vi
+ .fn()
+ .mockRejectedValueOnce(denied)
+ .mockRejectedValueOnce(busy);
+ vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
+
+ const { result, rerender } = renderHook(
+ ({ id }: { id: string }) => useMicrophoneLevel(id, true),
+ { initialProps: { id: "mic-1" } },
+ );
+ await waitFor(() => expect(result.current).toEqual({ type: "denied" }));
+
+ rerender({ id: "mic-2" });
+ await waitFor(() =>
+ expect(result.current).toEqual({ type: "unavailable" }),
+ );
+ });
+
+ test("no capture is taken while metering is disabled", () => {
+ const getUserMedia = vi.fn();
+ vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
+
+ const { result } = renderHook(() => useMicrophoneLevel("mic-1", false));
+ expect(getUserMedia).not.toHaveBeenCalled();
+ expect(result.current).toEqual({ type: "inactive" });
+ });
+
+ let amplitude = 0;
+ let frames: (() => void)[] = [];
+ let closed = 0;
+ let resumed = 0;
+
+ beforeEach(() => {
+ amplitude = 0;
+ frames = [];
+ closed = 0;
+ resumed = 0;
+
+ vi.stubGlobal("requestAnimationFrame", (cb: () => void) => {
+ frames.push(cb);
+ return frames.length;
+ });
+ vi.stubGlobal("cancelAnimationFrame", () => {
+ frames = [];
+ });
+ vi.stubGlobal(
+ "AudioContext",
+ class {
+ public createAnalyser(): unknown {
+ return {
+ fftSize: 1024,
+ getFloatTimeDomainData: (out: Float32Array): void => {
+ out.fill(amplitude);
+ },
+ };
+ }
+ public createMediaStreamSource(): { connect: () => void } {
+ return { connect: (): void => {} };
+ }
+ public async resume(): Promise {
+ resumed++;
+ await Promise.resolve();
+ }
+ public async close(): Promise {
+ closed++;
+ await Promise.resolve();
+ }
+ },
+ );
+ });
+
+ afterEach(() => vi.unstubAllGlobals());
+
+ /** A microphone that reports a constant amplitude on every sample. */
+ function fakeStream(): {
+ stream: MediaStream;
+ stop: ReturnType;
+ } {
+ const stop = vi.fn();
+ const stream = {
+ getTracks: () => [{ stop }],
+ } as unknown as MediaStream;
+ return { stream, stop };
+ }
+
+ /** Runs one animation frame, if the hook has asked for one. */
+ function tick(times = 1): void {
+ for (let i = 0; i < times; i++) {
+ const pending = frames;
+ frames = [];
+ act(() => {
+ pending.forEach((f) => f());
+ });
+ }
+ }
+});
diff --git a/src/components/useMicrophoneLevel.ts b/src/components/useMicrophoneLevel.ts
new file mode 100644
index 000000000..9e0968d00
--- /dev/null
+++ b/src/components/useMicrophoneLevel.ts
@@ -0,0 +1,149 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { useEffect, useState } from "react";
+import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
+
+/**
+ * The state of the microphone level indicator.
+ *
+ * `inactive` is the resting state: nothing is being captured, because nothing
+ * has asked for a level yet. `active` means we hold a capture and `level` is
+ * the current signal level in the range 0..1. `denied` and `unavailable` are
+ * the two failure modes we distinguish, because they need different UI: the
+ * first is recoverable by the user, the second is not.
+ */
+export type MicrophoneLevelState =
+ | { type: "inactive" }
+ | { type: "active"; level: number }
+ | { type: "denied" }
+ | { type: "unavailable" };
+
+/** Signal at or below this many dBFS reads as silence. */
+const FLOOR_DB = -60;
+/**
+ * Smoothing applied to the displayed level. Rises are followed almost
+ * immediately so speech registers at once; falls are eased so the bars do not
+ * flicker between syllables.
+ */
+const ATTACK = 0.5;
+const RELEASE = 0.12;
+
+/**
+ * Observes the signal level of a microphone, for as long as `enabled` holds.
+ *
+ * The capture is owned by this hook and is torn down whenever `enabled` goes
+ * false, the device changes, or the component unmounts, including when any of
+ * those happen while the capture is still being acquired. Nothing outlives the
+ * caller.
+ *
+ * @param deviceId - The microphone to observe, or undefined if none is selected.
+ * @param enabled - Whether to hold a capture at all.
+ */
+export function useMicrophoneLevel(
+ deviceId: string | undefined,
+ enabled: boolean,
+): MicrophoneLevelState {
+ const [state, setState] = useState({
+ type: "inactive",
+ });
+
+ useEffect(() => {
+ if (!enabled || deviceId === undefined) {
+ setState({ type: "inactive" });
+ return;
+ }
+
+ // Guards every asynchronous continuation below: the effect can be cleaned
+ // up while getUserMedia is still in flight, and the stream it eventually
+ // resolves with must be stopped rather than left running.
+ let disposed = false;
+ let stream: MediaStream | undefined;
+ let context: AudioContext | undefined;
+ let frame: number | undefined;
+
+ const dispose = (): void => {
+ disposed = true;
+ if (frame !== undefined) cancelAnimationFrame(frame);
+ stream?.getTracks().forEach((t) => t.stop());
+ // close() rejects if the context is already closed, which is possible if
+ // the browser tore it down with the page.
+ context?.close().catch(() => {});
+ stream = undefined;
+ context = undefined;
+ frame = undefined;
+ };
+
+ navigator.mediaDevices
+ .getUserMedia({ audio: { deviceId: { exact: deviceId } } })
+ .then((acquired) => {
+ if (disposed) {
+ acquired.getTracks().forEach((t) => t.stop());
+ return;
+ }
+ stream = acquired;
+ context = new AudioContext();
+ // Firefox starts a context suspended unless the page has user
+ // activation, and a suspended context feeds the analyser silence
+ // rather than the microphone. Resuming one that already runs is a
+ // no-op.
+ void context.resume().catch(() => {});
+ const analyser = context.createAnalyser();
+ analyser.fftSize = 1024;
+ context.createMediaStreamSource(acquired).connect(analyser);
+ const samples = new Float32Array(analyser.fftSize);
+
+ let smoothed = 0;
+ const tick = (): void => {
+ if (disposed) return;
+ analyser.getFloatTimeDomainData(samples);
+ const level = amplitudeToLevel(rms(samples));
+ smoothed +=
+ (level - smoothed) * (level > smoothed ? ATTACK : RELEASE);
+ setState({ type: "active", level: smoothed });
+ frame = requestAnimationFrame(tick);
+ };
+ setState({ type: "active", level: 0 });
+ frame = requestAnimationFrame(tick);
+ })
+ .catch((e: unknown) => {
+ if (disposed) return;
+ rootLogger
+ .getChild("[useMicrophoneLevel]")
+ .warn("Could not open microphone for level metering", e);
+ setState(classifyError(e));
+ });
+
+ return dispose;
+ }, [deviceId, enabled]);
+
+ return state;
+}
+
+/** Root mean square of a block of samples, as a linear amplitude. */
+function rms(samples: Float32Array): number {
+ let sum = 0;
+ for (const s of samples) sum += s * s;
+ return Math.sqrt(sum / samples.length);
+}
+
+/** Maps a linear amplitude onto 0..1 over a fixed dBFS window. */
+function amplitudeToLevel(amplitude: number): number {
+ if (amplitude <= 0) return 0;
+ const db = 20 * Math.log10(amplitude);
+ if (db <= FLOOR_DB) return 0;
+ return Math.min(1, db / -FLOOR_DB + 1);
+}
+
+function classifyError(e: unknown): MicrophoneLevelState {
+ const name = e instanceof Error ? e.name : "";
+ // NotAllowedError is the modern name; SecurityError is what older WebKit
+ // raises for the same situation.
+ if (name === "NotAllowedError" || name === "SecurityError")
+ return { type: "denied" };
+ return { type: "unavailable" };
+}