/* 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 { IImageInfo, ISendEventResponse, MatrixClient, RelationType, UploadResponse } from "matrix-js-sdk/src/matrix"; import { defer } from "matrix-js-sdk/src/utils"; import encrypt, { IEncryptedFile } from "matrix-encrypt-attachment"; import ContentMessages, { UploadCanceledError, uploadFile } from "../src/ContentMessages"; import { doMaybeLocalRoomAction } from "../src/utils/local-room"; import { createTestClient, mkEvent } from "./test-utils"; import { BlurhashEncoder } from "../src/BlurhashEncoder"; import SettingsStore from "../src/settings/SettingsStore"; jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) })); jest.mock("../src/BlurhashEncoder", () => ({ BlurhashEncoder: { instance: { getBlurhash: jest.fn(), }, }, })); jest.mock("../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), })); const createElement = document.createElement.bind(document); describe("ContentMessages", () => { const stickerUrl = "https://example.com/sticker"; const roomId = "!room:example.com"; const imageInfo = {} as unknown as IImageInfo; const text = "test sticker"; let client: MatrixClient; let contentMessages: ContentMessages; let prom: Promise; beforeEach(() => { client = { getSafeUserId: jest.fn().mockReturnValue("@alice:test"), sendStickerMessage: jest.fn(), sendMessage: jest.fn(), isRoomEncrypted: jest.fn().mockReturnValue(false), uploadContent: jest.fn().mockResolvedValue({ content_uri: "mxc://server/file" }), } as unknown as MatrixClient; contentMessages = new ContentMessages(); prom = Promise.resolve({ event_id: "$event_id" }); }); describe("sendStickerContentToRoom", () => { beforeEach(() => { mocked(client.sendStickerMessage).mockReturnValue(prom); mocked(doMaybeLocalRoomAction).mockImplementation( (roomId: string, fn: (actualRoomId: string) => Promise, client?: MatrixClient) => { return fn(roomId); }, ); }); it("should forward the call to doMaybeLocalRoomAction", async () => { await contentMessages.sendStickerContentToRoom(stickerUrl, roomId, null, imageInfo, text, client); expect(client.sendStickerMessage).toHaveBeenCalledWith(roomId, null, stickerUrl, imageInfo, text); }); }); describe("sendContentToRoom", () => { const roomId = "!roomId:server"; beforeEach(() => { Object.defineProperty(global.Image.prototype, "src", { // Define the property setter set(src) { window.setTimeout(() => this.onload()); }, }); Object.defineProperty(global.Image.prototype, "height", { get() { return 600; }, }); Object.defineProperty(global.Image.prototype, "width", { get() { return 800; }, }); mocked(doMaybeLocalRoomAction).mockImplementation( (roomId: string, fn: (actualRoomId: string) => Promise) => fn(roomId), ); mocked(BlurhashEncoder.instance.getBlurhash).mockResolvedValue("blurhashstring"); }); it("should use m.image for image files", async () => { mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const file = new File([], "fileName", { type: "image/jpeg" }); await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); expect(client.sendMessage).toHaveBeenCalledWith( roomId, null, expect.objectContaining({ url: "mxc://server/file", msgtype: "m.image", }), ); }); it("should use m.image for PNG files which cannot be parsed but successfully thumbnail", async () => { mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const file = new File([], "fileName", { type: "image/png" }); await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); expect(client.sendMessage).toHaveBeenCalledWith( roomId, null, expect.objectContaining({ url: "mxc://server/file", msgtype: "m.image", }), ); }); it("should fall back to m.file for invalid image files", async () => { mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const file = new File([], "fileName", { type: "image/jpeg" }); mocked(BlurhashEncoder.instance.getBlurhash).mockRejectedValue("NOT_AN_IMAGE"); await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); expect(client.sendMessage).toHaveBeenCalledWith( roomId, null, expect.objectContaining({ url: "mxc://server/file", msgtype: "m.file", }), ); }); it("should use m.video for video files", async () => { jest.spyOn(document, "createElement").mockImplementation((tagName) => { const element = createElement(tagName); if (tagName === "video") { (element).load = jest.fn(); (element).play = () => element.onloadeddata!(new Event("loadeddata")); (element).pause = jest.fn(); Object.defineProperty(element, "videoHeight", { get() { return 600; }, }); Object.defineProperty(element, "videoWidth", { get() { return 800; }, }); Object.defineProperty(element, "duration", { get() { return 123; }, }); } return element; }); mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const file = new File([], "fileName", { type: "video/mp4" }); await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); expect(client.sendMessage).toHaveBeenCalledWith( roomId, null, expect.objectContaining({ url: "mxc://server/file", msgtype: "m.video", info: expect.objectContaining({ duration: 123000, }), }), ); }); it("should use m.audio for audio files", async () => { jest.spyOn(document, "createElement").mockImplementation((tagName) => { const element = createElement(tagName); if (tagName === "audio") { Object.defineProperty(element, "duration", { get() { return 621; }, }); Object.defineProperty(element, "src", { set() { element.onloadedmetadata!(new Event("loadedmetadata")); }, }); } return element; }); mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const file = new File([], "fileName", { type: "audio/mp3" }); await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); expect(client.sendMessage).toHaveBeenCalledWith( roomId, null, expect.objectContaining({ url: "mxc://server/file", msgtype: "m.audio", info: expect.objectContaining({ duration: 621000, }), }), ); }); it("should fall back to m.file for invalid audio files", async () => { jest.spyOn(document, "createElement").mockImplementation((tagName) => { const element = createElement(tagName); if (tagName === "audio") { Object.defineProperty(element, "src", { set() { element.onerror!("fail"); }, }); } return element; }); mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const file = new File([], "fileName", { type: "audio/mp3" }); await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); expect(client.sendMessage).toHaveBeenCalledWith( roomId, null, expect.objectContaining({ url: "mxc://server/file", msgtype: "m.file", }), ); }); it("should default to name 'Attachment' if file doesn't have a name", async () => { mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const file = new File([], "", { type: "text/plain" }); await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); expect(client.sendMessage).toHaveBeenCalledWith( roomId, null, expect.objectContaining({ url: "mxc://server/file", msgtype: "m.file", body: "Attachment", }), ); }); it("should keep RoomUpload's total and loaded values up to date", async () => { mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const file = new File([], "", { type: "text/plain" }); const prom = contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); const [upload] = contentMessages.getCurrentUploads(); expect(upload.loaded).toBe(0); expect(upload.total).toBe(file.size); const { progressHandler } = mocked(client.uploadContent).mock.calls[0][1]!; progressHandler!({ loaded: 123, total: 1234 }); expect(upload.loaded).toBe(123); expect(upload.total).toBe(1234); await prom; }); it("properly handles replies", async () => { jest.spyOn(SettingsStore, "getValue").mockImplementation( (settingName) => settingName === "feature_intentional_mentions", ); mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const file = new File([], "fileName", { type: "image/jpeg" }); const replyToEvent = mkEvent({ type: "m.room.message", user: "@bob:test", room: roomId, content: {}, event: true, }); await contentMessages.sendContentToRoom(file, roomId, undefined, client, replyToEvent); expect(client.sendMessage).toHaveBeenCalledWith( roomId, null, expect.objectContaining({ "url": "mxc://server/file", "msgtype": "m.image", "m.mentions": { user_ids: ["@bob:test"], }, }), ); }); }); describe("getCurrentUploads", () => { const file1 = new File([], "file1"); const file2 = new File([], "file2"); const roomId = "!roomId:server"; beforeEach(() => { mocked(doMaybeLocalRoomAction).mockImplementation( (roomId: string, fn: (actualRoomId: string) => Promise) => fn(roomId), ); }); it("should return only uploads for the given relation", async () => { const relation = { rel_type: RelationType.Thread, event_id: "!threadId:server", }; const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined); const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined); const uploads = contentMessages.getCurrentUploads(relation); expect(uploads).toHaveLength(1); expect(uploads[0].relation).toEqual(relation); expect(uploads[0].fileName).toEqual("file1"); await Promise.all([p1, p2]); }); it("should return only uploads for no relation when not passed one", async () => { const relation = { rel_type: RelationType.Thread, event_id: "!threadId:server", }; const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined); const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined); const uploads = contentMessages.getCurrentUploads(); expect(uploads).toHaveLength(1); expect(uploads[0].relation).toEqual(undefined); expect(uploads[0].fileName).toEqual("file2"); await Promise.all([p1, p2]); }); }); describe("cancelUpload", () => { it("should cancel in-flight upload", async () => { const deferred = defer(); mocked(client.uploadContent).mockReturnValue(deferred.promise); const file1 = new File([], "file1"); const prom = contentMessages.sendContentToRoom(file1, roomId, undefined, client, undefined); const { abortController } = mocked(client.uploadContent).mock.calls[0][1]!; expect(abortController!.signal.aborted).toBeFalsy(); const [upload] = contentMessages.getCurrentUploads(); contentMessages.cancelUpload(upload); expect(abortController!.signal.aborted).toBeTruthy(); deferred.resolve({} as UploadResponse); await prom; }); }); }); describe("uploadFile", () => { beforeEach(() => { jest.clearAllMocks(); }); const client = createTestClient(); it("should not encrypt the file if the room isn't encrypted", async () => { mocked(client.isRoomEncrypted).mockReturnValue(false); mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const progressHandler = jest.fn(); const file = new Blob([]); const res = await uploadFile(client, "!roomId:server", file, progressHandler); expect(res.url).toBe("mxc://server/file"); expect(res.file).toBeFalsy(); expect(encrypt.encryptAttachment).not.toHaveBeenCalled(); expect(client.uploadContent).toHaveBeenCalledWith(file, expect.objectContaining({ progressHandler })); }); it("should encrypt the file if the room is encrypted", async () => { mocked(client.isRoomEncrypted).mockReturnValue(true); mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); mocked(encrypt.encryptAttachment).mockResolvedValue({ data: new ArrayBuffer(123), info: {} as IEncryptedFile, }); const progressHandler = jest.fn(); const file = new Blob(["123"]); const res = await uploadFile(client, "!roomId:server", file, progressHandler); expect(res.url).toBeFalsy(); expect(res.file).toEqual( expect.objectContaining({ url: "mxc://server/file", }), ); expect(encrypt.encryptAttachment).toHaveBeenCalled(); expect(client.uploadContent).toHaveBeenCalledWith( expect.any(Blob), expect.objectContaining({ progressHandler, includeFilename: false, type: "application/octet-stream", }), ); expect(mocked(client.uploadContent).mock.calls[0][0]).not.toBe(file); }); it("should throw UploadCanceledError upon aborting the upload", async () => { mocked(client.isRoomEncrypted).mockReturnValue(false); const deferred = defer(); mocked(client.uploadContent).mockReturnValue(deferred.promise); const file = new Blob([]); const prom = uploadFile(client, "!roomId:server", file); mocked(client.uploadContent).mock.calls[0][1]!.abortController!.abort(); deferred.resolve({ content_uri: "mxc://foo/bar" }); await expect(prom).rejects.toThrow(UploadCanceledError); }); });