From 195065b217622f519e6a4053dbc3ffe453637719 Mon Sep 17 00:00:00 2001 From: Michael Weimann <michaelw@matrix.org> Date: Fri, 14 Oct 2022 20:12:26 +0200 Subject: [PATCH] Voice Broadcast recording pip (#9385) --- res/css/_components.pcss | 1 + .../_VoiceBroadcastRecordingPip.pcss | 35 +++++++++ res/img/element-icons/Stop.svg | 3 + src/SdkConfig.ts | 2 +- src/components/atoms/Icon.tsx | 3 + src/components/views/voip/PipView.tsx | 44 +++++++++++- src/i18n/strings/en_EN.json | 1 + .../components/atoms/StopButton.tsx | 40 +++++++++++ .../molecules/VoiceBroadcastRecordingPip.tsx | 51 +++++++++++++ src/voice-broadcast/index.ts | 4 +- .../components/atoms/StopButton-test.tsx | 45 ++++++++++++ .../__snapshots__/StopButton-test.tsx.snap | 19 +++++ .../VoiceBroadcastRecordingPip-test.tsx | 67 +++++++++++++++++ .../VoiceBroadcastRecordingPip-test.tsx.snap | 71 +++++++++++++++++++ 14 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss create mode 100644 res/img/element-icons/Stop.svg create mode 100644 src/voice-broadcast/components/atoms/StopButton.tsx create mode 100644 src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx create mode 100644 test/voice-broadcast/components/atoms/StopButton-test.tsx create mode 100644 test/voice-broadcast/components/atoms/__snapshots__/StopButton-test.tsx.snap create mode 100644 test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx create mode 100644 test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 1d6452aa82..f916d5925d 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -370,3 +370,4 @@ @import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss"; @import "./voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss"; @import "./voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss"; +@import "./voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss"; diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss new file mode 100644 index 0000000000..b01b1b80db --- /dev/null +++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss @@ -0,0 +1,35 @@ +/* +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. +*/ + +.mx_VoiceBroadcastRecordingPip { + background-color: $system; + border-radius: 8px; + box-shadow: 0 2px 8px 0 #0000004a; + display: inline-block; + padding: $spacing-12; +} + +.mx_VoiceBroadcastRecordingPip_divider { + background-color: $quinary-content; + border: 0; + height: 1px; + margin: $spacing-12 0; +} + +.mx_VoiceBroadcastRecordingPip_controls { + display: flex; + justify-content: center; +} diff --git a/res/img/element-icons/Stop.svg b/res/img/element-icons/Stop.svg new file mode 100644 index 0000000000..29c7a0cef7 --- /dev/null +++ b/res/img/element-icons/Stop.svg @@ -0,0 +1,3 @@ +<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="0.973633" y="2" width="12" height="12" rx="1" fill="#737D8C"/> +</svg> diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 0d3400f4bb..6698f3ffb2 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -47,7 +47,7 @@ export const DEFAULTS: IConfigOptions = { url: "https://element.io/get-started", }, voice_broadcast: { - chunk_length: 60 * 1000, // one minute + chunk_length: 60, // one minute }, }; diff --git a/src/components/atoms/Icon.tsx b/src/components/atoms/Icon.tsx index 2241a711ca..56d8236250 100644 --- a/src/components/atoms/Icon.tsx +++ b/src/components/atoms/Icon.tsx @@ -20,12 +20,14 @@ import liveIcon from "../../../res/img/element-icons/live.svg"; import microphoneIcon from "../../../res/img/voip/call-view/mic-on.svg"; import pauseIcon from "../../../res/img/element-icons/pause.svg"; import playIcon from "../../../res/img/element-icons/play.svg"; +import stopIcon from "../../../res/img/element-icons/Stop.svg"; export enum IconType { Live, Microphone, Pause, Play, + Stop, } const iconTypeMap = new Map([ @@ -33,6 +35,7 @@ const iconTypeMap = new Map([ [IconType.Microphone, microphoneIcon], [IconType.Pause, pauseIcon], [IconType.Play, playIcon], + [IconType.Stop, stopIcon], ]); export enum IconColour { diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index 0c6feec0d5..0bebfe1bf3 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; +import React, { createRef, useState } from 'react'; import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { logger } from "matrix-js-sdk/src/logger"; import classNames from 'classnames'; @@ -35,6 +35,13 @@ import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; import { CallStore } from "../../../stores/CallStore"; +import { + VoiceBroadcastRecording, + VoiceBroadcastRecordingPip, + VoiceBroadcastRecordingsStore, + VoiceBroadcastRecordingsStoreEvent, +} from '../../../voice-broadcast'; +import { useTypedEventEmitter } from '../../../hooks/useEventEmitter'; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -46,6 +53,7 @@ const SHOW_CALL_IN_STATES = [ ]; interface IProps { + voiceBroadcastRecording?: VoiceBroadcastRecording; } interface IState { @@ -115,7 +123,7 @@ function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall * and all widgets that are active but not shown in any other possible container. */ -export default class PipView extends React.Component<IProps, IState> { +class PipView extends React.Component<IProps, IState> { private movePersistedElement = createRef<() => void>(); constructor(props: IProps) { @@ -353,6 +361,14 @@ export default class PipView extends React.Component<IProps, IState> { </div>; } + if (this.props.voiceBroadcastRecording) { + pipContent = ({ onStartMoving }) => <div onMouseDown={onStartMoving}> + <VoiceBroadcastRecordingPip + recording={this.props.voiceBroadcastRecording} + /> + </div>; + } + if (!!pipContent) { return <PictureInPictureDragger className="mx_LegacyCallPreview" @@ -367,3 +383,27 @@ export default class PipView extends React.Component<IProps, IState> { return null; } } + +const PipViewHOC: React.FC<IProps> = (props) => { + // TODO Michael W: extract to custom hook + + const voiceBroadcastRecordingsStore = VoiceBroadcastRecordingsStore.instance(); + const [voiceBroadcastRecording, setVoiceBroadcastRecording] = useState( + voiceBroadcastRecordingsStore.getCurrent(), + ); + + useTypedEventEmitter( + voiceBroadcastRecordingsStore, + VoiceBroadcastRecordingsStoreEvent.CurrentChanged, + (recording: VoiceBroadcastRecording) => { + setVoiceBroadcastRecording(recording); + }, + ); + + return <PipView + voiceBroadcastRecording={voiceBroadcastRecording} + {...props} + />; +}; + +export default PipViewHOC; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1b05ce98f8..0af4100c05 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -640,6 +640,7 @@ "Live": "Live", "pause voice broadcast": "pause voice broadcast", "resume voice broadcast": "resume voice broadcast", + "stop voice broadcast": "stop voice broadcast", "Voice broadcast": "Voice broadcast", "Cannot reach homeserver": "Cannot reach homeserver", "Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin", diff --git a/src/voice-broadcast/components/atoms/StopButton.tsx b/src/voice-broadcast/components/atoms/StopButton.tsx new file mode 100644 index 0000000000..50abb209d0 --- /dev/null +++ b/src/voice-broadcast/components/atoms/StopButton.tsx @@ -0,0 +1,40 @@ +/* +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 { Icon, IconColour, IconType } from "../../../components/atoms/Icon"; +import AccessibleButton from "../../../components/views/elements/AccessibleButton"; +import { _t } from "../../../languageHandler"; + +interface Props { + onClick: () => void; +} + +export const StopButton: React.FC<Props> = ({ + onClick, +}) => { + return <AccessibleButton + className="mx_BroadcastPlaybackControlButton" + onClick={onClick} + aria-label={_t("stop voice broadcast")} + > + <Icon + colour={IconColour.CompoundSecondaryContent} + type={IconType.Stop} + /> + </AccessibleButton>; +}; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx new file mode 100644 index 0000000000..c7604b7d90 --- /dev/null +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx @@ -0,0 +1,51 @@ +/* +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 { + StopButton, + VoiceBroadcastRecording, +} from "../.."; +import { useVoiceBroadcastRecording } from "../../hooks/useVoiceBroadcastRecording"; +import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader"; + +interface VoiceBroadcastRecordingPipProps { + recording: VoiceBroadcastRecording; +} + +export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProps> = ({ recording }) => { + const { + live, + sender, + room, + stopRecording, + } = useVoiceBroadcastRecording(recording); + + return <div + className="mx_VoiceBroadcastRecordingPip" + > + <VoiceBroadcastHeader + live={live} + sender={sender} + room={room} + /> + <hr className="mx_VoiceBroadcastRecordingPip_divider" /> + <div className="mx_VoiceBroadcastRecordingPip_controls"> + <StopButton onClick={stopRecording} /> + </div> + </div>; +}; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index 2a3bb6573f..7262382b0c 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -27,15 +27,17 @@ export * from "./audio/VoiceBroadcastRecorder"; export * from "./components/VoiceBroadcastBody"; export * from "./components/atoms/LiveBadge"; export * from "./components/atoms/PlaybackControlButton"; +export * from "./components/atoms/StopButton"; export * from "./components/atoms/VoiceBroadcastHeader"; export * from "./components/molecules/VoiceBroadcastPlaybackBody"; export * from "./components/molecules/VoiceBroadcastRecordingBody"; +export * from "./components/molecules/VoiceBroadcastRecordingPip"; +export * from "./hooks/useVoiceBroadcastRecording"; export * from "./stores/VoiceBroadcastPlaybacksStore"; export * from "./stores/VoiceBroadcastRecordingsStore"; export * from "./utils/shouldDisplayAsVoiceBroadcastRecordingTile"; export * from "./utils/shouldDisplayAsVoiceBroadcastTile"; export * from "./utils/startNewVoiceBroadcastRecording"; -export * from "./hooks/useVoiceBroadcastRecording"; export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info"; export const VoiceBroadcastChunkEventType = "io.element.voice_broadcast_chunk"; diff --git a/test/voice-broadcast/components/atoms/StopButton-test.tsx b/test/voice-broadcast/components/atoms/StopButton-test.tsx new file mode 100644 index 0000000000..742844fca1 --- /dev/null +++ b/test/voice-broadcast/components/atoms/StopButton-test.tsx @@ -0,0 +1,45 @@ +/* +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 { render, RenderResult } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { StopButton } from "../../../../src/voice-broadcast"; + +describe("StopButton", () => { + let result: RenderResult; + let onClick: () => {}; + + beforeEach(() => { + onClick = jest.fn(); + result = render(<StopButton onClick={onClick} />); + }); + + it("should render as expected", () => { + expect(result.container).toMatchSnapshot(); + }); + + describe("when clicking it", () => { + beforeEach(async () => { + await userEvent.click(result.getByLabelText("stop voice broadcast")); + }); + + it("should invoke the callback", () => { + expect(onClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/voice-broadcast/components/atoms/__snapshots__/StopButton-test.tsx.snap b/test/voice-broadcast/components/atoms/__snapshots__/StopButton-test.tsx.snap new file mode 100644 index 0000000000..ca5015a79a --- /dev/null +++ b/test/voice-broadcast/components/atoms/__snapshots__/StopButton-test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StopButton should render as expected 1`] = ` +<div> + <div + aria-label="stop voice broadcast" + class="mx_AccessibleButton mx_BroadcastPlaybackControlButton" + role="button" + tabindex="0" + > + <i + aria-hidden="true" + class="mx_Icon mx_Icon_16 mx_Icon_compound-secondary-content" + role="presentation" + style="mask-image: url(\\"image-file-stub\\");" + /> + </div> +</div> +`; diff --git a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx new file mode 100644 index 0000000000..a25c1b605d --- /dev/null +++ b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx @@ -0,0 +1,67 @@ +/* +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 { render, RenderResult } from "@testing-library/react"; +import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { + VoiceBroadcastInfoEventType, + VoiceBroadcastRecording, + VoiceBroadcastRecordingPip, +} from "../../../../src/voice-broadcast"; +import { mkEvent, stubClient } from "../../../test-utils"; + +// 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 <div data-testid="room-avatar">room avatar: { room.name }</div>; + }), +})); + +describe("VoiceBroadcastRecordingPip", () => { + const userId = "@user:example.com"; + const roomId = "!room:example.com"; + let client: MatrixClient; + let infoEvent: MatrixEvent; + let recording: VoiceBroadcastRecording; + + beforeAll(() => { + client = stubClient(); + infoEvent = mkEvent({ + event: true, + type: VoiceBroadcastInfoEventType, + content: {}, + room: roomId, + user: userId, + }); + recording = new VoiceBroadcastRecording(infoEvent, client); + }); + + describe("when rendering", () => { + let renderResult: RenderResult; + + beforeEach(() => { + renderResult = render(<VoiceBroadcastRecordingPip recording={recording} />); + }); + + it("should create the expected result", () => { + expect(renderResult.container).toMatchSnapshot(); + }); + }); +}); diff --git a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap new file mode 100644 index 0000000000..7eec506e21 --- /dev/null +++ b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VoiceBroadcastRecordingPip when rendering should create the expected result 1`] = ` +<div> + <div + class="mx_VoiceBroadcastRecordingPip" + > + <div + class="mx_VoiceBroadcastHeader" + > + <div + data-testid="room-avatar" + > + room avatar: + My room + </div> + <div + class="mx_VoiceBroadcastHeader_content" + > + <div + class="mx_VoiceBroadcastHeader_room" + > + My room + </div> + <div + class="mx_VoiceBroadcastHeader_line" + > + <i + aria-hidden="true" + class="mx_Icon mx_Icon_16 mx_Icon_compound-secondary-content" + role="presentation" + style="mask-image: url(\\"image-file-stub\\");" + /> + @user:example.com + </div> + </div> + <div + class="mx_LiveBadge" + > + <i + aria-hidden="true" + class="mx_Icon mx_Icon_16 mx_Icon_live-badge" + role="presentation" + style="mask-image: url(\\"image-file-stub\\");" + /> + Live + </div> + </div> + <hr + class="mx_VoiceBroadcastRecordingPip_divider" + /> + <div + class="mx_VoiceBroadcastRecordingPip_controls" + > + <div + aria-label="stop voice broadcast" + class="mx_AccessibleButton mx_BroadcastPlaybackControlButton" + role="button" + tabindex="0" + > + <i + aria-hidden="true" + class="mx_Icon mx_Icon_16 mx_Icon_compound-secondary-content" + role="presentation" + style="mask-image: url(\\"image-file-stub\\");" + /> + </div> + </div> + </div> +</div> +`;