diff --git a/src/audio/VoiceMessageRecording.ts b/src/audio/VoiceMessageRecording.ts new file mode 100644 index 0000000000..39951ff278 --- /dev/null +++ b/src/audio/VoiceMessageRecording.ts @@ -0,0 +1,166 @@ +/* +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 { IEncryptedFile, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { SimpleObservable } from "matrix-widget-api"; + +import { uploadFile } from "../ContentMessages"; +import { IDestroyable } from "../utils/IDestroyable"; +import { Singleflight } from "../utils/Singleflight"; +import { Playback } from "./Playback"; +import { IRecordingUpdate, RecordingState, VoiceRecording } from "./VoiceRecording"; + +export interface IUpload { + mxc?: string; // for unencrypted uploads + encrypted?: IEncryptedFile; +} + +/** + * This class can be used to record a single voice message. + */ +export class VoiceMessageRecording implements IDestroyable { + private lastUpload: IUpload; + private buffer = new Uint8Array(0); // use this.audioBuffer to access + private playback: Playback; + + public constructor( + private matrixClient: MatrixClient, + private voiceRecording: VoiceRecording, + ) { + this.voiceRecording.onDataAvailable = this.onDataAvailable; + } + + public async start(): Promise { + if (this.lastUpload || this.hasRecording) { + throw new Error("Recording already prepared"); + } + + return this.voiceRecording.start(); + } + + public async stop(): Promise { + await this.voiceRecording.stop(); + return this.audioBuffer; + } + + public on(event: string | symbol, listener: (...args: any[]) => void): this { + this.voiceRecording.on(event, listener); + return this; + } + + public off(event: string | symbol, listener: (...args: any[]) => void): this { + this.voiceRecording.off(event, listener); + return this; + } + + public emit(event: string, ...args: any[]): boolean { + return this.voiceRecording.emit(event, ...args); + } + + public get hasRecording(): boolean { + return this.buffer.length > 0; + } + + public get isRecording(): boolean { + return this.voiceRecording.isRecording; + } + + /** + * Gets a playback instance for this voice recording. Note that the playback will not + * have been prepared fully, meaning the `prepare()` function needs to be called on it. + * + * The same playback instance is returned each time. + * + * @returns {Playback} The playback instance. + */ + public getPlayback(): Playback { + this.playback = Singleflight.for(this, "playback").do(() => { + return new Playback(this.audioBuffer.buffer, this.voiceRecording.amplitudes); // cast to ArrayBuffer proper; + }); + return this.playback; + } + + public async upload(inRoomId: string): Promise { + if (!this.hasRecording) { + throw new Error("No recording available to upload"); + } + + if (this.lastUpload) return this.lastUpload; + + try { + this.emit(RecordingState.Uploading); + const { url: mxc, file: encrypted } = await uploadFile( + this.matrixClient, + inRoomId, + new Blob( + [this.audioBuffer], + { + type: this.contentType, + }, + ), + ); + this.lastUpload = { mxc, encrypted }; + this.emit(RecordingState.Uploaded); + } catch (e) { + this.emit(RecordingState.Ended); + throw e; + } + return this.lastUpload; + } + + public get durationSeconds(): number { + return this.voiceRecording.durationSeconds; + } + + public get contentType(): string { + return this.voiceRecording.contentType; + } + + public get contentLength(): number { + return this.buffer.length; + } + + public get liveData(): SimpleObservable { + return this.voiceRecording.liveData; + } + + public get isSupported(): boolean { + return this.voiceRecording.isSupported; + } + + destroy(): void { + this.playback?.destroy(); + this.voiceRecording.destroy(); + } + + private onDataAvailable = (data: ArrayBuffer) => { + const buf = new Uint8Array(data); + const newBuf = new Uint8Array(this.buffer.length + buf.length); + newBuf.set(this.buffer, 0); + newBuf.set(buf, this.buffer.length); + this.buffer = newBuf; + }; + + private get audioBuffer(): Uint8Array { + // We need a clone of the buffer to avoid accidentally changing the position + // on the real thing. + return this.buffer.slice(0); + } +} + +export const createVoiceMessageRecording = (matrixClient: MatrixClient) => { + return new VoiceMessageRecording(matrixClient, new VoiceRecording()); +}; diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index d0b34493d8..e98e85aba5 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -16,10 +16,8 @@ limitations under the License. import * as Recorder from 'opus-recorder'; import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; -import { MatrixClient } from "matrix-js-sdk/src/client"; import { SimpleObservable } from "matrix-widget-api"; import EventEmitter from "events"; -import { IEncryptedFile } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; import MediaDeviceHandler from "../MediaDeviceHandler"; @@ -27,9 +25,7 @@ import { IDestroyable } from "../utils/IDestroyable"; import { Singleflight } from "../utils/Singleflight"; import { PayloadEvent, WORKLET_NAME } from "./consts"; import { UPDATE_EVENT } from "../stores/AsyncStore"; -import { Playback } from "./Playback"; import { createAudioContext } from "./compat"; -import { uploadFile } from "../ContentMessages"; import { FixedRollingArray } from "../utils/FixedRollingArray"; import { clamp } from "../utils/numbers"; import mxRecorderWorkletPath from "./RecorderWorklet"; @@ -55,11 +51,6 @@ export enum RecordingState { Uploaded = "uploaded", } -export interface IUpload { - mxc?: string; // for unencrypted uploads - encrypted?: IEncryptedFile; -} - export class VoiceRecording extends EventEmitter implements IDestroyable { private recorder: Recorder; private recorderContext: AudioContext; @@ -67,26 +58,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recorderStream: MediaStream; private recorderWorklet: AudioWorkletNode; private recorderProcessor: ScriptProcessorNode; - private buffer = new Uint8Array(0); // use this.audioBuffer to access - private lastUpload: IUpload; private recording = false; private observable: SimpleObservable; - private amplitudes: number[] = []; // at each second mark, generated - private playback: Playback; + public amplitudes: number[] = []; // at each second mark, generated private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0); - - public constructor(private client: MatrixClient) { - super(); - } + public onDataAvailable: (data: ArrayBuffer) => void; public get contentType(): string { return "audio/ogg"; } - public get contentLength(): number { - return this.buffer.length; - } - public get durationSeconds(): number { if (!this.recorder) throw new Error("Duration not available without a recording"); return this.recorderContext.currentTime; @@ -165,13 +146,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { encoderComplexity: 3, // 0-10, 10 is slow and high quality. resampleQuality: 3, // 0-10, 10 is slow and high quality }); - this.recorder.ondataavailable = (a: ArrayBuffer) => { - const buf = new Uint8Array(a); - const newBuf = new Uint8Array(this.buffer.length + buf.length); - newBuf.set(this.buffer, 0); - newBuf.set(buf, this.buffer.length); - this.buffer = newBuf; - }; + + // not using EventEmitter here because it leads to detached bufferes + this.recorder.ondataavailable = (data: ArrayBuffer) => this?.onDataAvailable(data); } catch (e) { logger.error("Error starting recording: ", e); if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely @@ -191,12 +168,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } } - private get audioBuffer(): Uint8Array { - // We need a clone of the buffer to avoid accidentally changing the position - // on the real thing. - return this.buffer.slice(0); - } - public get liveData(): SimpleObservable { if (!this.recording) throw new Error("No observable when not recording"); return this.observable; @@ -206,10 +177,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return !!Recorder.isRecordingSupported(); } - public get hasRecording(): boolean { - return this.buffer.length > 0; - } - private onAudioProcess = (ev: AudioProcessingEvent) => { this.processAudioUpdate(ev.playbackTime); @@ -251,9 +218,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { }; public async start(): Promise { - if (this.lastUpload || this.hasRecording) { - throw new Error("Recording already prepared"); - } if (this.recording) { throw new Error("Recording already in progress"); } @@ -267,7 +231,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.emit(RecordingState.Started); } - public async stop(): Promise { + public async stop(): Promise { return Singleflight.for(this, "stop").do(async () => { if (!this.recording) { throw new Error("No recording to stop"); @@ -293,54 +257,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.recording = false; await this.recorder.close(); this.emit(RecordingState.Ended); - - return this.audioBuffer; }); } - /** - * Gets a playback instance for this voice recording. Note that the playback will not - * have been prepared fully, meaning the `prepare()` function needs to be called on it. - * - * The same playback instance is returned each time. - * - * @returns {Playback} The playback instance. - */ - public getPlayback(): Playback { - this.playback = Singleflight.for(this, "playback").do(() => { - return new Playback(this.audioBuffer.buffer, this.amplitudes); // cast to ArrayBuffer proper; - }); - return this.playback; - } - public destroy() { // noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here this.stop(); this.removeAllListeners(); + this.onDataAvailable = undefined; Singleflight.forgetAllFor(this); // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here - this.playback?.destroy(); this.observable.close(); } - - public async upload(inRoomId: string): Promise { - if (!this.hasRecording) { - throw new Error("No recording available to upload"); - } - - if (this.lastUpload) return this.lastUpload; - - try { - this.emit(RecordingState.Uploading); - const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], { - type: this.contentType, - })); - this.lastUpload = { mxc, encrypted }; - this.emit(RecordingState.Uploaded); - } catch (e) { - this.emit(RecordingState.Ended); - throw e; - } - return this.lastUpload; - } } diff --git a/src/components/views/audio_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx index 34e4c559fe..10005d8b9a 100644 --- a/src/components/views/audio_messages/LiveRecordingClock.tsx +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -16,12 +16,13 @@ limitations under the License. import React from "react"; -import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording"; +import { IRecordingUpdate } from "../../../audio/VoiceRecording"; import Clock from "./Clock"; import { MarkedExecution } from "../../../utils/MarkedExecution"; +import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; interface IProps { - recorder: VoiceRecording; + recorder: VoiceMessageRecording; } interface IState { diff --git a/src/components/views/audio_messages/LiveRecordingWaveform.tsx b/src/components/views/audio_messages/LiveRecordingWaveform.tsx index c9c122c98a..3a546c2a6d 100644 --- a/src/components/views/audio_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/audio_messages/LiveRecordingWaveform.tsx @@ -16,13 +16,14 @@ limitations under the License. import React from "react"; -import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording"; +import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES } from "../../../audio/VoiceRecording"; import { arrayFastResample, arraySeed } from "../../../utils/arrays"; import Waveform from "./Waveform"; import { MarkedExecution } from "../../../utils/MarkedExecution"; +import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; interface IProps { - recorder: VoiceRecording; + recorder: VoiceMessageRecording; } interface IState { diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 3f75e9f16d..f9aaf21105 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -37,7 +37,7 @@ import ReplyPreview from "./ReplyPreview"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; -import { RecordingState, VoiceRecording } from "../../../audio/VoiceRecording"; +import { RecordingState } from "../../../audio/VoiceRecording"; import Tooltip, { Alignment } from "../elements/Tooltip"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import { E2EStatus } from '../../../utils/ShieldUtils'; @@ -53,6 +53,7 @@ import { ButtonEvent } from '../elements/AccessibleButton'; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { Features } from '../../../settings/Settings'; +import { VoiceMessageRecording } from '../../../audio/VoiceMessageRecording'; let instanceCount = 0; @@ -101,7 +102,7 @@ export default class MessageComposer extends React.Component { private ref: React.RefObject = createRef(); private instanceId: number; - private _voiceRecording: Optional; + private _voiceRecording: Optional; public static contextType = RoomContext; public context!: React.ContextType; @@ -133,11 +134,11 @@ export default class MessageComposer extends React.Component { SettingsStore.monitorSetting(Features.VoiceBroadcast, null); } - private get voiceRecording(): Optional { + private get voiceRecording(): Optional { return this._voiceRecording; } - private set voiceRecording(rec: Optional) { + private set voiceRecording(rec: Optional) { if (this._voiceRecording) { this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted); this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon); diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index b25d87a0ce..782cda9f4c 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -23,7 +23,7 @@ import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { _t } from "../../../languageHandler"; -import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording"; +import { RecordingState } from "../../../audio/VoiceRecording"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform"; import LiveRecordingClock from "../audio_messages/LiveRecordingClock"; @@ -44,6 +44,7 @@ import { attachRelation } from "./SendMessageComposer"; import { addReplyToMessageContent } from "../../../utils/Reply"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import RoomContext from "../../../contexts/RoomContext"; +import { IUpload, VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; interface IProps { room: Room; @@ -53,7 +54,7 @@ interface IProps { } interface IState { - recorder?: VoiceRecording; + recorder?: VoiceMessageRecording; recordingPhase?: RecordingState; didUploadFail?: boolean; } @@ -250,7 +251,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent) { + private bindNewRecorder(recorder: Optional) { if (this.state.recorder) { this.state.recorder.off(UPDATE_EVENT, this.onRecordingUpdate); } diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts index 29af171f6e..ed2b480255 100644 --- a/src/stores/VoiceRecordingStore.ts +++ b/src/stores/VoiceRecordingStore.ts @@ -22,12 +22,12 @@ import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; -import { VoiceRecording } from "../audio/VoiceRecording"; +import { createVoiceMessageRecording, VoiceMessageRecording } from "../audio/VoiceMessageRecording"; const SEPARATOR = "|"; interface IState { - [voiceRecordingId: string]: Optional; + [voiceRecordingId: string]: Optional; } export class VoiceRecordingStore extends AsyncStoreWithClient { @@ -63,7 +63,7 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to get the recording in. * @returns {Optional} The recording, if any. */ - public getActiveRecording(voiceRecordingId: string): Optional { + public getActiveRecording(voiceRecordingId: string): Optional { return this.state[voiceRecordingId]; } @@ -74,12 +74,12 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to start recording in. * @returns {VoiceRecording} The recording. */ - public startRecording(voiceRecordingId: string): VoiceRecording { + public startRecording(voiceRecordingId: string): VoiceMessageRecording { if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient"); if (!voiceRecordingId) throw new Error("Recording must be associated with a room"); if (this.state[voiceRecordingId]) throw new Error("A recording is already in progress"); - const recording = new VoiceRecording(this.matrixClient); + const recording = createVoiceMessageRecording(this.matrixClient); // noinspection JSIgnoredPromiseFromCall - we can safely run this async this.updateState({ ...this.state, [voiceRecordingId]: recording }); diff --git a/test/audio/VoiceMessageRecording-test.ts b/test/audio/VoiceMessageRecording-test.ts new file mode 100644 index 0000000000..5114045c47 --- /dev/null +++ b/test/audio/VoiceMessageRecording-test.ts @@ -0,0 +1,221 @@ +/* +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 { IAbortablePromise, IEncryptedFile, IUploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { createVoiceMessageRecording, VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording"; +import { RecordingState, VoiceRecording } from "../../src/audio/VoiceRecording"; +import { uploadFile } from "../../src/ContentMessages"; +import { stubClient } from "../test-utils"; +import { Playback } from "../../src/audio/Playback"; + +jest.mock("../../src/ContentMessages", () => ({ + uploadFile: jest.fn(), +})); + +jest.mock("../../src/audio/Playback", () => ({ + Playback: jest.fn(), +})); + +describe("VoiceMessageRecording", () => { + const roomId = "!room:example.com"; + const contentType = "test content type"; + const durationSeconds = 23; + const testBuf = new Uint8Array([1, 2, 3]); + const testAmplitudes = [4, 5, 6]; + + let voiceRecording: VoiceRecording; + let voiceMessageRecording: VoiceMessageRecording; + let client: MatrixClient; + + beforeEach(() => { + client = stubClient(); + voiceRecording = { + contentType, + durationSeconds, + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + isRecording: true, + isSupported: true, + liveData: jest.fn(), + amplitudes: testAmplitudes, + } as unknown as VoiceRecording; + voiceMessageRecording = new VoiceMessageRecording( + client, + voiceRecording, + ); + }); + + it("hasRecording should return false", () => { + expect(voiceMessageRecording.hasRecording).toBe(false); + }); + + it("createVoiceMessageRecording should return a VoiceMessageRecording", () => { + expect(createVoiceMessageRecording(client)).toBeInstanceOf(VoiceMessageRecording); + }); + + it("durationSeconds should return the VoiceRecording value", () => { + expect(voiceMessageRecording.durationSeconds).toBe(durationSeconds); + }); + + it("contentType should return the VoiceRecording value", () => { + expect(voiceMessageRecording.contentType).toBe(contentType); + }); + + it.each([true, false])("isRecording should return %s from VoiceRecording", (value: boolean) => { + // @ts-ignore + voiceRecording.isRecording = value; + expect(voiceMessageRecording.isRecording).toBe(value); + }); + + it.each([true, false])("isSupported should return %s from VoiceRecording", (value: boolean) => { + // @ts-ignore + voiceRecording.isSupported = value; + expect(voiceMessageRecording.isSupported).toBe(value); + }); + + it("should return liveData from VoiceRecording", () => { + expect(voiceMessageRecording.liveData).toBe(voiceRecording.liveData); + }); + + it("start should forward the call to VoiceRecording.start", async () => { + await voiceMessageRecording.start(); + expect(voiceRecording.start).toHaveBeenCalled(); + }); + + it("on should forward the call to VoiceRecording", () => { + const callback = () => {}; + const result = voiceMessageRecording.on("test on", callback); + expect(voiceRecording.on).toHaveBeenCalledWith("test on", callback); + expect(result).toBe(voiceMessageRecording); + }); + + it("off should forward the call to VoiceRecording", () => { + const callback = () => {}; + const result = voiceMessageRecording.off("test off", callback); + expect(voiceRecording.off).toHaveBeenCalledWith("test off", callback); + expect(result).toBe(voiceMessageRecording); + }); + + it("emit should forward the call to VoiceRecording", () => { + voiceMessageRecording.emit("test emit", 42); + expect(voiceRecording.emit).toHaveBeenCalledWith("test emit", 42); + }); + + it("upload should raise an error", async () => { + await expect(voiceMessageRecording.upload(roomId)) + .rejects + .toThrow("No recording available to upload"); + }); + + describe("when the first data has been received", () => { + const uploadUrl = "https://example.com/content123"; + const encryptedFile = {} as unknown as IEncryptedFile; + + beforeEach(() => { + voiceRecording.onDataAvailable(testBuf); + }); + + it("contentLength should return the buffer length", () => { + expect(voiceMessageRecording.contentLength).toBe(testBuf.length); + }); + + it("stop should return a copy of the data buffer", async () => { + const result = await voiceMessageRecording.stop(); + expect(voiceRecording.stop).toHaveBeenCalled(); + expect(result).toEqual(testBuf); + }); + + it("hasRecording should return true", () => { + expect(voiceMessageRecording.hasRecording).toBe(true); + }); + + describe("upload", () => { + let uploadFileClient: MatrixClient; + let uploadFileRoomId: string; + let uploadBlob: Blob; + + beforeEach(() => { + uploadFileClient = null; + uploadFileRoomId = null; + uploadBlob = null; + + mocked(uploadFile).mockImplementation(( + matrixClient: MatrixClient, + roomId: string, + file: File | Blob, + _progressHandler?: IUploadOpts["progressHandler"], + ): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> => { + uploadFileClient = matrixClient; + uploadFileRoomId = roomId; + uploadBlob = file; + // @ts-ignore + return Promise.resolve({ + url: uploadUrl, + file: encryptedFile, + }); + }); + }); + + it("should upload the file and trigger the upload events", async () => { + const result = await voiceMessageRecording.upload(roomId); + expect(voiceRecording.emit).toHaveBeenNthCalledWith(1, RecordingState.Uploading); + expect(voiceRecording.emit).toHaveBeenNthCalledWith(2, RecordingState.Uploaded); + + expect(result.mxc).toBe(uploadUrl); + expect(result.encrypted).toBe(encryptedFile); + + expect(mocked(uploadFile)).toHaveBeenCalled(); + expect(uploadFileClient).toBe(client); + expect(uploadFileRoomId).toBe(roomId); + expect(uploadBlob.type).toBe(contentType); + const blobArray = await uploadBlob.arrayBuffer(); + expect(new Uint8Array(blobArray)).toEqual(testBuf); + }); + + it("should reuse the result", async () => { + const result1 = await voiceMessageRecording.upload(roomId); + const result2 = await voiceMessageRecording.upload(roomId); + expect(result1).toBe(result2); + }); + }); + + describe("getPlayback", () => { + beforeEach(() => { + mocked(Playback).mockImplementation((buf: ArrayBuffer, seedWaveform) => { + expect(new Uint8Array(buf)).toEqual(testBuf); + expect(seedWaveform).toEqual(testAmplitudes); + return {} as Playback; + }); + }); + + it("should return a Playback with the data", () => { + voiceMessageRecording.getPlayback(); + expect(mocked(Playback)).toHaveBeenCalled(); + }); + + it("should reuse the result", () => { + const playback1 = voiceMessageRecording.getPlayback(); + const playback2 = voiceMessageRecording.getPlayback(); + expect(playback1).toBe(playback2); + }); + }); + }); +}); diff --git a/test/components/views/rooms/VoiceRecordComposerTile-test.tsx b/test/components/views/rooms/VoiceRecordComposerTile-test.tsx index 88cd97a2d5..77df519a8d 100644 --- a/test/components/views/rooms/VoiceRecordComposerTile-test.tsx +++ b/test/components/views/rooms/VoiceRecordComposerTile-test.tsx @@ -21,9 +21,10 @@ import { ISendEventResponse, MatrixClient, MsgType, Room } from "matrix-js-sdk/s import { mocked } from "jest-mock"; import VoiceRecordComposerTile from "../../../../src/components/views/rooms/VoiceRecordComposerTile"; -import { IUpload, VoiceRecording } from "../../../../src/audio/VoiceRecording"; +import { VoiceRecording } from "../../../../src/audio/VoiceRecording"; import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { IUpload } from "../../../../src/audio/VoiceMessageRecording"; jest.mock("../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), diff --git a/test/stores/VoiceRecordingStore-test.ts b/test/stores/VoiceRecordingStore-test.ts index 150307348a..c675d8cc1a 100644 --- a/test/stores/VoiceRecordingStore-test.ts +++ b/test/stores/VoiceRecordingStore-test.ts @@ -17,10 +17,10 @@ limitations under the License. import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { VoiceRecording } from '../../src/audio/VoiceRecording'; import { VoiceRecordingStore } from '../../src/stores/VoiceRecordingStore'; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { flushPromises } from "../test-utils"; +import { VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording"; const stubClient = {} as undefined as MatrixClient; jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(stubClient); @@ -29,8 +29,8 @@ describe('VoiceRecordingStore', () => { const room1Id = '!room1:server.org'; const room2Id = '!room2:server.org'; const room3Id = '!room3:server.org'; - const room1Recording = { destroy: jest.fn() } as unknown as VoiceRecording; - const room2Recording = { destroy: jest.fn() } as unknown as VoiceRecording; + const room1Recording = { destroy: jest.fn() } as unknown as VoiceMessageRecording; + const room2Recording = { destroy: jest.fn() } as unknown as VoiceMessageRecording; const state = { [room1Id]: room1Recording, @@ -63,7 +63,7 @@ describe('VoiceRecordingStore', () => { await flushPromises(); - expect(result).toBeInstanceOf(VoiceRecording); + expect(result).toBeInstanceOf(VoiceMessageRecording); expect(store.getActiveRecording(room2Id)).toEqual(result); }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 029c88df8f..2022d73a9c 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -49,7 +49,7 @@ import MatrixClientBackedSettingsHandler from "../../src/settings/handlers/Matri * the react context, we can get rid of this and just inject a test client * via the context instead. */ -export function stubClient() { +export function stubClient(): MatrixClient { const client = createTestClient(); // stub out the methods in MatrixClientPeg @@ -63,6 +63,7 @@ export function stubClient() { // fast stub function rather than a sinon stub peg.get = function() { return client; }; MatrixClientBackedSettingsHandler.matrixClient = client; + return client; } /**