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>
+`;