From abec724387486444c0705773a4efa4e11c029153 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Thu, 10 Nov 2022 09:38:48 +0100 Subject: [PATCH] Add voice broadcast pre-recoding PiP (#9548) --- .../molecules/_VoiceBroadcastBody.pcss | 5 + .../views/rooms/MessageComposer.tsx | 10 +- .../views/voip/PictureInPictureDragger.tsx | 2 + src/components/views/voip/PipView.tsx | 99 ++++++++----- src/contexts/SDKContext.ts | 17 +++ src/i18n/strings/en_EN.json | 1 + .../components/atoms/VoiceBroadcastHeader.tsx | 23 ++- .../VoiceBroadcastPreRecordingPip.tsx | 48 ++++++ .../useCurrentVoiceBroadcastPreRecording.ts | 38 +++++ .../useCurrentVoiceBroadcastRecording.ts | 38 +++++ src/voice-broadcast/index.ts | 6 + .../models/VoiceBroadcastPreRecording.ts | 58 ++++++++ .../stores/VoiceBroadcastPreRecordingStore.ts | 70 +++++++++ .../checkVoiceBroadcastPreConditions.tsx | 84 +++++++++++ .../utils/setUpVoiceBroadcastPreRecording.ts | 45 ++++++ ...tsx => startNewVoiceBroadcastRecording.ts} | 74 ++-------- test/TestSdkContext.ts | 3 + test/components/views/voip/PipView-test.tsx | 73 +++++++++- test/contexts/SdkContext-test.ts | 34 +++++ test/stores/OwnBeaconStore-test.ts | 1 + test/test-utils/test-utils.ts | 28 ++++ test/test-utils/wrappers.tsx | 14 ++ test/utils/MultiInviter-test.ts | 1 + .../models/VoiceBroadcastPreRecording-test.ts | 77 ++++++++++ .../VoiceBroadcastPreRecordingStore-test.ts | 137 ++++++++++++++++++ .../setUpVoiceBroadcastPreRecording-test.ts | 102 +++++++++++++ 26 files changed, 977 insertions(+), 111 deletions(-) create mode 100644 src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx create mode 100644 src/voice-broadcast/hooks/useCurrentVoiceBroadcastPreRecording.ts create mode 100644 src/voice-broadcast/hooks/useCurrentVoiceBroadcastRecording.ts create mode 100644 src/voice-broadcast/models/VoiceBroadcastPreRecording.ts create mode 100644 src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore.ts create mode 100644 src/voice-broadcast/utils/checkVoiceBroadcastPreConditions.tsx create mode 100644 src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording.ts rename src/voice-broadcast/utils/{startNewVoiceBroadcastRecording.tsx => startNewVoiceBroadcastRecording.ts} (55%) create mode 100644 test/contexts/SdkContext-test.ts create mode 100644 test/voice-broadcast/models/VoiceBroadcastPreRecording-test.ts create mode 100644 test/voice-broadcast/stores/VoiceBroadcastPreRecordingStore-test.ts create mode 100644 test/voice-broadcast/utils/setUpVoiceBroadcastPreRecording-test.ts diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss index ad7f879b5c..0a16dc96f4 100644 --- a/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss +++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss @@ -45,3 +45,8 @@ limitations under the License. display: flex; gap: $spacing-4; } + +.mx_AccessibleButton.mx_VoiceBroadcastBody_blockButton { + display: flex; + gap: $spacing-8; +} diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 7594b897e1..7ff403455d 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -54,13 +54,12 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { Features } from '../../../settings/Settings'; import { VoiceMessageRecording } from '../../../audio/VoiceMessageRecording'; -import { - startNewVoiceBroadcastRecording, - VoiceBroadcastRecordingsStore, -} from '../../../voice-broadcast'; +import { VoiceBroadcastRecordingsStore } from '../../../voice-broadcast'; import { SendWysiwygComposer, sendMessage } from './wysiwyg_composer/'; import { MatrixClientProps, withMatrixClientHOC } from '../../../contexts/MatrixClientContext'; import { htmlToPlainText } from '../../../utils/room/htmlToPlaintext'; +import { setUpVoiceBroadcastPreRecording } from '../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording'; +import { SdkContextClass } from '../../../contexts/SDKContext'; let instanceCount = 0; @@ -581,10 +580,11 @@ export class MessageComposer extends React.Component { toggleButtonMenu={this.toggleButtonMenu} showVoiceBroadcastButton={this.state.showVoiceBroadcastButton} onStartVoiceBroadcastClick={() => { - startNewVoiceBroadcastRecording( + setUpVoiceBroadcastPreRecording( this.props.room, MatrixClientPeg.get(), VoiceBroadcastRecordingsStore.instance(), + SdkContextClass.instance.voiceBroadcastPreRecordingStore, ); this.toggleButtonMenu(); }} diff --git a/src/components/views/voip/PictureInPictureDragger.tsx b/src/components/views/voip/PictureInPictureDragger.tsx index 90653113b7..dd02a13e90 100644 --- a/src/components/views/voip/PictureInPictureDragger.tsx +++ b/src/components/views/voip/PictureInPictureDragger.tsx @@ -68,6 +68,8 @@ export default class PictureInPictureDragger extends React.Component { document.addEventListener("mousemove", this.onMoving); document.addEventListener("mouseup", this.onEndMoving); UIStore.instance.on(UI_EVENTS.Resize, this.onResize); + // correctly position the PiP + this.snap(); } public componentWillUnmount() { diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index 3aaa9ac430..54140e0f4e 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -14,11 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef, useState } from 'react'; +import React, { createRef, useContext } 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'; import { Room } from "matrix-js-sdk/src/models/room"; +import { Optional } from 'matrix-events-sdk'; import LegacyCallView from "./LegacyCallView"; import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler'; @@ -33,15 +34,16 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/Activ import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; -import { SdkContextClass } from '../../../contexts/SDKContext'; +import { SDKContext, SdkContextClass } from '../../../contexts/SDKContext'; import { CallStore } from "../../../stores/CallStore"; import { + useCurrentVoiceBroadcastPreRecording, + useCurrentVoiceBroadcastRecording, + VoiceBroadcastPreRecording, + VoiceBroadcastPreRecordingPip, VoiceBroadcastRecording, VoiceBroadcastRecordingPip, - VoiceBroadcastRecordingsStore, - VoiceBroadcastRecordingsStoreEvent, } from '../../../voice-broadcast'; -import { useTypedEventEmitter } from '../../../hooks/useEventEmitter'; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -53,14 +55,15 @@ const SHOW_CALL_IN_STATES = [ ]; interface IProps { - voiceBroadcastRecording?: VoiceBroadcastRecording; + voiceBroadcastRecording?: Optional; + voiceBroadcastPreRecording?: Optional; } interface IState { - viewedRoomId: string; + viewedRoomId?: string; // The main call that we are displaying (ie. not including the call in the room being viewed, if any) - primaryCall: MatrixCall; + primaryCall: MatrixCall | null; // Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms // they belong to @@ -74,24 +77,26 @@ interface IState { moving: boolean; } -const getRoomAndAppForWidget = (widgetId: string, roomId: string): [Room, IApp] => { - if (!widgetId) return; - if (!roomId) return; +const getRoomAndAppForWidget = (widgetId: string, roomId: string): [Room | null, IApp | null] => { + if (!widgetId) return [null, null]; + if (!roomId) return [null, null]; const room = MatrixClientPeg.get().getRoom(roomId); const app = WidgetStore.instance.getApps(roomId).find((app) => app.id === widgetId); - return [room, app]; + return [room, app || null]; }; // Splits a list of calls into one 'primary' one and a list // (which should be a single element) of other calls. // The primary will be the one not on hold, or an arbitrary one // if they're all on hold) -function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall[]] { +function getPrimarySecondaryCallsForPip(roomId: Optional): [MatrixCall | null, MatrixCall[]] { + if (!roomId) return [null, []]; + const calls = LegacyCallHandler.instance.getAllActiveCallsForPip(roomId); - let primary: MatrixCall = null; + let primary: MatrixCall | null = null; let secondaries: MatrixCall[] = []; for (const call of calls) { @@ -135,8 +140,8 @@ class PipView extends React.Component { this.state = { moving: false, - viewedRoomId: roomId, - primaryCall: primaryCall, + viewedRoomId: roomId || undefined, + primaryCall: primaryCall || null, secondaryCall: secondaryCalls[0], persistentWidgetId: ActiveWidgetStore.instance.getPersistentWidgetId(), persistentRoomId: ActiveWidgetStore.instance.getPersistentRoomId(), @@ -195,7 +200,7 @@ class PipView extends React.Component { if (oldRoom) { WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(oldRoom), this.updateCalls); } - const newRoom = MatrixClientPeg.get()?.getRoom(newRoomId); + const newRoom = MatrixClientPeg.get()?.getRoom(newRoomId || undefined); if (newRoom) { WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(newRoom), this.updateCalls); } @@ -259,20 +264,27 @@ class PipView extends React.Component { if (this.state.showWidgetInPip && widgetId && roomId) { const [room, app] = getRoomAndAppForWidget(widgetId, roomId); - WidgetLayoutStore.instance.moveToContainer(room, app, Container.Center); - } else { - dis.dispatch({ - action: 'video_fullscreen', - fullscreen: true, - }); + + if (room && app) { + WidgetLayoutStore.instance.moveToContainer(room, app, Container.Center); + return; + } } + + dis.dispatch({ + action: 'video_fullscreen', + fullscreen: true, + }); }; private onPin = (): void => { if (!this.state.showWidgetInPip) return; const [room, app] = getRoomAndAppForWidget(this.state.persistentWidgetId, this.state.persistentRoomId); - WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top); + + if (room && app) { + WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top); + } }; private onExpand = (): void => { @@ -321,10 +333,12 @@ class PipView extends React.Component { let pipContent; if (this.state.primaryCall) { + // get a ref to call inside the current scope + const call = this.state.primaryCall; pipContent = ({ onStartMoving, onResize }) => { ; } + if (this.props.voiceBroadcastPreRecording) { + // get a ref to pre-recording inside the current scope + const preRecording = this.props.voiceBroadcastPreRecording; + pipContent = ({ onStartMoving }) =>
+ +
; + } + if (this.props.voiceBroadcastRecording) { + // get a ref to recording inside the current scope + const recording = this.props.voiceBroadcastRecording; pipContent = ({ onStartMoving }) =>
; } @@ -385,23 +411,18 @@ class PipView extends React.Component { } const PipViewHOC: React.FC = (props) => { - // TODO Michael W: extract to custom hook - - const voiceBroadcastRecordingsStore = VoiceBroadcastRecordingsStore.instance(); - const [voiceBroadcastRecording, setVoiceBroadcastRecording] = useState( - voiceBroadcastRecordingsStore.getCurrent(), + const sdkContext = useContext(SDKContext); + const voiceBroadcastPreRecordingStore = sdkContext.voiceBroadcastPreRecordingStore; + const { currentVoiceBroadcastPreRecording } = useCurrentVoiceBroadcastPreRecording( + voiceBroadcastPreRecordingStore, ); - useTypedEventEmitter( - voiceBroadcastRecordingsStore, - VoiceBroadcastRecordingsStoreEvent.CurrentChanged, - (recording: VoiceBroadcastRecording) => { - setVoiceBroadcastRecording(recording); - }, - ); + const voiceBroadcastRecordingsStore = sdkContext.voiceBroadcastRecordingsStore; + const { currentVoiceBroadcastRecording } = useCurrentVoiceBroadcastRecording(voiceBroadcastRecordingsStore); return ; }; diff --git a/src/contexts/SDKContext.ts b/src/contexts/SDKContext.ts index 09f882ba89..fc2e7e4b49 100644 --- a/src/contexts/SDKContext.ts +++ b/src/contexts/SDKContext.ts @@ -29,6 +29,7 @@ import TypingStore from "../stores/TypingStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import { WidgetPermissionStore } from "../stores/widgets/WidgetPermissionStore"; import WidgetStore from "../stores/WidgetStore"; +import { VoiceBroadcastPreRecordingStore, VoiceBroadcastRecordingsStore } from "../voice-broadcast"; export const SDKContext = createContext(undefined); SDKContext.displayName = "SDKContext"; @@ -63,6 +64,8 @@ export class SdkContextClass { protected _SpaceStore?: SpaceStoreClass; protected _LegacyCallHandler?: LegacyCallHandler; protected _TypingStore?: TypingStore; + protected _VoiceBroadcastRecordingsStore?: VoiceBroadcastRecordingsStore; + protected _VoiceBroadcastPreRecordingStore?: VoiceBroadcastPreRecordingStore; /** * Automatically construct stores which need to be created eagerly so they can register with @@ -141,4 +144,18 @@ export class SdkContextClass { } return this._TypingStore; } + + public get voiceBroadcastRecordingsStore(): VoiceBroadcastRecordingsStore { + if (!this._VoiceBroadcastRecordingsStore) { + this._VoiceBroadcastRecordingsStore = VoiceBroadcastRecordingsStore.instance(); + } + return this._VoiceBroadcastRecordingsStore; + } + + public get voiceBroadcastPreRecordingStore(): VoiceBroadcastPreRecordingStore { + if (!this._VoiceBroadcastPreRecordingStore) { + this._VoiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore(); + } + return this._VoiceBroadcastPreRecordingStore; + } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 260ebfc111..f5913e50d2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -647,6 +647,7 @@ "play voice broadcast": "play voice broadcast", "resume voice broadcast": "resume voice broadcast", "pause voice broadcast": "pause voice broadcast", + "Go live": "Go live", "Live": "Live", "Voice broadcast": "Voice broadcast", "Cannot reach homeserver": "Cannot reach homeserver", diff --git a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx index c83e8e8a0c..a3655712ec 100644 --- a/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx +++ b/src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx @@ -19,19 +19,25 @@ import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; import { Icon as MicrophoneIcon } from "../../../../res/img/voip/call-view/mic-on.svg"; import { _t } from "../../../languageHandler"; import RoomAvatar from "../../../components/views/avatars/RoomAvatar"; +import AccessibleButton from "../../../components/views/elements/AccessibleButton"; +import { Icon as XIcon } from "../../../../res/img/element-icons/cancel-rounded.svg"; interface VoiceBroadcastHeaderProps { - live: boolean; - sender: RoomMember; + live?: boolean; + onCloseClick?: () => void; room: Room; + sender: RoomMember; showBroadcast?: boolean; + showClose?: boolean; } export const VoiceBroadcastHeader: React.FC = ({ - live, - sender, + live = false, + onCloseClick = () => {}, room, + sender, showBroadcast = false, + showClose = false, }) => { const broadcast = showBroadcast ?
@@ -39,7 +45,15 @@ export const VoiceBroadcastHeader: React.FC = ({ { _t("Voice broadcast") }
: null; + const liveBadge = live ? : null; + + const closeButton = showClose + ? + + + : null; + return
@@ -53,5 +67,6 @@ export const VoiceBroadcastHeader: React.FC = ({ { broadcast }
{ liveBadge } + { closeButton }
; }; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx new file mode 100644 index 0000000000..b8dfd11811 --- /dev/null +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx @@ -0,0 +1,48 @@ +/* +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 { VoiceBroadcastHeader } from "../.."; +import AccessibleButton from "../../../components/views/elements/AccessibleButton"; +import { VoiceBroadcastPreRecording } from "../../models/VoiceBroadcastPreRecording"; +import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; +import { _t } from "../../../languageHandler"; + +interface Props { + voiceBroadcastPreRecording: VoiceBroadcastPreRecording; +} + +export const VoiceBroadcastPreRecordingPip: React.FC = ({ + voiceBroadcastPreRecording, +}) => { + return
+ + + + { _t("Go live") } + +
; +}; diff --git a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPreRecording.ts b/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPreRecording.ts new file mode 100644 index 0000000000..ca9a5769eb --- /dev/null +++ b/src/voice-broadcast/hooks/useCurrentVoiceBroadcastPreRecording.ts @@ -0,0 +1,38 @@ +/* +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 { useState } from "react"; + +import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; +import { VoiceBroadcastPreRecordingStore } from "../stores/VoiceBroadcastPreRecordingStore"; + +export const useCurrentVoiceBroadcastPreRecording = ( + voiceBroadcastPreRecordingStore: VoiceBroadcastPreRecordingStore, +) => { + const [currentVoiceBroadcastPreRecording, setCurrentVoiceBroadcastPreRecording] = useState( + voiceBroadcastPreRecordingStore.getCurrent(), + ); + + useTypedEventEmitter( + voiceBroadcastPreRecordingStore, + "changed", + setCurrentVoiceBroadcastPreRecording, + ); + + return { + currentVoiceBroadcastPreRecording, + }; +}; diff --git a/src/voice-broadcast/hooks/useCurrentVoiceBroadcastRecording.ts b/src/voice-broadcast/hooks/useCurrentVoiceBroadcastRecording.ts new file mode 100644 index 0000000000..7b5c597a18 --- /dev/null +++ b/src/voice-broadcast/hooks/useCurrentVoiceBroadcastRecording.ts @@ -0,0 +1,38 @@ +/* +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 { useState } from "react"; + +import { VoiceBroadcastRecordingsStore, VoiceBroadcastRecordingsStoreEvent } from ".."; +import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; + +export const useCurrentVoiceBroadcastRecording = ( + voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore, +) => { + const [currentVoiceBroadcastRecording, setCurrentVoiceBroadcastRecording] = useState( + voiceBroadcastRecordingsStore.getCurrent(), + ); + + useTypedEventEmitter( + voiceBroadcastRecordingsStore, + VoiceBroadcastRecordingsStoreEvent.CurrentChanged, + setCurrentVoiceBroadcastRecording, + ); + + return { + currentVoiceBroadcastRecording, + }; +}; diff --git a/src/voice-broadcast/index.ts b/src/voice-broadcast/index.ts index c484f7af26..ac38f96307 100644 --- a/src/voice-broadcast/index.ts +++ b/src/voice-broadcast/index.ts @@ -22,6 +22,7 @@ limitations under the License. import { RelationType } from "matrix-js-sdk/src/matrix"; export * from "./models/VoiceBroadcastPlayback"; +export * from "./models/VoiceBroadcastPreRecording"; export * from "./models/VoiceBroadcastRecording"; export * from "./audio/VoiceBroadcastRecorder"; export * from "./components/VoiceBroadcastBody"; @@ -29,11 +30,16 @@ export * from "./components/atoms/LiveBadge"; export * from "./components/atoms/VoiceBroadcastControl"; export * from "./components/atoms/VoiceBroadcastHeader"; export * from "./components/molecules/VoiceBroadcastPlaybackBody"; +export * from "./components/molecules/VoiceBroadcastPreRecordingPip"; export * from "./components/molecules/VoiceBroadcastRecordingBody"; export * from "./components/molecules/VoiceBroadcastRecordingPip"; +export * from "./hooks/useCurrentVoiceBroadcastPreRecording"; +export * from "./hooks/useCurrentVoiceBroadcastRecording"; export * from "./hooks/useVoiceBroadcastRecording"; export * from "./stores/VoiceBroadcastPlaybacksStore"; +export * from "./stores/VoiceBroadcastPreRecordingStore"; export * from "./stores/VoiceBroadcastRecordingsStore"; +export * from "./utils/checkVoiceBroadcastPreConditions"; export * from "./utils/getChunkLength"; export * from "./utils/hasRoomLiveVoiceBroadcast"; export * from "./utils/findRoomLiveVoiceBroadcastFromUserAndDevice"; diff --git a/src/voice-broadcast/models/VoiceBroadcastPreRecording.ts b/src/voice-broadcast/models/VoiceBroadcastPreRecording.ts new file mode 100644 index 0000000000..f1e956c600 --- /dev/null +++ b/src/voice-broadcast/models/VoiceBroadcastPreRecording.ts @@ -0,0 +1,58 @@ +/* +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 { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix"; +import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; + +import { IDestroyable } from "../../utils/IDestroyable"; +import { VoiceBroadcastRecordingsStore } from "../stores/VoiceBroadcastRecordingsStore"; +import { startNewVoiceBroadcastRecording } from "../utils/startNewVoiceBroadcastRecording"; + +type VoiceBroadcastPreRecordingEvent = "dismiss"; + +interface EventMap { + "dismiss": (voiceBroadcastPreRecording: VoiceBroadcastPreRecording) => void; +} + +export class VoiceBroadcastPreRecording + extends TypedEventEmitter + implements IDestroyable { + public constructor( + public room: Room, + public sender: RoomMember, + private client: MatrixClient, + private recordingsStore: VoiceBroadcastRecordingsStore, + ) { + super(); + } + + public start = async (): Promise => { + await startNewVoiceBroadcastRecording( + this.room, + this.client, + this.recordingsStore, + ); + this.emit("dismiss", this); + }; + + public cancel = (): void => { + this.emit("dismiss", this); + }; + + public destroy(): void { + this.removeAllListeners(); + } +} diff --git a/src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore.ts b/src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore.ts new file mode 100644 index 0000000000..faefea3ddf --- /dev/null +++ b/src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore.ts @@ -0,0 +1,70 @@ +/* +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 { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; + +import { VoiceBroadcastPreRecording } from ".."; +import { IDestroyable } from "../../utils/IDestroyable"; + +export type VoiceBroadcastPreRecordingEvent = "changed"; + +interface EventMap { + changed: (preRecording: VoiceBroadcastPreRecording | null) => void; +} + +export class VoiceBroadcastPreRecordingStore + extends TypedEventEmitter + implements IDestroyable { + private current: VoiceBroadcastPreRecording | null = null; + + public setCurrent(current: VoiceBroadcastPreRecording): void { + if (this.current === current) return; + + if (this.current) { + this.current.off("dismiss", this.onCancel); + } + + this.current = current; + current.on("dismiss", this.onCancel); + this.emit("changed", current); + } + + public clearCurrent(): void { + if (this.current === null) return; + + this.current.off("dismiss", this.onCancel); + this.current = null; + this.emit("changed", null); + } + + public getCurrent(): VoiceBroadcastPreRecording | null { + return this.current; + } + + public destroy(): void { + this.removeAllListeners(); + + if (this.current) { + this.current.off("dismiss", this.onCancel); + } + } + + private onCancel = (voiceBroadcastPreRecording: VoiceBroadcastPreRecording): void => { + if (this.current === voiceBroadcastPreRecording) { + this.clearCurrent(); + } + }; +} diff --git a/src/voice-broadcast/utils/checkVoiceBroadcastPreConditions.tsx b/src/voice-broadcast/utils/checkVoiceBroadcastPreConditions.tsx new file mode 100644 index 0000000000..a76e6faa31 --- /dev/null +++ b/src/voice-broadcast/utils/checkVoiceBroadcastPreConditions.tsx @@ -0,0 +1,84 @@ +/* +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 { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { hasRoomLiveVoiceBroadcast, VoiceBroadcastInfoEventType, VoiceBroadcastRecordingsStore } from ".."; +import InfoDialog from "../../components/views/dialogs/InfoDialog"; +import { _t } from "../../languageHandler"; +import Modal from "../../Modal"; + +const showAlreadyRecordingDialog = () => { + Modal.createDialog(InfoDialog, { + title: _t("Can't start a new voice broadcast"), + description:

{ _t("You are already recording a voice broadcast. " + + "Please end your current voice broadcast to start a new one.") }

, + hasCloseButton: true, + }); +}; + +const showInsufficientPermissionsDialog = () => { + Modal.createDialog(InfoDialog, { + title: _t("Can't start a new voice broadcast"), + description:

{ _t("You don't have the required permissions to start a voice broadcast in this room. " + + "Contact a room administrator to upgrade your permissions.") }

, + hasCloseButton: true, + }); +}; + +const showOthersAlreadyRecordingDialog = () => { + Modal.createDialog(InfoDialog, { + title: _t("Can't start a new voice broadcast"), + description:

{ _t("Someone else is already recording a voice broadcast. " + + "Wait for their voice broadcast to end to start a new one.") }

, + hasCloseButton: true, + }); +}; + +export const checkVoiceBroadcastPreConditions = ( + room: Room, + client: MatrixClient, + recordingsStore: VoiceBroadcastRecordingsStore, +): boolean => { + if (recordingsStore.getCurrent()) { + showAlreadyRecordingDialog(); + return false; + } + + const currentUserId = client.getUserId(); + + if (!currentUserId) return false; + + if (!room.currentState.maySendStateEvent(VoiceBroadcastInfoEventType, currentUserId)) { + showInsufficientPermissionsDialog(); + return false; + } + + const { hasBroadcast, startedByUser } = hasRoomLiveVoiceBroadcast(room, currentUserId); + + if (hasBroadcast && startedByUser) { + showAlreadyRecordingDialog(); + return false; + } + + if (hasBroadcast) { + showOthersAlreadyRecordingDialog(); + return false; + } + + return true; +}; diff --git a/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording.ts b/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording.ts new file mode 100644 index 0000000000..8bd211f612 --- /dev/null +++ b/src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording.ts @@ -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 { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { + checkVoiceBroadcastPreConditions, + VoiceBroadcastPreRecording, + VoiceBroadcastPreRecordingStore, + VoiceBroadcastRecordingsStore, +} from ".."; + +export const setUpVoiceBroadcastPreRecording = ( + room: Room, + client: MatrixClient, + recordingsStore: VoiceBroadcastRecordingsStore, + preRecordingStore: VoiceBroadcastPreRecordingStore, +): VoiceBroadcastPreRecording | null => { + if (!checkVoiceBroadcastPreConditions(room, client, recordingsStore)) { + return null; + } + + const userId = client.getUserId(); + if (!userId) return null; + + const sender = room.getMember(userId); + if (!sender) return null; + + const preRecording = new VoiceBroadcastPreRecording(room, sender, client, recordingsStore); + preRecordingStore.setCurrent(preRecording); + return preRecording; +}; diff --git a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts similarity index 55% rename from src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx rename to src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts index ec57ea5312..ae4e40c4a3 100644 --- a/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.tsx +++ b/src/voice-broadcast/utils/startNewVoiceBroadcastRecording.ts @@ -14,38 +14,39 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; import { ISendEventResponse, MatrixClient, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { defer } from "matrix-js-sdk/src/utils"; -import { _t } from "../../languageHandler"; -import InfoDialog from "../../components/views/dialogs/InfoDialog"; -import Modal from "../../Modal"; import { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState, VoiceBroadcastRecordingsStore, VoiceBroadcastRecording, - hasRoomLiveVoiceBroadcast, getChunkLength, } from ".."; +import { checkVoiceBroadcastPreConditions } from "./checkVoiceBroadcastPreConditions"; const startBroadcast = async ( room: Room, client: MatrixClient, recordingsStore: VoiceBroadcastRecordingsStore, ): Promise => { - const { promise, resolve } = defer(); - let result: ISendEventResponse = null; + const { promise, resolve, reject } = defer(); + + const userId = client.getUserId(); + + if (!userId) { + reject("unable to start voice broadcast if current user is unkonwn"); + return promise; + } + + let result: ISendEventResponse | null = null; const onRoomStateEvents = () => { if (!result) return; - const voiceBroadcastEvent = room.currentState.getStateEvents( - VoiceBroadcastInfoEventType, - client.getUserId(), - ); + const voiceBroadcastEvent = room.currentState.getStateEvents(VoiceBroadcastInfoEventType, userId); if (voiceBroadcastEvent?.getId() === result.event_id) { room.off(RoomStateEvent.Events, onRoomStateEvents); @@ -70,39 +71,12 @@ const startBroadcast = async ( state: VoiceBroadcastInfoState.Started, chunk_length: getChunkLength(), } as VoiceBroadcastInfoEventContent, - client.getUserId(), + userId, ); return promise; }; -const showAlreadyRecordingDialog = () => { - Modal.createDialog(InfoDialog, { - title: _t("Can't start a new voice broadcast"), - description:

{ _t("You are already recording a voice broadcast. " - + "Please end your current voice broadcast to start a new one.") }

, - hasCloseButton: true, - }); -}; - -const showInsufficientPermissionsDialog = () => { - Modal.createDialog(InfoDialog, { - title: _t("Can't start a new voice broadcast"), - description:

{ _t("You don't have the required permissions to start a voice broadcast in this room. " - + "Contact a room administrator to upgrade your permissions.") }

, - hasCloseButton: true, - }); -}; - -const showOthersAlreadyRecordingDialog = () => { - Modal.createDialog(InfoDialog, { - title: _t("Can't start a new voice broadcast"), - description:

{ _t("Someone else is already recording a voice broadcast. " - + "Wait for their voice broadcast to end to start a new one.") }

, - hasCloseButton: true, - }); -}; - /** * Starts a new Voice Broadcast Recording, if * - the user has the permissions to do so in the room @@ -114,27 +88,7 @@ export const startNewVoiceBroadcastRecording = async ( client: MatrixClient, recordingsStore: VoiceBroadcastRecordingsStore, ): Promise => { - if (recordingsStore.getCurrent()) { - showAlreadyRecordingDialog(); - return null; - } - - const currentUserId = client.getUserId(); - - if (!room.currentState.maySendStateEvent(VoiceBroadcastInfoEventType, currentUserId)) { - showInsufficientPermissionsDialog(); - return null; - } - - const { hasBroadcast, startedByUser } = hasRoomLiveVoiceBroadcast(room, currentUserId); - - if (hasBroadcast && startedByUser) { - showAlreadyRecordingDialog(); - return null; - } - - if (hasBroadcast) { - showOthersAlreadyRecordingDialog(); + if (!checkVoiceBroadcastPreConditions(room, client, recordingsStore)) { return null; } diff --git a/test/TestSdkContext.ts b/test/TestSdkContext.ts index 4ce9100a94..7686285e23 100644 --- a/test/TestSdkContext.ts +++ b/test/TestSdkContext.ts @@ -24,6 +24,7 @@ import { SpaceStoreClass } from "../src/stores/spaces/SpaceStore"; import { WidgetLayoutStore } from "../src/stores/widgets/WidgetLayoutStore"; import { WidgetPermissionStore } from "../src/stores/widgets/WidgetPermissionStore"; import WidgetStore from "../src/stores/WidgetStore"; +import { VoiceBroadcastPreRecordingStore, VoiceBroadcastRecordingsStore } from "../src/voice-broadcast"; /** * A class which provides the same API as SdkContextClass but adds additional unsafe setters which can @@ -39,6 +40,8 @@ export class TestSdkContext extends SdkContextClass { public _PosthogAnalytics?: PosthogAnalytics; public _SlidingSyncManager?: SlidingSyncManager; public _SpaceStore?: SpaceStoreClass; + public _VoiceBroadcastRecordingsStore?: VoiceBroadcastRecordingsStore; + public _VoiceBroadcastPreRecordingStore?: VoiceBroadcastPreRecordingStore; constructor() { super(); diff --git a/test/components/views/voip/PipView-test.tsx b/test/components/views/voip/PipView-test.tsx index 4573525cef..370dfbe242 100644 --- a/test/components/views/voip/PipView-test.tsx +++ b/test/components/views/voip/PipView-test.tsx @@ -31,6 +31,8 @@ import { setupAsyncStoreWithClient, resetAsyncStoreWithClient, wrapInMatrixClientContext, + wrapInSdkContext, + mkRoomCreateEvent, } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { CallStore } from "../../../../src/stores/CallStore"; @@ -41,17 +43,27 @@ import DMRoomMap from "../../../../src/utils/DMRoomMap"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../src/dispatcher/actions"; import { ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload"; - -const PipView = wrapInMatrixClientContext(UnwrappedPipView); +import { TestSdkContext } from "../../../TestSdkContext"; +import { + VoiceBroadcastInfoState, + VoiceBroadcastPreRecording, + VoiceBroadcastPreRecordingStore, + VoiceBroadcastRecording, + VoiceBroadcastRecordingsStore, +} from "../../../../src/voice-broadcast"; +import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; describe("PipView", () => { useMockedCalls(); Object.defineProperty(navigator, "mediaDevices", { value: { enumerateDevices: () => [] } }); jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {}); + let sdkContext: TestSdkContext; let client: Mocked; let room: Room; let alice: RoomMember; + let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore; + let voiceBroadcastPreRecordingStore: VoiceBroadcastPreRecordingStore; beforeEach(async () => { stubClient(); @@ -64,6 +76,9 @@ describe("PipView", () => { client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); client.getRooms.mockReturnValue([room]); alice = mkRoomMember(room.roomId, "@alice:example.org"); + room.currentState.setStateEvents([ + mkRoomCreateEvent(alice.userId, room.roomId), + ]); jest.spyOn(room, "getMember").mockImplementation(userId => userId === alice.userId ? alice : null); client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); @@ -73,6 +88,13 @@ describe("PipView", () => { await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map( store => setupAsyncStoreWithClient(store, client), )); + + sdkContext = new TestSdkContext(); + voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore(); + voiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore(); + sdkContext.client = client; + sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore; + sdkContext._VoiceBroadcastPreRecordingStore = voiceBroadcastPreRecordingStore; }); afterEach(async () => { @@ -82,7 +104,12 @@ describe("PipView", () => { jest.restoreAllMocks(); }); - const renderPip = () => { render(); }; + const renderPip = () => { + const PipView = wrapInMatrixClientContext( + wrapInSdkContext(UnwrappedPipView, sdkContext), + ); + render(); + }; const viewRoom = (roomId: string) => defaultDispatcher.dispatch({ @@ -172,4 +199,44 @@ describe("PipView", () => { screen.getByRole("button", { name: /return/i }); }); }); + + describe("when there is a voice broadcast recording", () => { + beforeEach(() => { + const voiceBroadcastInfoEvent = mkVoiceBroadcastInfoStateEvent( + room.roomId, + VoiceBroadcastInfoState.Started, + alice.userId, + client.getDeviceId() || "", + ); + + const voiceBroadcastRecording = new VoiceBroadcastRecording(voiceBroadcastInfoEvent, client); + voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording); + + renderPip(); + }); + + it("should render the voice broadcast recording PiP", () => { + // check for the „Live“ badge + screen.getByText("Live"); + }); + }); + + describe("when there is a voice broadcast pre-recording", () => { + beforeEach(() => { + const voiceBroadcastPreRecording = new VoiceBroadcastPreRecording( + room, + alice, + client, + voiceBroadcastRecordingsStore, + ); + voiceBroadcastPreRecordingStore.setCurrent(voiceBroadcastPreRecording); + + renderPip(); + }); + + it("should render the voice broadcast pre-recording PiP", () => { + // check for the „Go live“ button + screen.getByText("Go live"); + }); + }); }); diff --git a/test/contexts/SdkContext-test.ts b/test/contexts/SdkContext-test.ts new file mode 100644 index 0000000000..cd8676b332 --- /dev/null +++ b/test/contexts/SdkContext-test.ts @@ -0,0 +1,34 @@ +/* +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 { SdkContextClass } from "../../src/contexts/SDKContext"; +import { VoiceBroadcastPreRecordingStore } from "../../src/voice-broadcast"; + +jest.mock("../../src/voice-broadcast/stores/VoiceBroadcastPreRecordingStore"); + +describe("SdkContextClass", () => { + const sdkContext = SdkContextClass.instance; + + it("instance should always return the same instance", () => { + expect(SdkContextClass.instance).toBe(sdkContext); + }); + + it("voiceBroadcastPreRecordingStore should always return the same VoiceBroadcastPreRecordingStore", () => { + const first = sdkContext.voiceBroadcastPreRecordingStore; + expect(first).toBeInstanceOf(VoiceBroadcastPreRecordingStore); + expect(sdkContext.voiceBroadcastPreRecordingStore).toBe(first); + }); +}); diff --git a/test/stores/OwnBeaconStore-test.ts b/test/stores/OwnBeaconStore-test.ts index 30bc2be5fa..9835ddfb46 100644 --- a/test/stores/OwnBeaconStore-test.ts +++ b/test/stores/OwnBeaconStore-test.ts @@ -45,6 +45,7 @@ import { getMockClientWithEventEmitter } from "../test-utils/client"; // modern fake timers and lodash.debounce are a faff // short circuit it jest.mock("lodash", () => ({ + ...jest.requireActual("lodash") as object, debounce: jest.fn().mockImplementation(callback => callback), })); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index ef95d6d5a7..27c33c900e 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -32,6 +32,7 @@ import { IUnsigned, IPusher, RoomType, + KNOWN_SAFE_ROOM_VERSION, } from 'matrix-js-sdk/src/matrix'; import { normalize } from "matrix-js-sdk/src/utils"; import { ReEmitter } from "matrix-js-sdk/src/ReEmitter"; @@ -223,6 +224,20 @@ type MakeEventProps = MakeEventPassThruProps & { unsigned?: IUnsigned; }; +export const mkRoomCreateEvent = (userId: string, roomId: string): MatrixEvent => { + return mkEvent({ + event: true, + type: EventType.RoomCreate, + content: { + creator: userId, + room_version: KNOWN_SAFE_ROOM_VERSION, + }, + skey: "", + user: userId, + room: roomId, + }); +}; + /** * Create an Event. * @param {Object} opts Values for the event. @@ -567,6 +582,19 @@ export const mkSpace = ( return space; }; +export const mkRoomMemberJoinEvent = (user: string, room: string): MatrixEvent => { + return mkEvent({ + event: true, + type: EventType.RoomMember, + content: { + membership: "join", + }, + skey: user, + user, + room, + }); +}; + export const mkPusher = (extra: Partial = {}): IPusher => ({ app_display_name: "app", app_id: "123", diff --git a/test/test-utils/wrappers.tsx b/test/test-utils/wrappers.tsx index faaf5bf6a7..62c11ff1a6 100644 --- a/test/test-utils/wrappers.tsx +++ b/test/test-utils/wrappers.tsx @@ -19,6 +19,7 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg'; import MatrixClientContext from "../../src/contexts/MatrixClientContext"; +import { SDKContext, SdkContextClass } from "../../src/contexts/SDKContext"; type WrapperProps = { wrappedRef?: RefCallback> } & T; @@ -39,3 +40,16 @@ export function wrapInMatrixClientContext(WrappedComponent: ComponentType) } return Wrapper; } + +export function wrapInSdkContext( + WrappedComponent: ComponentType, + sdkContext: SdkContextClass, +): ComponentType> { + return class extends React.Component> { + render() { + return + + ; + } + }; +} diff --git a/test/utils/MultiInviter-test.ts b/test/utils/MultiInviter-test.ts index 0e87e8d6d6..83b71232fc 100644 --- a/test/utils/MultiInviter-test.ts +++ b/test/utils/MultiInviter-test.ts @@ -41,6 +41,7 @@ jest.mock('../../src/Modal', () => ({ jest.mock('../../src/settings/SettingsStore', () => ({ getValue: jest.fn(), + monitorSetting: jest.fn(), })); const mockPromptBeforeInviteUnknownUsers = (value: boolean) => { diff --git a/test/voice-broadcast/models/VoiceBroadcastPreRecording-test.ts b/test/voice-broadcast/models/VoiceBroadcastPreRecording-test.ts new file mode 100644 index 0000000000..3a9fc11065 --- /dev/null +++ b/test/voice-broadcast/models/VoiceBroadcastPreRecording-test.ts @@ -0,0 +1,77 @@ +/* +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 { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix"; + +import { + startNewVoiceBroadcastRecording, + VoiceBroadcastPreRecording, + VoiceBroadcastRecordingsStore, +} from "../../../src/voice-broadcast"; +import { stubClient } from "../../test-utils"; + +jest.mock("../../../src/voice-broadcast/utils/startNewVoiceBroadcastRecording"); + +describe("VoiceBroadcastPreRecording", () => { + const roomId = "!room:example.com"; + let client: MatrixClient; + let room: Room; + let sender: RoomMember; + let recordingsStore: VoiceBroadcastRecordingsStore; + let preRecording: VoiceBroadcastPreRecording; + let onDismiss: (voiceBroadcastPreRecording: VoiceBroadcastPreRecording) => void; + + beforeAll(() => { + client = stubClient(); + room = new Room(roomId, client, client.getUserId() || ""); + sender = new RoomMember(roomId, client.getUserId() || ""); + recordingsStore = new VoiceBroadcastRecordingsStore(); + }); + + beforeEach(() => { + onDismiss = jest.fn(); + preRecording = new VoiceBroadcastPreRecording(room, sender, client, recordingsStore); + preRecording.on("dismiss", onDismiss); + }); + + describe("start", () => { + beforeEach(() => { + preRecording.start(); + }); + + it("should start a new voice broadcast recording", () => { + expect(startNewVoiceBroadcastRecording).toHaveBeenCalledWith( + room, + client, + recordingsStore, + ); + }); + + it("should emit a dismiss event", () => { + expect(onDismiss).toHaveBeenCalledWith(preRecording); + }); + }); + + describe("cancel", () => { + beforeEach(() => { + preRecording.cancel(); + }); + + it("should emit a dismiss event", () => { + expect(onDismiss).toHaveBeenCalledWith(preRecording); + }); + }); +}); diff --git a/test/voice-broadcast/stores/VoiceBroadcastPreRecordingStore-test.ts b/test/voice-broadcast/stores/VoiceBroadcastPreRecordingStore-test.ts new file mode 100644 index 0000000000..36983ae601 --- /dev/null +++ b/test/voice-broadcast/stores/VoiceBroadcastPreRecordingStore-test.ts @@ -0,0 +1,137 @@ +/* +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 { mocked } from "jest-mock"; +import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix"; + +import { + VoiceBroadcastPreRecording, + VoiceBroadcastPreRecordingStore, + VoiceBroadcastRecordingsStore, +} from "../../../src/voice-broadcast"; +import { stubClient } from "../../test-utils"; + +jest.mock("../../../src/voice-broadcast/stores/VoiceBroadcastRecordingsStore"); + +describe("VoiceBroadcastPreRecordingStore", () => { + const roomId = "!room:example.com"; + let client: MatrixClient; + let room: Room; + let sender: RoomMember; + let recordingsStore: VoiceBroadcastRecordingsStore; + let store: VoiceBroadcastPreRecordingStore; + let preRecording1: VoiceBroadcastPreRecording; + + beforeAll(() => { + client = stubClient(); + room = new Room(roomId, client, client.getUserId() || ""); + sender = new RoomMember(roomId, client.getUserId() || ""); + recordingsStore = new VoiceBroadcastRecordingsStore(); + }); + + beforeEach(() => { + store = new VoiceBroadcastPreRecordingStore(); + jest.spyOn(store, "emit"); + jest.spyOn(store, "removeAllListeners"); + preRecording1 = new VoiceBroadcastPreRecording(room, sender, client, recordingsStore); + jest.spyOn(preRecording1, "off"); + }); + + it("getCurrent() should return null", () => { + expect(store.getCurrent()).toBeNull(); + }); + + it("clearCurrent() should work", () => { + store.clearCurrent(); + expect(store.getCurrent()).toBeNull(); + }); + + describe("when setting a current recording", () => { + beforeEach(() => { + store.setCurrent(preRecording1); + }); + + it("getCurrent() should return the recording", () => { + expect(store.getCurrent()).toBe(preRecording1); + }); + + it("should emit a changed event with the recording", () => { + expect(store.emit).toHaveBeenCalledWith("changed", preRecording1); + }); + + describe("and calling destroy()", () => { + beforeEach(() => { + store.destroy(); + }); + + it("should remove all listeners", () => { + expect(store.removeAllListeners).toHaveBeenCalled(); + }); + + it("should deregister from the pre-recordings", () => { + expect(preRecording1.off).toHaveBeenCalledWith("dismiss", expect.any(Function)); + }); + }); + + describe("and cancelling the pre-recording", () => { + beforeEach(() => { + preRecording1.cancel(); + }); + + it("should clear the current recording", () => { + expect(store.getCurrent()).toBeNull(); + }); + + it("should emit a changed event with null", () => { + expect(store.emit).toHaveBeenCalledWith("changed", null); + }); + }); + + describe("and setting the same pre-recording again", () => { + beforeEach(() => { + mocked(store.emit).mockClear(); + store.setCurrent(preRecording1); + }); + + it("should not emit a changed event", () => { + expect(store.emit).not.toHaveBeenCalled(); + }); + }); + + describe("and setting another pre-recording", () => { + let preRecording2: VoiceBroadcastPreRecording; + + beforeEach(() => { + mocked(store.emit).mockClear(); + mocked(preRecording1.off).mockClear(); + preRecording2 = new VoiceBroadcastPreRecording(room, sender, client, recordingsStore); + store.setCurrent(preRecording2); + }); + + it("should deregister from the current pre-recording", () => { + expect(preRecording1.off).toHaveBeenCalledWith("dismiss", expect.any(Function)); + }); + + it("getCurrent() should return the new recording", () => { + expect(store.getCurrent()).toBe(preRecording2); + }); + + it("should emit a changed event with the new recording", () => { + expect(store.emit).toHaveBeenCalledWith("changed", preRecording2); + }); + }); + }); +}); diff --git a/test/voice-broadcast/utils/setUpVoiceBroadcastPreRecording-test.ts b/test/voice-broadcast/utils/setUpVoiceBroadcastPreRecording-test.ts new file mode 100644 index 0000000000..0b05d26912 --- /dev/null +++ b/test/voice-broadcast/utils/setUpVoiceBroadcastPreRecording-test.ts @@ -0,0 +1,102 @@ +/* +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 { mocked } from "jest-mock"; +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + +import { + checkVoiceBroadcastPreConditions, + VoiceBroadcastPreRecording, + VoiceBroadcastPreRecordingStore, + VoiceBroadcastRecordingsStore, +} from "../../../src/voice-broadcast"; +import { setUpVoiceBroadcastPreRecording } from "../../../src/voice-broadcast/utils/setUpVoiceBroadcastPreRecording"; +import { mkRoomMemberJoinEvent, stubClient } from "../../test-utils"; + +jest.mock("../../../src/voice-broadcast/utils/checkVoiceBroadcastPreConditions"); + +describe("setUpVoiceBroadcastPreRecording", () => { + const roomId = "!room:example.com"; + let client: MatrixClient; + let userId: string; + let room: Room; + let preRecordingStore: VoiceBroadcastPreRecordingStore; + let recordingsStore: VoiceBroadcastRecordingsStore; + + const itShouldReturnNull = () => { + it("should return null", () => { + expect(setUpVoiceBroadcastPreRecording(room, client, recordingsStore, preRecordingStore)).toBeNull(); + expect(checkVoiceBroadcastPreConditions).toHaveBeenCalledWith(room, client, recordingsStore); + }); + }; + + beforeEach(() => { + client = stubClient(); + + const clientUserId = client.getUserId(); + if (!clientUserId) fail("empty userId"); + userId = clientUserId; + + room = new Room(roomId, client, userId); + preRecordingStore = new VoiceBroadcastPreRecordingStore(); + recordingsStore = new VoiceBroadcastRecordingsStore(); + }); + + describe("when the preconditions fail", () => { + beforeEach(() => { + mocked(checkVoiceBroadcastPreConditions).mockReturnValue(false); + }); + + itShouldReturnNull(); + }); + + describe("when the preconditions pass", () => { + beforeEach(() => { + mocked(checkVoiceBroadcastPreConditions).mockReturnValue(true); + }); + + describe("and there is no user id", () => { + beforeEach(() => { + mocked(client.getUserId).mockReturnValue(null); + }); + + itShouldReturnNull(); + }); + + describe("and there is no room member", () => { + beforeEach(() => { + // check test precondition + expect(room.getMember(userId)).toBeNull(); + }); + + itShouldReturnNull(); + }); + + describe("and there is a room member", () => { + beforeEach(() => { + room.currentState.setStateEvents([ + mkRoomMemberJoinEvent(userId, roomId), + ]); + }); + + it("should create a voice broadcast pre-recording", () => { + const result = setUpVoiceBroadcastPreRecording(room, client, recordingsStore, preRecordingStore); + expect(checkVoiceBroadcastPreConditions).toHaveBeenCalledWith(room, client, recordingsStore); + expect(result).toBeInstanceOf(VoiceBroadcastPreRecording); + }); + }); + }); +});