Refactor MFileBody using MVVM and move to shared-components (#32730)
* 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
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user