From b756f035632bf9419e564bdb7ffea000129f4d7a Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 18 Feb 2022 07:29:08 -0700 Subject: [PATCH] Keep unsent voice messages in memory until they are deleted or sent (#7840) Fixes https://github.com/vector-im/element-web/issues/17979 --- .../views/rooms/MessageComposer.tsx | 82 ++++++++++++++----- .../views/rooms/VoiceRecordComposerTile.tsx | 48 ++++++++--- src/stores/VoiceRecordingStore.ts | 39 +++++---- 3 files changed, 123 insertions(+), 46 deletions(-) diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index c368f2a2cc..721ca46152 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -20,6 +20,7 @@ import { MatrixEvent, IEventRelation } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event'; +import { Optional } from "matrix-events-sdk"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -36,7 +37,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; -import { RecordingState } from "../../../audio/VoiceRecording"; +import { RecordingState, VoiceRecording } from "../../../audio/VoiceRecording"; import Tooltip, { Alignment } from "../elements/Tooltip"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import { E2EStatus } from '../../../utils/ShieldUtils'; @@ -100,14 +101,16 @@ export default class MessageComposer extends React.Component { private ref: React.RefObject = createRef(); private instanceId: number; - static contextType = RoomContext; + private _voiceRecording: Optional; + + public static contextType = RoomContext; public context!: React.ContextType; - static defaultProps = { + public static defaultProps = { compact: false, }; - constructor(props: IProps) { + public constructor(props: IProps) { super(props); VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate); @@ -127,12 +130,36 @@ export default class MessageComposer extends React.Component { SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null); } - componentDidMount() { + private get voiceRecording(): Optional { + return this._voiceRecording; + } + + private set voiceRecording(rec: Optional) { + if (this._voiceRecording) { + this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted); + this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon); + } + + this._voiceRecording = rec; + + if (rec) { + // Delay saying we have a recording until it is started, as we might not yet + // have A/V permissions + rec.on(RecordingState.Started, this.onRecordingStarted); + + // We show a little heads up that the recording is about to automatically end soon. The 3s + // display time is completely arbitrary. + rec.on(RecordingState.EndingSoon, this.onRecordingEndingSoon); + } + } + + public componentDidMount() { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); this.waitForOwnMember(); UIStore.instance.trackElementDimensions(`MessageComposer${this.instanceId}`, this.ref.current); UIStore.instance.on(`MessageComposer${this.instanceId}`, this.onResize); + this.updateRecordingState(); // grab any cached recordings } private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry) => { @@ -191,7 +218,7 @@ export default class MessageComposer extends React.Component { }); } - componentWillUnmount() { + public componentWillUnmount() { if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); } @@ -199,6 +226,9 @@ export default class MessageComposer extends React.Component { dis.unregister(this.dispatcherRef); UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`); UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize); + + // clean up our listeners by setting our cached recording to falsy (see internal setter) + this.voiceRecording = null; } private onRoomStateEvents = (ev, state) => { @@ -290,22 +320,34 @@ export default class MessageComposer extends React.Component { }; private onVoiceStoreUpdate = () => { - const recording = VoiceRecordingStore.instance.activeRecording; - if (recording) { - // Delay saying we have a recording until it is started, as we might not yet have A/V permissions - recording.on(RecordingState.Started, () => { - this.setState({ haveRecording: !!VoiceRecordingStore.instance.activeRecording }); - }); - // We show a little heads up that the recording is about to automatically end soon. The 3s - // display time is completely arbitrary. Note that we don't need to deregister the listener - // because the recording instance will clean that up for us. - recording.on(RecordingState.EndingSoon, ({ secondsLeft }) => { - this.setState({ recordingTimeLeftSeconds: secondsLeft }); - setTimeout(() => this.setState({ recordingTimeLeftSeconds: null }), 3000); - }); + this.updateRecordingState(); + }; + + private updateRecordingState() { + this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(this.props.room.roomId); + if (this.voiceRecording) { + // If the recording has already started, it's probably a cached one. + if (this.voiceRecording.hasRecording && !this.voiceRecording.isRecording) { + this.setState({ haveRecording: true }); + } + + // Note: Listeners for recording states are set by the `this.voiceRecording` setter. } else { this.setState({ haveRecording: false }); } + } + + private onRecordingStarted = () => { + // update the recording instance, just in case + this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(this.props.room.roomId); + this.setState({ + haveRecording: !!this.voiceRecording, + }); + }; + + private onRecordingEndingSoon = ({ secondsLeft }) => { + this.setState({ recordingTimeLeftSeconds: secondsLeft }); + setTimeout(() => this.setState({ recordingTimeLeftSeconds: null }), 3000); }; private setStickerPickerOpen = (isStickerPickerOpen: boolean) => { @@ -321,7 +363,7 @@ export default class MessageComposer extends React.Component { }); }; - render() { + public render() { const controls = [ this.props.e2eStatus ? : diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 18a49dd4c7..448883645e 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -18,6 +18,7 @@ import React, { ReactNode } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { MsgType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; +import { Optional } from "matrix-events-sdk"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { _t } from "../../../languageHandler"; @@ -61,8 +62,25 @@ export default class VoiceRecordComposerTile extends React.PureComponent { - if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here - this.setState({ recordingPhase: ev }); - }); + this.bindNewRecorder(recorder); this.setState({ recorder, recordingPhase: RecordingState.Started }); } catch (e) { @@ -197,10 +211,24 @@ export default class VoiceRecordComposerTile extends React.PureComponent) { + if (this.state.recorder) { + this.state.recorder.off(UPDATE_EVENT, this.onRecordingUpdate); + } + if (recorder) { + recorder.on(UPDATE_EVENT, this.onRecordingUpdate); + } + } + + private onRecordingUpdate = (ev: RecordingState) => { + if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here + this.setState({ recordingPhase: ev }); + }; + private renderWaveformArea(): ReactNode { if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts index df837fec88..f047916083 100644 --- a/src/stores/VoiceRecordingStore.ts +++ b/src/stores/VoiceRecordingStore.ts @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021 - 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. @@ -14,13 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { Optional } from "matrix-events-sdk"; + import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import { VoiceRecording } from "../audio/VoiceRecording"; interface IState { - recording?: VoiceRecording; + [roomId: string]: Optional; } export class VoiceRecordingStore extends AsyncStoreWithClient { @@ -30,13 +32,6 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { super(defaultDispatcher, {}); } - /** - * Gets the active recording instance, if any. - */ - public get activeRecording(): VoiceRecording | null { - return this.state.recording; - } - public static get instance(): VoiceRecordingStore { if (!VoiceRecordingStore.internalInstance) { VoiceRecordingStore.internalInstance = new VoiceRecordingStore(); @@ -49,33 +44,45 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { return; } + /** + * Gets the active recording instance, if any. + * @param {string} roomId The room ID to get the recording in. + * @returns {Optional} The recording, if any. + */ + public getActiveRecording(roomId: string): Optional { + return this.state[roomId]; + } + /** * Starts a new recording if one isn't already in progress. Note that this simply * creates a recording instance - whether or not recording is actively in progress * can be seen via the VoiceRecording class. + * @param {string} roomId The room ID to start recording in. * @returns {VoiceRecording} The recording. */ - public startRecording(): VoiceRecording { + public startRecording(roomId: string): VoiceRecording { if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient"); - if (this.state.recording) throw new Error("A recording is already in progress"); + if (!roomId) throw new Error("Recording must be associated with a room"); + if (this.state[roomId]) throw new Error("A recording is already in progress"); const recording = new VoiceRecording(this.matrixClient); // noinspection JSIgnoredPromiseFromCall - we can safely run this async - this.updateState({ recording }); + this.updateState({ ...this.state, [roomId]: recording }); return recording; } /** * Disposes of the current recording, no matter the state of it. + * @param {string} roomId The room ID to dispose of the recording in. * @returns {Promise} Resolves when complete. */ - public disposeRecording(): Promise { - if (this.state.recording) { - this.state.recording.destroy(); // stops internally + public disposeRecording(roomId: string): Promise { + if (this.state[roomId]) { + this.state[roomId].destroy(); // stops internally } - return this.updateState({ recording: null }); + return this.updateState(Object.fromEntries(Object.entries(this.state).filter(e => e[0] !== roomId))); } }