mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-25 22:35:49 +00:00
Draw the microphone level without re-rendering
- The level state carries a Behavior, so the state changes only when its kind does. - The meter renders its bars once and an effect draws the level into them. - The render-counting test now counts every commit, and asserts none while the level moves. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,24 +15,16 @@ import {
|
|||||||
type RenderResult,
|
type RenderResult,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { type JSX, useState, type ReactNode } from "react";
|
import { Profiler, type JSX, useState, type ReactNode } from "react";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MediaMuteAndSwitchButton,
|
MediaMuteAndSwitchButton,
|
||||||
type MenuOptions,
|
type MenuOptions,
|
||||||
} from "./MediaMuteAndSwitchButton";
|
} from "./MediaMuteAndSwitchButton";
|
||||||
import { MediaDevicesContext, useMediaDevices } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
import { type MediaDevices } from "../state/MediaDevices";
|
import { type MediaDevices } from "../state/MediaDevices";
|
||||||
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
|
||||||
import type * as MediaDevicesContextModule from "../MediaDevicesContext";
|
|
||||||
|
|
||||||
// The menu reads the devices on every render and the meter never does, so
|
|
||||||
// these calls count the menu's own renders.
|
|
||||||
vi.mock("../MediaDevicesContext", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof MediaDevicesContextModule>();
|
|
||||||
return { ...actual, useMediaDevices: vi.fn(actual.useMediaDevices) };
|
|
||||||
});
|
|
||||||
|
|
||||||
interface RenderOptions {
|
interface RenderOptions {
|
||||||
requestDeviceNames: () => void;
|
requestDeviceNames: () => void;
|
||||||
@@ -488,15 +480,19 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
).not.toHaveAttribute("aria-disabled", "true");
|
).not.toHaveAttribute("aria-disabled", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("redraws the meter and not the device rows around it", async () => {
|
test("moves the level without re-rendering anything", async () => {
|
||||||
// The level is held by the meter, so a moving level doesn't re-render the
|
// The level is drawn into the DOM, so a moving level commits nothing.
|
||||||
// menu.
|
|
||||||
const capture = stubAudioCapture();
|
const capture = stubAudioCapture();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const menuRenders = vi.mocked(useMediaDevices);
|
let commits = 0;
|
||||||
|
|
||||||
const { getByRole } = renderComponent(
|
const { getByRole } = renderComponent(
|
||||||
<>
|
<Profiler
|
||||||
|
id="menu"
|
||||||
|
onRender={(): void => {
|
||||||
|
commits++;
|
||||||
|
}}
|
||||||
|
>
|
||||||
<MediaMuteAndSwitchButton
|
<MediaMuteAndSwitchButton
|
||||||
iconsAndLabels="audio"
|
iconsAndLabels="audio"
|
||||||
enabled={true}
|
enabled={true}
|
||||||
@@ -513,13 +509,16 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
selectedOutputOption="spk1"
|
selectedOutputOption="spk1"
|
||||||
onSelectOutput={vi.fn()}
|
onSelectOutput={vi.fn()}
|
||||||
/>
|
/>
|
||||||
</>,
|
</Profiler>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(getByRole("button", { name: "Microphone" }));
|
await user.click(getByRole("button", { name: "Microphone" }));
|
||||||
capture.grant();
|
capture.grant();
|
||||||
|
await vi.waitFor(() => expect(capture.contexts).toHaveLength(1));
|
||||||
const meter = await screen.findByRole("meter");
|
const meter = await screen.findByRole("meter");
|
||||||
const settled = menuRenders.mock.calls.length;
|
// The capture's arrival is one render: the idle level swapped for its own.
|
||||||
|
await act(async () => {});
|
||||||
|
const settled = commits;
|
||||||
|
|
||||||
// The meter smooths by elapsed time, so hand-driven frames need a clock.
|
// The meter smooths by elapsed time, so hand-driven frames need a clock.
|
||||||
let elapsed = performance.now();
|
let elapsed = performance.now();
|
||||||
@@ -538,7 +537,7 @@ describe("MediaMuteAndSwitchButton", () => {
|
|||||||
clock.mockRestore();
|
clock.mockRestore();
|
||||||
|
|
||||||
expect(meter.getAttribute("aria-valuenow")).not.toBe("0");
|
expect(meter.getAttribute("aria-valuenow")).not.toBe("0");
|
||||||
expect(menuRenders.mock.calls.length - settled).toBe(0);
|
expect(commits - settled).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
|
test("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "./MicrophoneLevelMeter";
|
} from "./MicrophoneLevelMeter";
|
||||||
import styles from "./MicrophoneLevelMeter.module.css";
|
import styles from "./MicrophoneLevelMeter.module.css";
|
||||||
import { LEVEL_SCALE } from "../state/MicrophoneLevel";
|
import { LEVEL_SCALE } from "../state/MicrophoneLevel";
|
||||||
|
import { constant } from "../state/Behavior";
|
||||||
|
|
||||||
/** Roughly the menu's width. It only decides how many bars fit. */
|
/** Roughly the menu's width. It only decides how many bars fit. */
|
||||||
const STORY_WIDTH = 256;
|
const STORY_WIDTH = 256;
|
||||||
@@ -41,18 +42,18 @@ type Story = StoryObj<typeof meta>;
|
|||||||
|
|
||||||
/** A quiet room: hiss below the noise floor lights nothing. */
|
/** A quiet room: hiss below the noise floor lights nothing. */
|
||||||
export const Silent: Story = {
|
export const Silent: Story = {
|
||||||
args: { state: { type: "level", level: 0 } },
|
args: { state: { type: "level", level: constant(0) } },
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
await expect(litSegments(canvasElement)).toBe(0);
|
await expect(litSegments(canvasElement)).toBe(0);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const QuietSpeech: Story = {
|
export const QuietSpeech: Story = {
|
||||||
args: { state: { type: "level", level: 5 } },
|
args: { state: { type: "level", level: constant(5) } },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NormalSpeech: Story = {
|
export const NormalSpeech: Story = {
|
||||||
args: { state: { type: "level", level: 12 } },
|
args: { state: { type: "level", level: constant(12) } },
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
// A floor, not a count: the count follows from the design's bar and gap sizes.
|
// A floor, not a count: the count follows from the design's bar and gap sizes.
|
||||||
await expect(
|
await expect(
|
||||||
@@ -62,16 +63,20 @@ export const NormalSpeech: Story = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const LoudSpeech: Story = {
|
export const LoudSpeech: Story = {
|
||||||
args: { state: { type: "level", level: LEVEL_SCALE } },
|
args: { state: { type: "level", level: constant(LEVEL_SCALE) } },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The three volumes differ in how many bars are lit, not only in colour. */
|
/** The three volumes differ in how many bars are lit, not only in colour. */
|
||||||
export const VolumesAreDistinguishable: Story = {
|
export const VolumesAreDistinguishable: Story = {
|
||||||
args: { state: { type: "level", level: 5 } },
|
args: { state: { type: "level", level: constant(5) } },
|
||||||
play: async ({ canvasElement, mount }) => {
|
play: async ({ canvasElement, mount }) => {
|
||||||
const lit: number[] = [];
|
const lit: number[] = [];
|
||||||
for (const level of [5, 12, LEVEL_SCALE]) {
|
for (const level of [5, 12, LEVEL_SCALE]) {
|
||||||
await mount(<MicrophoneLevelMeter state={{ type: "level", level }} />);
|
await mount(
|
||||||
|
<MicrophoneLevelMeter
|
||||||
|
state={{ type: "level", level: constant(level) }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
lit.push(litSegments(canvasElement));
|
lit.push(litSegments(canvasElement));
|
||||||
await expect(within(canvasElement).getByRole("meter")).toHaveAttribute(
|
await expect(within(canvasElement).getByRole("meter")).toHaveAttribute(
|
||||||
"aria-valuenow",
|
"aria-valuenow",
|
||||||
@@ -116,7 +121,7 @@ export const NoDevice: Story = {
|
|||||||
|
|
||||||
/** The same meter at two widths: the bars keep their size and only their count changes. */
|
/** The same meter at two widths: the bars keep their size and only their count changes. */
|
||||||
export const ShapeStaysTheSameAtAnyWidth: Story = {
|
export const ShapeStaysTheSameAtAnyWidth: Story = {
|
||||||
args: { state: { type: "level", level: 12 } },
|
args: { state: { type: "level", level: constant(12) } },
|
||||||
play: async ({ mount, args }) => {
|
play: async ({ mount, args }) => {
|
||||||
const narrow = await measureAt(mount, args, 180);
|
const narrow = await measureAt(mount, args, 180);
|
||||||
const wide = await measureAt(mount, args, 400);
|
const wide = await measureAt(mount, args, 400);
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import { render, screen } from "@testing-library/react";
|
|||||||
|
|
||||||
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
|
||||||
import { LEVEL_SCALE } from "../state/MicrophoneLevel";
|
import { LEVEL_SCALE } from "../state/MicrophoneLevel";
|
||||||
|
import { constant } from "../state/Behavior";
|
||||||
|
|
||||||
describe("MicrophoneLevelMeter", () => {
|
describe("MicrophoneLevelMeter", () => {
|
||||||
test("announces the level rather than relying on hue", () => {
|
test("announces the level rather than relying on hue", () => {
|
||||||
render(<MicrophoneLevelMeter state={{ type: "level", level: 6 }} />);
|
render(
|
||||||
|
<MicrophoneLevelMeter state={{ type: "level", level: constant(6) }} />,
|
||||||
|
);
|
||||||
|
|
||||||
const meter = screen.getByRole("meter", { name: "Microphone level" });
|
const meter = screen.getByRole("meter", { name: "Microphone level" });
|
||||||
expect(meter).toHaveAttribute("aria-valuenow", "6");
|
expect(meter).toHaveAttribute("aria-valuenow", "6");
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState, type FC, type Ref } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FC,
|
||||||
|
type Ref,
|
||||||
|
} from "react";
|
||||||
import { Text } from "@vector-im/compound-web";
|
import { Text } from "@vector-im/compound-web";
|
||||||
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -57,20 +64,45 @@ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
|||||||
// Bars keep one size, so their count follows the width. Starts full so the
|
// Bars keep one size, so their count follows the width. Starts full so the
|
||||||
// first paint, and jsdom, draw a whole meter.
|
// first paint, and jsdom, draw a whole meter.
|
||||||
const [barCount, setBarCount] = useState(LEVEL_SCALE);
|
const [barCount, setBarCount] = useState(LEVEL_SCALE);
|
||||||
|
const segments = useRef<HTMLDivElement | null>(null);
|
||||||
const track = useCallback(
|
const track = useCallback(
|
||||||
(element: HTMLDivElement | null): (() => void) | undefined => {
|
(element: HTMLDivElement | null): (() => void) | undefined => {
|
||||||
if (element === null) return;
|
if (element === null) return;
|
||||||
|
segments.current = element;
|
||||||
const subscription = observeElementSize$(element)
|
const subscription = observeElementSize$(element)
|
||||||
.pipe(
|
.pipe(
|
||||||
map(({ width }) => barsThatFit(element, width)),
|
map(({ width }) => barsThatFit(element, width)),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
)
|
)
|
||||||
.subscribe(setBarCount);
|
.subscribe(setBarCount);
|
||||||
return (): void => subscription.unsubscribe();
|
return (): void => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
segments.current = null;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Drawn straight into the DOM: the level changes many times a second, and
|
||||||
|
// re-rendering for each change is what this avoids.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const element = segments.current;
|
||||||
|
if (state.type !== "level" || element === null) return;
|
||||||
|
const subscription = state.level.subscribe((level) => {
|
||||||
|
element.setAttribute("aria-valuenow", String(level));
|
||||||
|
element.setAttribute(
|
||||||
|
"aria-valuetext",
|
||||||
|
t("microphone_level.value", { level, max: LEVEL_SCALE }),
|
||||||
|
);
|
||||||
|
// The level is a share of the scale, not a bar count.
|
||||||
|
const lit = Math.round((level / LEVEL_SCALE) * barCount);
|
||||||
|
Array.from(element.children).forEach((bar, i) =>
|
||||||
|
bar.classList.toggle(styles.segmentLit, i < lit),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return (): void => subscription.unsubscribe();
|
||||||
|
}, [state, barCount, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={classNames(styles.meter, className)}>
|
<div ref={ref} className={classNames(styles.meter, className)}>
|
||||||
<MicOnIcon className={styles.icon} aria-hidden />
|
<MicOnIcon className={styles.icon} aria-hidden />
|
||||||
@@ -88,22 +120,15 @@ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
|
|||||||
aria-label={t("microphone_level.label")}
|
aria-label={t("microphone_level.label")}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={LEVEL_SCALE}
|
aria-valuemax={LEVEL_SCALE}
|
||||||
aria-valuenow={state.level}
|
// The first paint's value; the effect above keeps it current.
|
||||||
|
aria-valuenow={state.level.value}
|
||||||
aria-valuetext={t("microphone_level.value", {
|
aria-valuetext={t("microphone_level.value", {
|
||||||
level: state.level,
|
level: state.level.value,
|
||||||
max: LEVEL_SCALE,
|
max: LEVEL_SCALE,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{Array.from({ length: barCount }, (_, i) => (
|
{Array.from({ length: barCount }, (_, i) => (
|
||||||
<span
|
<span key={i} aria-hidden className={styles.segment} />
|
||||||
key={i}
|
|
||||||
aria-hidden
|
|
||||||
className={classNames(styles.segment, {
|
|
||||||
// The level is a share of the scale, not a bar count.
|
|
||||||
[styles.segmentLit]:
|
|
||||||
i < Math.round((state.level / LEVEL_SCALE) * barCount),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe("useMicrophoneLevel", () => {
|
|||||||
rerender({ id: "mic2" });
|
rerender({ id: "mic2" });
|
||||||
|
|
||||||
// No level carried over from the previous device.
|
// No level carried over from the previous device.
|
||||||
expect(result.current).toEqual({ type: "level", level: 0 });
|
const state = result.current;
|
||||||
|
expect(state.type === "level" ? state.level.value : state.type).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
type MicrophoneState,
|
type MicrophoneState,
|
||||||
observeMicrophoneState$,
|
observeMicrophoneState$,
|
||||||
} from "../state/MicrophoneLevel";
|
} from "../state/MicrophoneLevel";
|
||||||
|
import { constant } from "../state/Behavior";
|
||||||
|
|
||||||
const IDLE: MicrophoneState = { type: "level", level: 0 };
|
const IDLE: MicrophoneState = { type: "level", level: constant(0) };
|
||||||
|
|
||||||
/** The live level of a microphone, captured only while `active`. */
|
/** The live level of a microphone, captured only while `active`. */
|
||||||
export function useMicrophoneLevel(
|
export function useMicrophoneLevel(
|
||||||
|
|||||||
@@ -157,16 +157,20 @@ describe("observeMicrophoneState$", () => {
|
|||||||
test("says nothing on a frame that did not change the level", async () => {
|
test("says nothing on a frame that did not change the level", async () => {
|
||||||
const capture = stubAudioCapture();
|
const capture = stubAudioCapture();
|
||||||
|
|
||||||
let emissions = 0;
|
const states: string[] = [];
|
||||||
const subscription = observeMicrophoneState$("mic1").subscribe(
|
let levels = 0;
|
||||||
() => emissions++,
|
const subscription = observeMicrophoneState$("mic1").subscribe((state) => {
|
||||||
);
|
states.push(state.type);
|
||||||
|
if (state.type === "level") state.level.subscribe(() => levels++);
|
||||||
|
});
|
||||||
capture.grant();
|
capture.grant();
|
||||||
await vi.waitFor(() => expect(emissions).toBe(1));
|
await vi.waitFor(() => expect(levels).toBe(1));
|
||||||
|
|
||||||
// Read every frame, but a steady signal emits once.
|
// Read every frame, but a steady signal emits once, and the state itself
|
||||||
|
// arrives only once.
|
||||||
capture.drawFrames(20);
|
capture.drawFrames(20);
|
||||||
expect(emissions).toBe(1);
|
expect(levels).toBe(1);
|
||||||
|
expect(states).toEqual(["level"]);
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { distinctUntilChanged, Observable } from "rxjs";
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { type Behavior } from "./Behavior";
|
||||||
|
|
||||||
/** What the microphone picks up, or why it can't be read. */
|
/** What the microphone picks up, or why it can't be read. */
|
||||||
export type MicrophoneState =
|
export type MicrophoneState =
|
||||||
| { type: "level"; level: number }
|
// A Behavior, so a changing level can be drawn without re-rendering.
|
||||||
|
| { type: "level"; level: Behavior<number> }
|
||||||
| { type: "permission-denied" }
|
| { type: "permission-denied" }
|
||||||
| { type: "no-device" };
|
| { type: "no-device" };
|
||||||
|
|
||||||
@@ -29,6 +32,7 @@ export function observeMicrophoneState$(
|
|||||||
let stream: MediaStream | undefined;
|
let stream: MediaStream | undefined;
|
||||||
let context: AudioContext | undefined;
|
let context: AudioContext | undefined;
|
||||||
let frame: number | undefined;
|
let frame: number | undefined;
|
||||||
|
let level: BehaviorSubject<number> | undefined;
|
||||||
|
|
||||||
// Idempotent: teardown and start can both call it.
|
// Idempotent: teardown and start can both call it.
|
||||||
const release = (): void => {
|
const release = (): void => {
|
||||||
@@ -38,6 +42,8 @@ export function observeMicrophoneState$(
|
|||||||
frame = undefined;
|
frame = undefined;
|
||||||
stream = undefined;
|
stream = undefined;
|
||||||
context = undefined;
|
context = undefined;
|
||||||
|
level?.complete();
|
||||||
|
level = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const start = async (): Promise<void> => {
|
const start = async (): Promise<void> => {
|
||||||
@@ -59,6 +65,9 @@ export function observeMicrophoneState$(
|
|||||||
const samples = new Uint8Array(analyser.fftSize);
|
const samples = new Uint8Array(analyser.fftSize);
|
||||||
let displayed = 0;
|
let displayed = 0;
|
||||||
let previousFrame = performance.now();
|
let previousFrame = performance.now();
|
||||||
|
const current = new BehaviorSubject(0);
|
||||||
|
level = current;
|
||||||
|
subscriber.next({ type: "level", level: current });
|
||||||
|
|
||||||
const read = (): void => {
|
const read = (): void => {
|
||||||
analyser.getByteTimeDomainData(samples);
|
analyser.getByteTimeDomainData(samples);
|
||||||
@@ -75,7 +84,9 @@ export function observeMicrophoneState$(
|
|||||||
now - previousFrame,
|
now - previousFrame,
|
||||||
);
|
);
|
||||||
previousFrame = now;
|
previousFrame = now;
|
||||||
subscriber.next({ type: "level", level: segmentsForVolume(displayed) });
|
// Frames that don't move the quantised level say nothing.
|
||||||
|
const next = segmentsForVolume(displayed);
|
||||||
|
if (next !== current.value) current.next(next);
|
||||||
frame = requestAnimationFrame(read);
|
frame = requestAnimationFrame(read);
|
||||||
};
|
};
|
||||||
read();
|
read();
|
||||||
@@ -88,14 +99,7 @@ export function observeMicrophoneState$(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return release;
|
return release;
|
||||||
}).pipe(
|
});
|
||||||
// Frames that don't move the quantised level don't reach React.
|
|
||||||
distinctUntilChanged(
|
|
||||||
(a, b) =>
|
|
||||||
a.type === b.type &&
|
|
||||||
(a.type !== "level" || b.type !== "level" || a.level === b.level),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a failure to open the microphone means for the person using it. */
|
/** What a failure to open the microphone means for the person using it. */
|
||||||
|
|||||||
Reference in New Issue
Block a user