/* * Copyright 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial * Please see LICENSE files in the repository root for full details. */ import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { createRef, type RefObject } from "react"; import { FileBodyViewInfoIcon, FileBodyViewState } from "@element-hq/web-shared-components"; import Modal from "../../../src/Modal"; import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; import { type MediaEventHelper } from "../../../src/utils/MediaEventHelper"; import { FileBodyViewModel } from "../../../src/viewmodels/message-body/FileBodyViewModel"; import ErrorDialog from "../../../src/components/views/dialogs/ErrorDialog"; const mockDownload = jest.fn(); jest.mock("../../../src/utils/FileDownloader", () => ({ FileDownloader: jest.fn().mockImplementation(() => ({ download: mockDownload, })), })); jest.mock("../../../src/customisations/Media", () => ({ mediaFromContent: jest.fn((content: { file?: unknown; url?: string }) => ({ isEncrypted: !!content.file, srcHttp: content.url ?? null, })), })); describe("FileBodyViewModel", () => { const mkMediaEvent = ( content: Partial<{ body: string; msgtype: string; url: string; file: Record }>, ): MatrixEvent => new MatrixEvent({ room_id: "!room:server", sender: "@user:server", type: EventType.RoomMessage, content: { body: "alt", msgtype: "m.file", url: "https://server/file", ...content, }, }); const mkMediaEventHelper = ({ encrypted, blob = new Blob(["content"], { type: "text/plain" }), fileName = "file.txt", }: { encrypted: boolean; blob?: Blob; fileName?: string; }): MediaEventHelper => ({ media: { isEncrypted: encrypted }, sourceBlob: { value: Promise.resolve(blob) }, fileName, }) as unknown as MediaEventHelper; const createVm = (overrides: Partial[0]> = {}): FileBodyViewModel => new FileBodyViewModel({ mxEvent: mkMediaEvent({}), mediaEventHelper: mkMediaEventHelper({ encrypted: false }), showFileInfo: false, forExport: false, timelineRenderingType: TimelineRenderingType.File, refIFrame: createRef() as RefObject, refLink: createRef() as RefObject, ...overrides, }); beforeEach(() => { jest.clearAllMocks(); }); it("shows unencrypted download snapshot in file rendering type", () => { const vm = createVm(); expect(vm.getSnapshot()).toMatchObject({ state: FileBodyViewState.UNENCRYPTED, showInfo: false, showDownload: true, downloadHref: "https://server/file", }); }); it.each([ { msgtype: "m.file", expectedIcon: FileBodyViewInfoIcon.ATTACHMENT }, { msgtype: "m.audio", expectedIcon: FileBodyViewInfoIcon.AUDIO }, { msgtype: "m.video", expectedIcon: FileBodyViewInfoIcon.VIDEO }, ])("shows generic placeholder info for $msgtype", ({ msgtype, expectedIcon }) => { const vm = createVm({ mxEvent: mkMediaEvent({ msgtype }), showFileInfo: true, }); expect(vm.getSnapshot()).toMatchObject({ state: FileBodyViewState.UNENCRYPTED, showInfo: true, infoLabel: "alt", infoIcon: expectedIcon, showDownload: false, }); }); it("shows export snapshot with export href", () => { const vm = createVm({ forExport: true, showFileInfo: true, mxEvent: mkMediaEvent({ url: "https://server/export-file" }), }); expect(vm.getSnapshot()).toMatchObject({ state: FileBodyViewState.EXPORT, showInfo: true, infoLabel: "alt", infoHref: "https://server/export-file", }); }); it("downloads unencrypted placeholder content on info click", async () => { const blob = new Blob(["placeholder"], { type: "text/plain" }); const vm = createVm({ showFileInfo: true, mediaEventHelper: mkMediaEventHelper({ encrypted: false, blob, fileName: "placeholder.txt" }), }); await vm.onInfoClick(); expect(mockDownload).toHaveBeenCalledWith({ blob, name: "placeholder.txt", }); }); it("decrypts encrypted content and downloads on iframe load", async () => { const blob = new Blob(["encrypted"], { type: "application/octet-stream" }); const vm = createVm({ mediaEventHelper: mkMediaEventHelper({ encrypted: true, blob, fileName: "encrypted.bin" }), mxEvent: mkMediaEvent({ file: { url: "mxc://server/file" } }), }); await vm.onDownloadClick(); expect(vm.getSnapshot().state).toBe(FileBodyViewState.ENCRYPTED); vm.onDownloadIframeLoad(); expect(mockDownload).toHaveBeenCalledWith( expect.objectContaining({ blob, name: "encrypted.bin", autoDownload: true, opts: expect.objectContaining({ textContent: expect.any(String), }), }), ); }); it("downloads unencrypted source as blob in onDownloadLinkClick", async () => { const blob = new Blob(["direct-download"], { type: "text/plain" }); const vm = createVm({ mediaEventHelper: mkMediaEventHelper({ encrypted: false, blob, fileName: "direct.txt" }), mxEvent: mkMediaEvent({ msgtype: "m.file", url: "https://server/direct.txt" }), }); const click = jest.spyOn(HTMLAnchorElement.prototype, "click"); const event = { preventDefault: jest.fn(), stopPropagation: jest.fn(), } as any; vm.onDownloadLinkClick(event); await Promise.resolve(); expect(event.preventDefault).toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalled(); expect(URL.createObjectURL).toHaveBeenCalledWith(blob); expect(click).toHaveBeenCalled(); }); it("shows decrypt error dialog when decrypt fails", async () => { const vm = createVm({ mediaEventHelper: { media: { isEncrypted: true }, sourceBlob: { value: Promise.reject(new Error("decrypt failed")) }, fileName: "broken.bin", } as unknown as MediaEventHelper, mxEvent: mkMediaEvent({ file: { url: "mxc://server/file" } }), }); const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {}); const dialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ close: jest.fn() } as any); await vm.onDownloadClick(); expect(warnSpy).toHaveBeenCalled(); expect(dialogSpy).toHaveBeenCalledWith( ErrorDialog, expect.objectContaining({ title: "Error", description: expect.stringMatching(/decrypt/i), }), ); expect(vm.getSnapshot().state).toBe(FileBodyViewState.DECRYPTION_PENDING); }); it("resets decrypted state when mxEvent changes", async () => { const vm = createVm({ mediaEventHelper: mkMediaEventHelper({ encrypted: true }), mxEvent: mkMediaEvent({ file: { url: "mxc://server/file-a" } }), }); await vm.onDownloadClick(); expect(vm.getSnapshot().state).toBe(FileBodyViewState.ENCRYPTED); vm.setProps({ mxEvent: mkMediaEvent({ body: "new", file: { url: "mxc://server/file-b" } }), }); expect(vm.getSnapshot()).toMatchObject({ state: FileBodyViewState.DECRYPTION_PENDING, }); }); it("keeps decrypted state when non-event props change", async () => { const vm = createVm({ mediaEventHelper: mkMediaEventHelper({ encrypted: true }), mxEvent: mkMediaEvent({ file: { url: "mxc://server/file-a" } }), }); await vm.onDownloadClick(); expect(vm.getSnapshot().state).toBe(FileBodyViewState.ENCRYPTED); vm.setProps({ timelineRenderingType: TimelineRenderingType.Thread, }); expect(vm.getSnapshot()).toMatchObject({ state: FileBodyViewState.ENCRYPTED, showDownload: false, }); }); it("uses filename-like downloadTitle in encrypted mode when showFileInfo is false", () => { const vm = createVm({ showFileInfo: false, mediaEventHelper: mkMediaEventHelper({ encrypted: true }), mxEvent: mkMediaEvent({ body: "my-file.pdf", file: { url: "mxc://server/file" } }), }); expect(vm.getSnapshot()).toMatchObject({ state: FileBodyViewState.DECRYPTION_PENDING, downloadTitle: "my-file.pdf", }); }); it("hides download in thread rendering even when showFileInfo is false", () => { const vm = createVm({ showFileInfo: false, timelineRenderingType: TimelineRenderingType.Thread, }); expect(vm.getSnapshot()).toMatchObject({ state: FileBodyViewState.UNENCRYPTED, showDownload: false, }); }); it("returns INVALID snapshot when content URL is missing", () => { const vm = createVm({ mxEvent: mkMediaEvent({ url: undefined }), showFileInfo: true, }); expect(vm.getSnapshot()).toMatchObject({ state: FileBodyViewState.INVALID, showInfo: true, infoLabel: "alt", infoIcon: FileBodyViewInfoIcon.ATTACHMENT, }); }); it("does not download on info click when showFileInfo is false", async () => { const vm = createVm({ showFileInfo: false }); await vm.onInfoClick(); expect(mockDownload).not.toHaveBeenCalled(); }); it("keeps unencrypted snapshot when download is clicked without mediaEventHelper", async () => { const vm = createVm({ mediaEventHelper: undefined, mxEvent: mkMediaEvent({ file: { url: "mxc://server/file" } }), }); await vm.onDownloadClick(); expect(vm.getSnapshot().state).toBe(FileBodyViewState.UNENCRYPTED); }); });