mirror of
https://github.com/element-hq/element-web
synced 2024-11-27 03:36:07 +03:00
Keep unsent voice messages in memory until they are deleted or sent (#7840)
Fixes https://github.com/vector-im/element-web/issues/17979
This commit is contained in:
parent
38a547b5d0
commit
b756f03563
3 changed files with 123 additions and 46 deletions
|
@ -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<IProps, IState> {
|
|||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
||||
private instanceId: number;
|
||||
|
||||
static contextType = RoomContext;
|
||||
private _voiceRecording: Optional<VoiceRecording>;
|
||||
|
||||
public static contextType = RoomContext;
|
||||
public context!: React.ContextType<typeof RoomContext>;
|
||||
|
||||
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<IProps, IState> {
|
|||
SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
private get voiceRecording(): Optional<VoiceRecording> {
|
||||
return this._voiceRecording;
|
||||
}
|
||||
|
||||
private set voiceRecording(rec: Optional<VoiceRecording>) {
|
||||
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<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
public componentWillUnmount() {
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
|
||||
}
|
||||
|
@ -199,6 +226,9 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
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<IProps, IState> {
|
|||
};
|
||||
|
||||
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<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
public render() {
|
||||
const controls = [
|
||||
this.props.e2eStatus ?
|
||||
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
|
||||
|
|
|
@ -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<IProps,
|
|||
};
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const recorder = VoiceRecordingStore.instance.getActiveRecording(this.props.room.roomId);
|
||||
if (recorder) {
|
||||
if (recorder.isRecording || !recorder.hasRecording) {
|
||||
logger.warn("Cached recording hasn't ended yet and might cause issues");
|
||||
}
|
||||
this.bindNewRecorder(recorder);
|
||||
this.setState({ recorder, recordingPhase: RecordingState.Ended });
|
||||
}
|
||||
}
|
||||
|
||||
public async componentWillUnmount() {
|
||||
await VoiceRecordingStore.instance.disposeRecording();
|
||||
// Stop recording, but keep the recording memory (don't dispose it). This is to let the user
|
||||
// come back and finish working with it.
|
||||
const recording = VoiceRecordingStore.instance.getActiveRecording(this.props.room.roomId);
|
||||
await recording?.stop();
|
||||
|
||||
// Clean up our listeners by binding a falsy recorder
|
||||
this.bindNewRecorder(null);
|
||||
}
|
||||
|
||||
// called by composer
|
||||
|
@ -128,7 +146,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
}
|
||||
|
||||
private async disposeRecording() {
|
||||
await VoiceRecordingStore.instance.disposeRecording();
|
||||
await VoiceRecordingStore.instance.disposeRecording(this.props.room.roomId);
|
||||
|
||||
// Reset back to no recording, which means no phase (ie: restart component entirely)
|
||||
this.setState({ recorder: null, recordingPhase: null, didUploadFail: false });
|
||||
|
@ -182,14 +200,10 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
|||
// stop any noises which might be happening
|
||||
await PlaybackManager.instance.pauseAllExcept(null);
|
||||
|
||||
const recorder = VoiceRecordingStore.instance.startRecording();
|
||||
const recorder = VoiceRecordingStore.instance.startRecording(this.props.room.roomId);
|
||||
await recorder.start();
|
||||
|
||||
// We don't need to remove the listener: the recorder will clean that up for us.
|
||||
recorder.on(UPDATE_EVENT, (ev: RecordingState) => {
|
||||
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<IProps,
|
|||
accessError();
|
||||
|
||||
// noinspection ES6MissingAwait - if this goes wrong we don't want it to affect the call stack
|
||||
VoiceRecordingStore.instance.disposeRecording();
|
||||
VoiceRecordingStore.instance.disposeRecording(this.props.room.roomId);
|
||||
}
|
||||
};
|
||||
|
||||
private bindNewRecorder(recorder: Optional<VoiceRecording>) {
|
||||
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
|
||||
|
||||
|
|
|
@ -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<VoiceRecording>;
|
||||
}
|
||||
|
||||
export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
|
||||
|
@ -30,13 +32,6 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
|
|||
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<IState> {
|
|||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the active recording instance, if any.
|
||||
* @param {string} roomId The room ID to get the recording in.
|
||||
* @returns {Optional<VoiceRecording>} The recording, if any.
|
||||
*/
|
||||
public getActiveRecording(roomId: string): Optional<VoiceRecording> {
|
||||
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<void>} Resolves when complete.
|
||||
*/
|
||||
public disposeRecording(): Promise<void> {
|
||||
if (this.state.recording) {
|
||||
this.state.recording.destroy(); // stops internally
|
||||
public disposeRecording(roomId: string): Promise<void> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue