mirror of
https://github.com/vector-im/element-web.git
synced 2026-03-31 03:02:14 +02:00
* Refactor MFileBody using MVVM and move to shared component * Simplyfing rendering properties * Create a first version of view model for the component * Simplifying component properties and make it possible to override module css using data-* attributes * Create a MBodyFactory in element-web and use it to render MFileBodyView from MessageEvent * Use <MediaBody instead of <button to support legacy rendering * Updated styling and comments * Refactoring className from snapshot to component property * Rename MFileBody* to FileBody* * Rename MFileBody* to FileBody* * Refactoring render branches to allow for displaying nothing * Fix styling issues * Fix lint errors * Fix for css selectors in playwright tests * Remove the MFileBody component and change all callers to use MBodyFactory:FileBodyView * Remove unused strings in element-web * Revert to render text in story iframes * Fix for prettier error * Fix playwright test css selectors * Apply legacy styling in element-web * Add legacy styling for mx_MFileBody * Restore file * Change from <div to <button * Calculate span width ad update screenshots * Remove width calculation and update snapshots * Fix for letter-spacing and better content in story * Updated playwright screenshots * Updated snapshots * Fixing Sonar errors/warnings * Removed extra parentheses * Changes after review * Change border-radius to px and updated snapshots * Fix typo in description * And another typo fix * Changes after review
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
/*
|
|
* 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<string, unknown> }>,
|
|
): 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<ConstructorParameters<typeof FileBodyViewModel>[0]> = {}): FileBodyViewModel =>
|
|
new FileBodyViewModel({
|
|
mxEvent: mkMediaEvent({}),
|
|
mediaEventHelper: mkMediaEventHelper({ encrypted: false }),
|
|
showFileInfo: false,
|
|
forExport: false,
|
|
timelineRenderingType: TimelineRenderingType.File,
|
|
refIFrame: createRef<HTMLIFrameElement>() as RefObject<HTMLIFrameElement>,
|
|
refLink: createRef<HTMLAnchorElement>() as RefObject<HTMLAnchorElement>,
|
|
...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);
|
|
});
|
|
});
|