From b3022752894a9720a338c115431b37ace6b15a4c Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 28 Nov 2022 15:16:44 +0100 Subject: [PATCH] Add input device selection during voice broadcast (#9620) --- res/css/compound/_Icon.pcss | 4 + res/img/element-icons/Mic.svg | 1 + .../audio_messages/DevicesContextMenu.tsx | 56 +++++++ src/hooks/useAudioDeviceSelection.ts | 76 ++++++++++ src/i18n/strings/en_EN.json | 1 + .../VoiceBroadcastPreRecordingPip.tsx | 94 +++--------- .../molecules/VoiceBroadcastRecordingPip.tsx | 45 +++++- .../hooks/useVoiceBroadcastRecording.tsx | 8 +- .../VoiceBroadcastPreRecordingPip-test.tsx | 138 ++++++++++++++++++ .../VoiceBroadcastRecordingPip-test.tsx | 72 +++++++-- ...oiceBroadcastPreRecordingPip-test.tsx.snap | 58 ++++++++ .../VoiceBroadcastRecordingPip-test.tsx.snap | 20 +++ 12 files changed, 486 insertions(+), 87 deletions(-) create mode 100644 res/img/element-icons/Mic.svg create mode 100644 src/components/views/audio_messages/DevicesContextMenu.tsx create mode 100644 src/hooks/useAudioDeviceSelection.ts create mode 100644 test/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip-test.tsx create mode 100644 test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPreRecordingPip-test.tsx.snap diff --git a/res/css/compound/_Icon.pcss b/res/css/compound/_Icon.pcss index 39af843f27..4a1d832675 100644 --- a/res/css/compound/_Icon.pcss +++ b/res/css/compound/_Icon.pcss @@ -29,6 +29,10 @@ limitations under the License. color: $accent; } +.mx_Icon_alert { + color: $alert; +} + .mx_Icon_8 { flex: 0 0 8px; height: 8px; diff --git a/res/img/element-icons/Mic.svg b/res/img/element-icons/Mic.svg new file mode 100644 index 0000000000..00f0564edc --- /dev/null +++ b/res/img/element-icons/Mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/views/audio_messages/DevicesContextMenu.tsx b/src/components/views/audio_messages/DevicesContextMenu.tsx new file mode 100644 index 0000000000..5b72917eb3 --- /dev/null +++ b/src/components/views/audio_messages/DevicesContextMenu.tsx @@ -0,0 +1,56 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { MutableRefObject } from "react"; + +import { toLeftOrRightOf } from "../../structures/ContextMenu"; +import IconizedContextMenu, { + IconizedContextMenuOptionList, + IconizedContextMenuRadio, +} from "../context_menus/IconizedContextMenu"; + +interface Props { + containerRef: MutableRefObject; + currentDevice: MediaDeviceInfo | null; + devices: MediaDeviceInfo[]; + onDeviceSelect: (device: MediaDeviceInfo) => void; +} + +export const DevicesContextMenu: React.FC = ({ + containerRef, + currentDevice, + devices, + onDeviceSelect, +}) => { + const deviceOptions = devices.map((d: MediaDeviceInfo) => { + return onDeviceSelect(d)} + label={d.label} + />; + }); + + return {}} + {...toLeftOrRightOf(containerRef.current.getBoundingClientRect(), 0)} + > + + { deviceOptions } + + ; +}; diff --git a/src/hooks/useAudioDeviceSelection.ts b/src/hooks/useAudioDeviceSelection.ts new file mode 100644 index 0000000000..65e3d5a8e5 --- /dev/null +++ b/src/hooks/useAudioDeviceSelection.ts @@ -0,0 +1,76 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useRef, useState } from "react"; + +import { _t } from "../languageHandler"; +import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler"; +import { requestMediaPermissions } from "../utils/media/requestMediaPermissions"; + +interface State { + devices: MediaDeviceInfo[]; + device: MediaDeviceInfo | null; +} + +export const useAudioDeviceSelection = ( + onDeviceChanged?: (device: MediaDeviceInfo) => void, +) => { + const shouldRequestPermissionsRef = useRef(true); + const [state, setState] = useState({ + devices: [], + device: null, + }); + + if (shouldRequestPermissionsRef.current) { + shouldRequestPermissionsRef.current = false; + requestMediaPermissions(false).then((stream: MediaStream | undefined) => { + MediaDeviceHandler.getDevices().then(({ audioinput }) => { + MediaDeviceHandler.getDefaultDevice(audioinput); + const deviceFromSettings = MediaDeviceHandler.getAudioInput(); + const device = audioinput.find((d) => { + return d.deviceId === deviceFromSettings; + }) || audioinput[0]; + setState({ + ...state, + devices: audioinput, + device, + }); + stream?.getTracks().forEach(t => t.stop()); + }); + }); + } + + const setDevice = (device: MediaDeviceInfo) => { + const shouldNotify = device.deviceId !== state.device?.deviceId; + MediaDeviceHandler.instance.setDevice(device.deviceId, MediaDeviceKindEnum.AudioInput); + + setState({ + ...state, + device, + }); + + if (shouldNotify) { + onDeviceChanged?.(device); + } + }; + + return { + currentDevice: state.device, + currentDeviceLabel: state.device?.label || _t("Default Device"), + devices: state.devices, + setDevice, + }; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index af1cab69dc..af85045bf9 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -654,6 +654,7 @@ "30s backward": "30s backward", "30s forward": "30s forward", "Go live": "Go live", + "Change input device": "Change input device", "Live": "Live", "Voice broadcast": "Voice broadcast", "Cannot reach homeserver": "Cannot reach homeserver", diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx index e3a3b5f424..3353093282 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx @@ -21,99 +21,34 @@ import AccessibleButton from "../../../components/views/elements/AccessibleButto import { VoiceBroadcastPreRecording } from "../../models/VoiceBroadcastPreRecording"; import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; import { _t } from "../../../languageHandler"; -import IconizedContextMenu, { - IconizedContextMenuOptionList, - IconizedContextMenuRadio, -} from "../../../components/views/context_menus/IconizedContextMenu"; -import { requestMediaPermissions } from "../../../utils/media/requestMediaPermissions"; -import MediaDeviceHandler from "../../../MediaDeviceHandler"; -import { toLeftOrRightOf } from "../../../components/structures/ContextMenu"; +import { useAudioDeviceSelection } from "../../../hooks/useAudioDeviceSelection"; +import { DevicesContextMenu } from "../../../components/views/audio_messages/DevicesContextMenu"; interface Props { voiceBroadcastPreRecording: VoiceBroadcastPreRecording; } -interface State { - devices: MediaDeviceInfo[]; - device: MediaDeviceInfo | null; - showDeviceSelect: boolean; -} - export const VoiceBroadcastPreRecordingPip: React.FC = ({ voiceBroadcastPreRecording, }) => { - const shouldRequestPermissionsRef = useRef(true); - const pipRef = useRef(null); - const [state, setState] = useState({ - devices: [], - device: null, - showDeviceSelect: false, - }); + const pipRef = useRef(null); + const { currentDevice, currentDeviceLabel, devices, setDevice } = useAudioDeviceSelection(); + const [showDeviceSelect, setShowDeviceSelect] = useState(false); - if (shouldRequestPermissionsRef.current) { - shouldRequestPermissionsRef.current = false; - requestMediaPermissions(false).then((stream: MediaStream | undefined) => { - MediaDeviceHandler.getDevices().then(({ audioinput }) => { - MediaDeviceHandler.getDefaultDevice(audioinput); - const deviceFromSettings = MediaDeviceHandler.getAudioInput(); - const device = audioinput.find((d) => { - return d.deviceId === deviceFromSettings; - }) || audioinput[0]; - setState({ - ...state, - devices: audioinput, - device, - }); - stream?.getTracks().forEach(t => t.stop()); - }); - }); - } - - const onDeviceOptionClick = (device: MediaDeviceInfo) => { - setState({ - ...state, - device, - showDeviceSelect: false, - }); + const onDeviceSelect = (device: MediaDeviceInfo | null) => { + setShowDeviceSelect(false); + setDevice(device); }; - const onMicrophoneLineClick = () => { - setState({ - ...state, - showDeviceSelect: true, - }); - }; - - const deviceOptions = state.devices.map((d: MediaDeviceInfo) => { - return onDeviceOptionClick(d)} - label={d.label} - />; - }); - - const devicesMenu = state.showDeviceSelect && pipRef.current - ? {}} - {...toLeftOrRightOf(pipRef.current.getBoundingClientRect(), 0)} - > - - { deviceOptions } - - - : null; - return
setShowDeviceSelect(true)} room={voiceBroadcastPreRecording.room} - microphoneLabel={state.device?.label || _t('Default Device')} + microphoneLabel={currentDeviceLabel} showClose={true} /> = ({ { _t("Go live") } - { devicesMenu } + { + showDeviceSelect && + }
; }; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx index 9d7c68ec97..06ebebb39f 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { useRef, useState } from "react"; import { VoiceBroadcastControl, @@ -26,13 +26,18 @@ import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader"; import { Icon as StopIcon } from "../../../../res/img/element-icons/Stop.svg"; import { Icon as PauseIcon } from "../../../../res/img/element-icons/pause.svg"; import { Icon as RecordIcon } from "../../../../res/img/element-icons/Record.svg"; +import { Icon as MicrophoneIcon } from "../../../../res/img/element-icons/Mic.svg"; import { _t } from "../../../languageHandler"; +import AccessibleButton from "../../../components/views/elements/AccessibleButton"; +import { useAudioDeviceSelection } from "../../../hooks/useAudioDeviceSelection"; +import { DevicesContextMenu } from "../../../components/views/audio_messages/DevicesContextMenu"; interface VoiceBroadcastRecordingPipProps { recording: VoiceBroadcastRecording; } export const VoiceBroadcastRecordingPip: React.FC = ({ recording }) => { + const pipRef = useRef(null); const { live, timeLeft, @@ -41,6 +46,29 @@ export const VoiceBroadcastRecordingPip: React.FC { + setShowDeviceSelect(false); + + if (currentDevice.deviceId === device.deviceId) { + // device unchanged + return; + } + + setDevice(device); + + if ([VoiceBroadcastInfoState.Paused, VoiceBroadcastInfoState.Stopped].includes(recordingState)) { + // Nothing to do in these cases. Resume will use the selected device. + return; + } + + // pause and resume to switch the input device + await recording.pause(); + await recording.resume(); + }; + + const [showDeviceSelect, setShowDeviceSelect] = useState(false); const toggleControl = recordingState === VoiceBroadcastInfoState.Paused ?
{ toggleControl } + setShowDeviceSelect(true)} + > + +
+ { + showDeviceSelect && + } ; }; diff --git a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx index d4bf1fdbd9..d718e274f2 100644 --- a/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx +++ b/src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx @@ -47,7 +47,13 @@ const showStopBroadcastingDialog = async (): Promise => { export const useVoiceBroadcastRecording = (recording: VoiceBroadcastRecording) => { const client = MatrixClientPeg.get(); - const room = client.getRoom(recording.infoEvent.getRoomId()); + const roomId = recording.infoEvent.getRoomId(); + const room = client.getRoom(roomId); + + if (!room) { + throw new Error("Unable to find voice broadcast room with Id: " + roomId); + } + const stopRecording = async () => { const confirmed = await showStopBroadcastingDialog(); diff --git a/test/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip-test.tsx b/test/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip-test.tsx new file mode 100644 index 0000000000..91658f26ed --- /dev/null +++ b/test/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip-test.tsx @@ -0,0 +1,138 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { mocked } from "jest-mock"; +import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix"; +import { act, render, RenderResult, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { + VoiceBroadcastPreRecording, + VoiceBroadcastPreRecordingPip, + VoiceBroadcastRecordingsStore, +} from "../../../../src/voice-broadcast"; +import { flushPromises, stubClient } from "../../../test-utils"; +import { requestMediaPermissions } from "../../../../src/utils/media/requestMediaPermissions"; +import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler"; + +jest.mock("../../../../src/utils/media/requestMediaPermissions"); + +// mock RoomAvatar, because it is doing too much fancy stuff +jest.mock("../../../../src/components/views/avatars/RoomAvatar", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(({ room }) => { + return
room avatar: { room.name }
; + }), +})); + +describe("VoiceBroadcastPreRecordingPip", () => { + let renderResult: RenderResult; + let preRecording: VoiceBroadcastPreRecording; + let recordingsStore: VoiceBroadcastRecordingsStore; + let client: MatrixClient; + let room: Room; + let sender: RoomMember; + + beforeEach(() => { + client = stubClient(); + room = new Room("!room@example.com", client, client.getUserId() || ""); + sender = new RoomMember(room.roomId, client.getUserId() || ""); + recordingsStore = new VoiceBroadcastRecordingsStore(); + mocked(requestMediaPermissions).mockReturnValue(new Promise((r) => { + r({ + getTracks: () => [], + } as unknown as MediaStream); + })); + jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ + [MediaDeviceKindEnum.AudioInput]: [ + { + deviceId: "d1", + label: "Device 1", + } as MediaDeviceInfo, + { + deviceId: "d2", + label: "Device 2", + } as MediaDeviceInfo, + ], + [MediaDeviceKindEnum.AudioOutput]: [], + [MediaDeviceKindEnum.VideoInput]: [], + }); + jest.spyOn(MediaDeviceHandler.instance, "setDevice").mockImplementation(); + preRecording = new VoiceBroadcastPreRecording( + room, + sender, + client, + recordingsStore, + ); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + describe("when rendered", () => { + beforeEach(async () => { + renderResult = render(); + + await act(async () => { + flushPromises(); + }); + }); + + it("should match the snapshot", () => { + expect(renderResult.container).toMatchSnapshot(); + }); + + describe("and clicking the device label", () => { + beforeEach(async () => { + await act(async () => { + await userEvent.click(screen.getByText("Default Device")); + }); + }); + + it("should display the device selection", () => { + expect(screen.queryAllByText("Default Device").length).toBe(2); + expect(screen.queryByText("Device 1")).toBeInTheDocument(); + expect(screen.queryByText("Device 2")).toBeInTheDocument(); + }); + + describe("and selecting a device", () => { + beforeEach(async () => { + await act(async () => { + await userEvent.click(screen.getByText("Device 1")); + }); + }); + + it("should set it as current device", () => { + expect(MediaDeviceHandler.instance.setDevice).toHaveBeenCalledWith( + "d1", + MediaDeviceKindEnum.AudioInput, + ); + }); + + it("should not show the device selection", () => { + expect(screen.queryByText("Default Device")).not.toBeInTheDocument(); + // expected to be one in the document, displayed in the pip directly + expect(screen.queryByText("Device 1")).toBeInTheDocument(); + expect(screen.queryByText("Device 2")).not.toBeInTheDocument(); + }); + }); + }); + }); +}); diff --git a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx index 6a0c1016ff..5aac28fbeb 100644 --- a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx +++ b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx @@ -16,18 +16,23 @@ limitations under the License. // import React from "react"; -import { render, RenderResult, screen } from "@testing-library/react"; +import { act, render, RenderResult, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { sleep } from "matrix-js-sdk/src/utils"; +import { mocked } from "jest-mock"; import { VoiceBroadcastInfoState, VoiceBroadcastRecording, VoiceBroadcastRecordingPip, } from "../../../../src/voice-broadcast"; -import { stubClient } from "../../../test-utils"; +import { filterConsole, flushPromises, stubClient } from "../../../test-utils"; import { mkVoiceBroadcastInfoStateEvent } from "../../utils/test-utils"; +import { requestMediaPermissions } from "../../../../src/utils/media/requestMediaPermissions"; +import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler"; + +jest.mock("../../../../src/utils/media/requestMediaPermissions"); // mock RoomAvatar, because it is doing too much fancy stuff jest.mock("../../../../src/components/views/avatars/RoomAvatar", () => ({ @@ -54,31 +59,80 @@ describe("VoiceBroadcastRecordingPip", () => { let infoEvent: MatrixEvent; let recording: VoiceBroadcastRecording; let renderResult: RenderResult; + let restoreConsole: () => void; - const renderPip = (state: VoiceBroadcastInfoState) => { + const renderPip = async (state: VoiceBroadcastInfoState) => { infoEvent = mkVoiceBroadcastInfoStateEvent( roomId, state, - client.getUserId(), - client.getDeviceId(), + client.getUserId() || "", + client.getDeviceId() || "", ); recording = new VoiceBroadcastRecording(infoEvent, client, state); + jest.spyOn(recording, "pause"); + jest.spyOn(recording, "resume"); renderResult = render(); + await act(async () => { + flushPromises(); + }); }; beforeAll(() => { client = stubClient(); + mocked(requestMediaPermissions).mockReturnValue(new Promise((r) => { + r({ + getTracks: () => [], + } as unknown as MediaStream); + })); + jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ + [MediaDeviceKindEnum.AudioInput]: [ + { + deviceId: "d1", + label: "Device 1", + } as MediaDeviceInfo, + { + deviceId: "d2", + label: "Device 2", + } as MediaDeviceInfo, + ], + [MediaDeviceKindEnum.AudioOutput]: [], + [MediaDeviceKindEnum.VideoInput]: [], + }); + jest.spyOn(MediaDeviceHandler.instance, "setDevice").mockImplementation(); + restoreConsole = filterConsole("Starting load of AsyncWrapper for modal"); + }); + + afterAll(() => { + restoreConsole(); }); describe("when rendering a started recording", () => { - beforeEach(() => { - renderPip(VoiceBroadcastInfoState.Started); + beforeEach(async () => { + await renderPip(VoiceBroadcastInfoState.Started); }); it("should render as expected", () => { expect(renderResult.container).toMatchSnapshot(); }); + describe("and selecting another input device", () => { + beforeEach(async () => { + await act(async () => { + await userEvent.click(screen.getByLabelText("Change input device")); + await userEvent.click(screen.getByText("Device 1")); + }); + }); + + it("should select the device and pause and resume the broadcast", () => { + expect(MediaDeviceHandler.instance.setDevice).toHaveBeenCalledWith( + "d1", + MediaDeviceKindEnum.AudioInput, + ); + expect(recording.pause).toHaveBeenCalled(); + expect(recording.resume).toHaveBeenCalled(); + }); + }); + describe("and clicking the pause button", () => { beforeEach(async () => { await userEvent.click(screen.getByLabelText("pause voice broadcast")); @@ -113,8 +167,8 @@ describe("VoiceBroadcastRecordingPip", () => { }); describe("when rendering a paused recording", () => { - beforeEach(() => { - renderPip(VoiceBroadcastInfoState.Paused); + beforeEach(async () => { + await renderPip(VoiceBroadcastInfoState.Paused); }); it("should render as expected", () => { diff --git a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPreRecordingPip-test.tsx.snap b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPreRecordingPip-test.tsx.snap new file mode 100644 index 0000000000..758d2c6371 --- /dev/null +++ b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPreRecordingPip-test.tsx.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VoiceBroadcastPreRecordingPip when rendered should match the snapshot 1`] = ` +
+
+
+
+ room avatar: + !room@example.com +
+
+
+ !room@example.com +
+
+
+ + Default Device + +
+
+
+
+
+
+
+
+ Go live +
+
+
+`; diff --git a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap index 00166f5bcc..72c345fb54 100644 --- a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap +++ b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap @@ -60,6 +60,16 @@ exports[`VoiceBroadcastRecordingPip when rendering a paused recording should ren class="mx_Icon mx_Icon_16" />
+
+
+
+
+
+