Generalise VoiceRecording (#9304)

This commit is contained in:
Michael Weimann 2022-09-21 18:46:28 +02:00 committed by GitHub
parent 71cf9bf932
commit c182c1c706
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 422 additions and 103 deletions

View file

@ -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<void> {
if (this.lastUpload || this.hasRecording) {
throw new Error("Recording already prepared");
}
return this.voiceRecording.start();
}
public async stop(): Promise<Uint8Array> {
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<IUpload> {
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<IRecordingUpdate> {
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());
};

View file

@ -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<IRecordingUpdate>;
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<IRecordingUpdate> {
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<void> {
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<Uint8Array> {
public async stop(): Promise<void> {
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<IUpload> {
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;
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number;
private _voiceRecording: Optional<VoiceRecording>;
private _voiceRecording: Optional<VoiceMessageRecording>;
public static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
@ -133,11 +134,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
SettingsStore.monitorSetting(Features.VoiceBroadcast, null);
}
private get voiceRecording(): Optional<VoiceRecording> {
private get voiceRecording(): Optional<VoiceMessageRecording> {
return this._voiceRecording;
}
private set voiceRecording(rec: Optional<VoiceRecording>) {
private set voiceRecording(rec: Optional<VoiceMessageRecording>) {
if (this._voiceRecording) {
this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted);
this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon);

View file

@ -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<IProps,
}
};
private bindNewRecorder(recorder: Optional<VoiceRecording>) {
private bindNewRecorder(recorder: Optional<VoiceMessageRecording>) {
if (this.state.recorder) {
this.state.recorder.off(UPDATE_EVENT, this.onRecordingUpdate);
}

View file

@ -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<VoiceRecording>;
[voiceRecordingId: string]: Optional<VoiceMessageRecording>;
}
export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
@ -63,7 +63,7 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
* @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.
*/
public getActiveRecording(voiceRecordingId: string): Optional<VoiceRecording> {
public getActiveRecording(voiceRecordingId: string): Optional<VoiceMessageRecording> {
return this.state[voiceRecordingId];
}
@ -74,12 +74,12 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
* @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 });

View file

@ -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);
});
});
});
});

View file

@ -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(),

View file

@ -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);
});
});

View file

@ -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;
}
/**