/* 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 { EventTimelineSet, EventType, MatrixClient, MatrixEvent, MsgType, RelationType, Room, } from "matrix-js-sdk/src/matrix"; import { Relations } from "matrix-js-sdk/src/models/relations"; import { uploadFile } from "../../../src/ContentMessages"; import { IEncryptedFile } from "../../../src/customisations/models/IMediaEventContent"; import { createVoiceMessageContent } from "../../../src/utils/createVoiceMessageContent"; import { ChunkRecordedPayload, createVoiceBroadcastRecorder, VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState, VoiceBroadcastRecorder, VoiceBroadcastRecorderEvent, VoiceBroadcastRecording, VoiceBroadcastRecordingEvent, } from "../../../src/voice-broadcast"; import { mkEvent, mkStubRoom, stubClient } from "../../test-utils"; jest.mock("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder", () => ({ ...jest.requireActual("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder") as object, createVoiceBroadcastRecorder: jest.fn(), })); jest.mock("../../../src/ContentMessages", () => ({ uploadFile: jest.fn(), })); jest.mock("../../../src/utils/createVoiceMessageContent", () => ({ createVoiceMessageContent: jest.fn(), })); describe("VoiceBroadcastRecording", () => { const roomId = "!room:example.com"; const uploadedUrl = "mxc://example.com/vb"; const uploadedFile = { file: true } as unknown as IEncryptedFile; let room: Room; let client: MatrixClient; let infoEvent: MatrixEvent; let voiceBroadcastRecording: VoiceBroadcastRecording; let onStateChanged: (state: VoiceBroadcastInfoState) => void; let voiceBroadcastRecorder: VoiceBroadcastRecorder; let onChunkRecorded: (chunk: ChunkRecordedPayload) => Promise; const mkVoiceBroadcastInfoEvent = (content: VoiceBroadcastInfoEventContent) => { return mkEvent({ event: true, type: VoiceBroadcastInfoEventType, user: client.getUserId(), room: roomId, content, }); }; const setUpVoiceBroadcastRecording = () => { voiceBroadcastRecording = new VoiceBroadcastRecording(infoEvent, client); voiceBroadcastRecording.on(VoiceBroadcastRecordingEvent.StateChanged, onStateChanged); jest.spyOn(voiceBroadcastRecording, "removeAllListeners"); }; beforeEach(() => { client = stubClient(); room = mkStubRoom(roomId, "Test Room", client); mocked(client.getRoom).mockImplementation((getRoomId: string) => { if (getRoomId === roomId) { return room; } }); onStateChanged = jest.fn(); voiceBroadcastRecorder = { contentType: "audio/ogg", on: jest.fn(), off: jest.fn(), start: jest.fn(), stop: jest.fn(), } as unknown as VoiceBroadcastRecorder; mocked(createVoiceBroadcastRecorder).mockReturnValue(voiceBroadcastRecorder); onChunkRecorded = jest.fn(); mocked(voiceBroadcastRecorder.on).mockImplementation( (event: VoiceBroadcastRecorderEvent, listener: any): VoiceBroadcastRecorder => { if (event === VoiceBroadcastRecorderEvent.ChunkRecorded) { onChunkRecorded = listener; } return voiceBroadcastRecorder; }, ); mocked(uploadFile).mockResolvedValue({ url: uploadedUrl, file: uploadedFile, }); mocked(createVoiceMessageContent).mockImplementation(( mxc: string, mimetype: string, duration: number, size: number, file?: IEncryptedFile, waveform?: number[], ) => { return { body: "Voice message", msgtype: MsgType.Audio, url: mxc, file, info: { duration, mimetype, size, }, ["org.matrix.msc1767.text"]: "Voice message", ["org.matrix.msc1767.file"]: { url: mxc, file, name: "Voice message.ogg", mimetype, size, }, ["org.matrix.msc1767.audio"]: { duration, // https://github.com/matrix-org/matrix-doc/pull/3246 waveform, }, ["org.matrix.msc3245.voice"]: {}, // No content, this is a rendering hint }; }); }); afterEach(() => { voiceBroadcastRecording.off(VoiceBroadcastRecordingEvent.StateChanged, onStateChanged); }); describe("when created for a Voice Broadcast Info without relations", () => { beforeEach(() => { infoEvent = mkVoiceBroadcastInfoEvent({ device_id: client.getDeviceId(), state: VoiceBroadcastInfoState.Started, }); setUpVoiceBroadcastRecording(); }); it("should be in Started state", () => { expect(voiceBroadcastRecording.getState()).toBe(VoiceBroadcastInfoState.Started); }); describe("and calling stop()", () => { beforeEach(() => { voiceBroadcastRecording.stop(); }); it("should send a stopped Voice Broadcast Info event", () => { expect(mocked(client.sendStateEvent)).toHaveBeenCalledWith( roomId, VoiceBroadcastInfoEventType, { device_id: client.getDeviceId(), state: VoiceBroadcastInfoState.Stopped, ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: infoEvent.getId(), }, }, client.getUserId(), ); }); it("should be in state stopped", () => { expect(voiceBroadcastRecording.getState()).toBe(VoiceBroadcastInfoState.Stopped); }); it("should emit a stopped state changed event", () => { expect(onStateChanged).toHaveBeenCalledWith(VoiceBroadcastInfoState.Stopped); }); }); describe("and calling start", () => { beforeEach(async () => { await voiceBroadcastRecording.start(); }); it("should start the recorder", () => { expect(voiceBroadcastRecorder.start).toHaveBeenCalled(); }); describe("and a chunk has been recorded", () => { beforeEach(async () => { await onChunkRecorded({ buffer: new Uint8Array([1, 2, 3]), length: 23, }); }); it("should send a voice message", () => { expect(uploadFile).toHaveBeenCalledWith( client, roomId, new Blob([new Uint8Array([1, 2, 3])], { type: voiceBroadcastRecorder.contentType }), ); expect(mocked(client.sendMessage)).toHaveBeenCalledWith( roomId, { body: "Voice message", file: { file: true, }, info: { duration: 23000, mimetype: "audio/ogg", size: 3, }, ["m.relates_to"]: { event_id: infoEvent.getId(), rel_type: "m.reference", }, msgtype: "m.audio", ["org.matrix.msc1767.audio"]: { duration: 23000, waveform: undefined, }, ["org.matrix.msc1767.file"]: { file: { file: true, }, mimetype: "audio/ogg", name: "Voice message.ogg", size: 3, url: "mxc://example.com/vb", }, ["org.matrix.msc1767.text"]: "Voice message", ["org.matrix.msc3245.voice"]: {}, url: "mxc://example.com/vb", ["io.element.voice_broadcast_chunk"]: { sequence: 1, }, }, ); }); }); describe("and calling stop", () => { beforeEach(async () => { await onChunkRecorded({ buffer: new Uint8Array([1, 2, 3]), length: 23, }); mocked(voiceBroadcastRecorder.stop).mockResolvedValue({ buffer: new Uint8Array([4, 5, 6]), length: 42, }); await voiceBroadcastRecording.stop(); }); it("should send the last chunk", () => { expect(uploadFile).toHaveBeenCalledWith( client, roomId, new Blob([new Uint8Array([4, 5, 6])], { type: voiceBroadcastRecorder.contentType }), ); expect(mocked(client.sendMessage)).toHaveBeenCalledWith( roomId, { body: "Voice message", file: { file: true, }, info: { duration: 42000, mimetype: "audio/ogg", size: 3, }, ["m.relates_to"]: { event_id: infoEvent.getId(), rel_type: "m.reference", }, msgtype: "m.audio", ["org.matrix.msc1767.audio"]: { duration: 42000, waveform: undefined, }, ["org.matrix.msc1767.file"]: { file: { file: true, }, mimetype: "audio/ogg", name: "Voice message.ogg", size: 3, url: "mxc://example.com/vb", }, ["org.matrix.msc1767.text"]: "Voice message", ["org.matrix.msc3245.voice"]: {}, url: "mxc://example.com/vb", ["io.element.voice_broadcast_chunk"]: { sequence: 2, }, }, ); }); }); describe("and calling destroy", () => { beforeEach(() => { voiceBroadcastRecording.destroy(); }); it("should stop the recorder and remove all listeners", () => { expect(mocked(voiceBroadcastRecorder.stop)).toHaveBeenCalled(); expect(mocked(voiceBroadcastRecorder.off)).toHaveBeenCalledWith( VoiceBroadcastRecorderEvent.ChunkRecorded, onChunkRecorded, ); expect(mocked(voiceBroadcastRecording.removeAllListeners)).toHaveBeenCalled(); }); }); }); }); describe("when created for a Voice Broadcast Info with a Stopped relation", () => { beforeEach(() => { infoEvent = mkVoiceBroadcastInfoEvent({ device_id: client.getDeviceId(), state: VoiceBroadcastInfoState.Started, chunk_length: 300, }); const relationsContainer = { getRelations: jest.fn(), } as unknown as Relations; mocked(relationsContainer.getRelations).mockReturnValue([ mkVoiceBroadcastInfoEvent({ device_id: client.getDeviceId(), state: VoiceBroadcastInfoState.Stopped, ["m.relates_to"]: { rel_type: RelationType.Reference, event_id: infoEvent.getId(), }, }), ]); const timelineSet = { relations: { getChildEventsForEvent: jest.fn().mockImplementation( ( eventId: string, relationType: RelationType | string, eventType: EventType | string, ) => { if ( eventId === infoEvent.getId() && relationType === RelationType.Reference && eventType === VoiceBroadcastInfoEventType ) { return relationsContainer; } }, ), }, } as unknown as EventTimelineSet; mocked(room.getUnfilteredTimelineSet).mockReturnValue(timelineSet); setUpVoiceBroadcastRecording(); }); it("should be in Stopped state", () => { expect(voiceBroadcastRecording.getState()).toBe(VoiceBroadcastInfoState.Stopped); }); }); });