/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { mocked } from "jest-mock"; import { ISendEventResponse, MatrixClient, RelationType, UploadResponse } from "matrix-js-sdk/src/matrix"; import { ImageInfo } from "matrix-js-sdk/src/types"; 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"; 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 ImageInfo; 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 () => { 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); }); });