Fix voice messages with multiple composers (#9208)

* Allow having multiple voice messages in composers

Co-authored-by: grimhilt <grimhilt@users.noreply.github.com>
Co-authored-by: Janne Mareike Koschinski <janne@kuschku.de>
This commit is contained in:
grimhilt 2022-09-05 10:04:37 +00:00 committed by GitHub
parent 85f92308f9
commit 6d03cb35b7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 39 additions and 24 deletions

View file

@ -308,7 +308,8 @@ export default class MessageComposer extends React.Component<IProps, IState> {
}; };
private updateRecordingState() { private updateRecordingState() {
this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(this.props.room.roomId); const voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation);
this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(voiceRecordingId);
if (this.voiceRecording) { if (this.voiceRecording) {
// If the recording has already started, it's probably a cached one. // If the recording has already started, it's probably a cached one.
if (this.voiceRecording.hasRecording && !this.voiceRecording.isRecording) { if (this.voiceRecording.hasRecording && !this.voiceRecording.isRecording) {
@ -323,7 +324,8 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private onRecordingStarted = () => { private onRecordingStarted = () => {
// update the recording instance, just in case // update the recording instance, just in case
this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(this.props.room.roomId); const voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation);
this.voiceRecording = VoiceRecordingStore.instance.getActiveRecording(voiceRecordingId);
this.setState({ this.setState({
haveRecording: !!this.voiceRecording, haveRecording: !!this.voiceRecording,
}); });

View file

@ -64,6 +64,7 @@ interface IState {
export default class VoiceRecordComposerTile extends React.PureComponent<IProps, IState> { export default class VoiceRecordComposerTile extends React.PureComponent<IProps, IState> {
static contextType = RoomContext; static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>; public context!: React.ContextType<typeof RoomContext>;
private voiceRecordingId: string;
public constructor(props: IProps) { public constructor(props: IProps) {
super(props); super(props);
@ -71,10 +72,12 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
this.state = { this.state = {
recorder: null, // no recording started by default recorder: null, // no recording started by default
}; };
this.voiceRecordingId = VoiceRecordingStore.getVoiceRecordingId(this.props.room, this.props.relation);
} }
public componentDidMount() { public componentDidMount() {
const recorder = VoiceRecordingStore.instance.getActiveRecording(this.props.room.roomId); const recorder = VoiceRecordingStore.instance.getActiveRecording(this.voiceRecordingId);
if (recorder) { if (recorder) {
if (recorder.isRecording || !recorder.hasRecording) { if (recorder.isRecording || !recorder.hasRecording) {
logger.warn("Cached recording hasn't ended yet and might cause issues"); logger.warn("Cached recording hasn't ended yet and might cause issues");
@ -87,7 +90,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
public async componentWillUnmount() { public async componentWillUnmount() {
// Stop recording, but keep the recording memory (don't dispose it). This is to let the user // Stop recording, but keep the recording memory (don't dispose it). This is to let the user
// come back and finish working with it. // come back and finish working with it.
const recording = VoiceRecordingStore.instance.getActiveRecording(this.props.room.roomId); const recording = VoiceRecordingStore.instance.getActiveRecording(this.voiceRecordingId);
await recording?.stop(); await recording?.stop();
// Clean up our listeners by binding a falsy recorder // Clean up our listeners by binding a falsy recorder
@ -106,7 +109,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
let upload: IUpload; let upload: IUpload;
try { try {
upload = await this.state.recorder.upload(this.props.room.roomId); upload = await this.state.recorder.upload(this.voiceRecordingId);
} catch (e) { } catch (e) {
logger.error("Error uploading voice message:", e); logger.error("Error uploading voice message:", e);
@ -179,7 +182,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
} }
private async disposeRecording() { private async disposeRecording() {
await VoiceRecordingStore.instance.disposeRecording(this.props.room.roomId); await VoiceRecordingStore.instance.disposeRecording(this.voiceRecordingId);
// Reset back to no recording, which means no phase (ie: restart component entirely) // Reset back to no recording, which means no phase (ie: restart component entirely)
this.setState({ recorder: null, recordingPhase: null, didUploadFail: false }); this.setState({ recorder: null, recordingPhase: null, didUploadFail: false });
@ -232,8 +235,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
try { try {
// stop any noises which might be happening // stop any noises which might be happening
PlaybackManager.instance.pauseAllExcept(null); PlaybackManager.instance.pauseAllExcept(null);
const recorder = VoiceRecordingStore.instance.startRecording(this.voiceRecordingId);
const recorder = VoiceRecordingStore.instance.startRecording(this.props.room.roomId);
await recorder.start(); await recorder.start();
this.bindNewRecorder(recorder); this.bindNewRecorder(recorder);
@ -244,7 +246,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
accessError(); accessError();
// noinspection ES6MissingAwait - if this goes wrong we don't want it to affect the call stack // noinspection ES6MissingAwait - if this goes wrong we don't want it to affect the call stack
VoiceRecordingStore.instance.disposeRecording(this.props.room.roomId); VoiceRecordingStore.instance.disposeRecording(this.voiceRecordingId);
} }
}; };

View file

@ -15,14 +15,19 @@ limitations under the License.
*/ */
import { Optional } from "matrix-events-sdk"; import { Optional } from "matrix-events-sdk";
import { Room } from "matrix-js-sdk/src/models/room";
import { RelationType } from "matrix-js-sdk/src/@types/event";
import { IEventRelation } from "matrix-js-sdk/src/models/event";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher"; import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads"; import { ActionPayload } from "../dispatcher/payloads";
import { VoiceRecording } from "../audio/VoiceRecording"; import { VoiceRecording } from "../audio/VoiceRecording";
const SEPARATOR = "|";
interface IState { interface IState {
[roomId: string]: Optional<VoiceRecording>; [voiceRecordingId: string]: Optional<VoiceRecording>;
} }
export class VoiceRecordingStore extends AsyncStoreWithClient<IState> { export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
@ -45,48 +50,54 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
return; return;
} }
public static getVoiceRecordingId(room: Room, relation?: IEventRelation): string {
if (relation?.rel_type === "io.element.thread" || relation?.rel_type === RelationType.Thread) {
return room.roomId + SEPARATOR + relation.event_id;
} else {
return room.roomId;
}
}
/** /**
* Gets the active recording instance, if any. * Gets the active recording instance, if any.
* @param {string} roomId The room ID to get the recording in. * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to get the recording in.
* @returns {Optional<VoiceRecording>} The recording, if any. * @returns {Optional<VoiceRecording>} The recording, if any.
*/ */
public getActiveRecording(roomId: string): Optional<VoiceRecording> { public getActiveRecording(voiceRecordingId: string): Optional<VoiceRecording> {
return this.state[roomId]; return this.state[voiceRecordingId];
} }
/** /**
* Starts a new recording if one isn't already in progress. Note that this simply * 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 * creates a recording instance - whether or not recording is actively in progress
* can be seen via the VoiceRecording class. * can be seen via the VoiceRecording class.
* @param {string} roomId The room ID to start recording in. * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to start recording in.
* @returns {VoiceRecording} The recording. * @returns {VoiceRecording} The recording.
*/ */
public startRecording(roomId: string): VoiceRecording { public startRecording(voiceRecordingId: string): VoiceRecording {
if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient"); if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient");
if (!roomId) throw new Error("Recording must be associated with a room"); if (!voiceRecordingId) throw new Error("Recording must be associated with a room");
if (this.state[roomId]) throw new Error("A recording is already in progress"); if (this.state[voiceRecordingId]) throw new Error("A recording is already in progress");
const recording = new VoiceRecording(this.matrixClient); const recording = new VoiceRecording(this.matrixClient);
// noinspection JSIgnoredPromiseFromCall - we can safely run this async // noinspection JSIgnoredPromiseFromCall - we can safely run this async
this.updateState({ ...this.state, [roomId]: recording }); this.updateState({ ...this.state, [voiceRecordingId]: recording });
return recording; return recording;
} }
/** /**
* Disposes of the current recording, no matter the state of it. * Disposes of the current recording, no matter the state of it.
* @param {string} roomId The room ID to dispose of the recording in. * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to dispose of the recording in.
* @returns {Promise<void>} Resolves when complete. * @returns {Promise<void>} Resolves when complete.
*/ */
public disposeRecording(roomId: string): Promise<void> { public disposeRecording(voiceRecordingId: string): Promise<void> {
if (this.state[roomId]) { this.state[voiceRecordingId]?.destroy(); // stops internally
this.state[roomId].destroy(); // stops internally
}
const { const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
[roomId]: _toDelete, [voiceRecordingId]: _toDelete,
...newState ...newState
} = this.state; } = this.state;
// unexpectedly AsyncStore.updateState merges state // unexpectedly AsyncStore.updateState merges state