diff --git a/packages/shared-components/src/event-tiles/FileBody/FileBody.module.css b/packages/shared-components/src/event-tiles/FileBody/FileBody.module.css new file mode 100644 index 00000000000..93225c0bdc6 --- /dev/null +++ b/packages/shared-components/src/event-tiles/FileBody/FileBody.module.css @@ -0,0 +1,84 @@ +/* +Copyright 2024 New Vector 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. +*/ + +.root { + display: block; +} + +.root > span { + display: block; + margin-top: var(--cpd-space-2x); +} + +.download { + color: var(--cpd-color-text-action-accent); + height: var(--cpd-space-9x); + margin-top: var(--cpd-space-2x); +} + +.download object { + margin-left: -16px; + padding-right: 4px; + margin-top: -4px; + vertical-align: middle; + pointer-events: none; +} + +/* Remove the border and padding for iframes for download links. */ +.download iframe { + margin: 0px; + padding: 0px; + border: none; + width: 100%; +} + +.info { + cursor: pointer; + background: none; + border: none; + padding: 0; + text-align: left; + font: inherit; + display: inline-block; +} + +.infoIcon { + background-color: var(--cpd-color-bg-subtle-secondary); + border-radius: 20px; + display: inline-block; + width: 32px; + height: 32px; + position: relative; + vertical-align: middle; + margin-right: 12px; +} + +.infoIcon::before { + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + /* Using a data URL for the attachment icon to avoid external dependencies */ + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M12.207 2.793a1 1 0 0 0-1.414 0l-8 8a1 1 0 1 0 1.414 1.414L11 5.414V19a1 1 0 1 0 2 0V5.414l6.793 6.793a1 1 0 0 0 1.414-1.414l-8-8Z' clip-rule='evenodd'/%3E%3C/svg%3E"); + background-color: var(--cpd-color-icon-secondary); + width: 15px; + height: 15px; + position: absolute; + top: 8px; + left: 8px; +} + +.infoFilename { + font: var(--cpd-font-body-md-regular); + color: var(--cpd-color-text-primary); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + display: inline-block; + width: calc(100% - 32px - 12px); /* 32px icon, 12px margin on the icon */ + vertical-align: middle; +} diff --git a/packages/shared-components/src/event-tiles/FileBody/FileBody.stories.tsx b/packages/shared-components/src/event-tiles/FileBody/FileBody.stories.tsx new file mode 100644 index 00000000000..e33ab3ea5c1 --- /dev/null +++ b/packages/shared-components/src/event-tiles/FileBody/FileBody.stories.tsx @@ -0,0 +1,157 @@ +/* +Copyright 2024 New Vector 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 React, { type JSX } from "react"; +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { fn } from "storybook/test"; + +import { FileBody, type FileBodyViewSnapshot, type FileBodyActions } from "./FileBody"; +import { useMockedViewModel } from "../../useMockedViewModel"; + +type FileBodyProps = FileBodyViewSnapshot & FileBodyActions; + +const FileBodyWrapper = ({ + onPlaceholderClick, + onDownloadClick, + onDecryptClick, + onIframeLoad, + ...rest +}: FileBodyProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onPlaceholderClick, + onDownloadClick, + onDecryptClick, + onIframeLoad, + }); + return ; +}; + +const meta: Meta = { + title: "Event Tiles/FileBody", + component: FileBodyWrapper, + tags: ["autodocs"], + args: { + fileInfo: { + filename: "Important Document.pdf", + tooltip: "Important Document.pdf", + mimeType: "application/pdf", + }, + downloadLabel: "Download", + showGenericPlaceholder: true, + showDownloadLink: true, + isEncrypted: false, + isDecrypted: false, + forExport: false, + onPlaceholderClick: fn(), + onDownloadClick: fn(), + onDecryptClick: fn(), + onIframeLoad: fn(), + }, +}; + +export default meta; + +const Template: StoryFn = (args) => ; + +/** + * Default file body with placeholder and download button + */ +export const Default = Template.bind({}); + +/** + * File body without the generic placeholder + */ +export const WithoutPlaceholder = Template.bind({}); +WithoutPlaceholder.args = { + showGenericPlaceholder: false, +}; + +/** + * File body without download link + */ +export const WithoutDownloadLink = Template.bind({}); +WithoutDownloadLink.args = { + showDownloadLink: false, +}; + +/** + * Encrypted file that hasn't been decrypted yet - shows decrypt button + */ +export const EncryptedNotDecrypted = Template.bind({}); +EncryptedNotDecrypted.args = { + isEncrypted: true, + isDecrypted: false, +}; + +/** + * Encrypted file that has been decrypted - shows iframe for download + */ +export const EncryptedDecrypted = Template.bind({}); +EncryptedDecrypted.args = { + isEncrypted: true, + isDecrypted: true, + iframeSrc: "usercontent/", +}; + +/** + * File body in export mode with a direct link + */ +export const ExportMode = Template.bind({}); +ExportMode.args = { + forExport: true, + exportUrl: "mxc://server/file123", +}; + +/** + * File body with an error message + */ +export const WithError = Template.bind({}); +WithError.args = { + error: "Invalid file", +}; + +/** + * Large file name that will be truncated + */ +export const LongFilename = Template.bind({}); +LongFilename.args = { + fileInfo: { + filename: "This is a very long filename that should be truncated when displayed.pdf", + tooltip: "This is a very long filename that should be truncated when displayed.pdf", + mimeType: "application/pdf", + }, +}; + +/** + * Different file types + */ +export const ImageFile = Template.bind({}); +ImageFile.args = { + fileInfo: { + filename: "photo.jpg", + tooltip: "photo.jpg (2.3 MB)", + mimeType: "image/jpeg", + }, +}; + +export const VideoFile = Template.bind({}); +VideoFile.args = { + fileInfo: { + filename: "video.mp4", + tooltip: "video.mp4 (45 MB)", + mimeType: "video/mp4", + }, +}; + +export const AudioFile = Template.bind({}); +AudioFile.args = { + fileInfo: { + filename: "song.mp3", + tooltip: "song.mp3 (5.2 MB)", + mimeType: "audio/mpeg", + }, +}; diff --git a/packages/shared-components/src/event-tiles/FileBody/FileBody.test.tsx b/packages/shared-components/src/event-tiles/FileBody/FileBody.test.tsx new file mode 100644 index 00000000000..ea81faa5152 --- /dev/null +++ b/packages/shared-components/src/event-tiles/FileBody/FileBody.test.tsx @@ -0,0 +1,192 @@ +/* +Copyright 2024 New Vector 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 React, { createRef } from "react"; +import { render } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import { FileBody, type FileBodyViewSnapshot, type FileBodyActions } from "./FileBody"; +import { MockViewModel } from "../../viewmodel/MockViewModel"; + +describe("FileBody", () => { + const defaultFileInfo = { + filename: "test-file.pdf", + tooltip: "test-file.pdf", + mimeType: "application/pdf", + }; + + const defaultSnapshot: FileBodyViewSnapshot = { + fileInfo: defaultFileInfo, + downloadLabel: "Download", + showGenericPlaceholder: true, + showDownloadLink: true, + isEncrypted: false, + isDecrypted: false, + forExport: false, + }; + + function createViewModel(snapshot: FileBodyViewSnapshot, actions: FileBodyActions = {}) { + const vm = new MockViewModel(snapshot); + return Object.assign(vm, actions); + } + + it("renders with placeholder and download button for unencrypted file", () => { + const onDownloadClick = jest.fn(); + const vm = createViewModel(defaultSnapshot, { onDownloadClick }); + const { container } = render(); + + expect(container.textContent).toContain("test-file.pdf"); + expect(container.querySelector(".mx_MFileBody_download")).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it("renders without placeholder when showGenericPlaceholder is false", () => { + const vm = createViewModel({ ...defaultSnapshot, showGenericPlaceholder: false }); + const { container } = render(); + + expect(container.querySelector(".mx_MFileBody_info")).toBeFalsy(); + expect(container.querySelector(".mx_MFileBody_download")).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it("renders without download link when showDownloadLink is false", () => { + const vm = createViewModel({ ...defaultSnapshot, showDownloadLink: false }); + const { container } = render(); + + expect(container.textContent).toContain("test-file.pdf"); + expect(container.querySelector(".mx_MFileBody_download")).toBeFalsy(); + expect(container).toMatchSnapshot(); + }); + + it("calls onPlaceholderClick when placeholder is clicked", async () => { + const user = userEvent.setup(); + const onPlaceholderClick = jest.fn(); + const vm = createViewModel(defaultSnapshot, { onPlaceholderClick }); + const { container } = render(); + + const placeholder = container.querySelector(".mx_MFileBody_info"); + await user.click(placeholder!); + expect(onPlaceholderClick).toHaveBeenCalledTimes(1); + }); + + it("calls onDownloadClick when download button is clicked for unencrypted file", async () => { + const user = userEvent.setup(); + const onDownloadClick = jest.fn((e: React.MouseEvent) => e.preventDefault()); + const vm = createViewModel(defaultSnapshot, { onDownloadClick }); + const { container } = render(); + + const downloadLink = container.querySelector(".mx_MFileBody_download a"); + await user.click(downloadLink!); + expect(onDownloadClick).toHaveBeenCalledTimes(1); + }); + + it("renders decrypt button for encrypted file that hasn't been decrypted", () => { + const onDecryptClick = jest.fn(); + const vm = createViewModel( + { + ...defaultSnapshot, + isEncrypted: true, + isDecrypted: false, + }, + { onDecryptClick }, + ); + const { container } = render(); + + expect(container.querySelector(".mx_MFileBody_download button")).toBeTruthy(); + expect(container).toMatchSnapshot(); + }); + + it("calls onDecryptClick when decrypt button is clicked", async () => { + const user = userEvent.setup(); + const onDecryptClick = jest.fn(); + const vm = createViewModel( + { + ...defaultSnapshot, + isEncrypted: true, + isDecrypted: false, + }, + { onDecryptClick }, + ); + const { container } = render(); + + const downloadBtn = container.querySelector(".mx_MFileBody_download button"); + await user.click(downloadBtn!); + expect(onDecryptClick).toHaveBeenCalledTimes(1); + }); + + it("renders iframe for encrypted file that has been decrypted", () => { + const iframeRef = createRef(); + const dummyLinkRef = createRef(); + const onIframeLoad = jest.fn(); + const vm = createViewModel( + { + ...defaultSnapshot, + isEncrypted: true, + isDecrypted: true, + iframeSrc: "usercontent/", + iframeRef, + dummyLinkRef, + }, + { onIframeLoad }, + ); + const { container } = render(); + + const iframe = container.querySelector("iframe"); + expect(iframe).toBeTruthy(); + expect(iframe?.getAttribute("src")).toBe("usercontent/"); + expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts allow-downloads"); + expect(container).toMatchSnapshot(); + }); + + it("renders export mode with link", () => { + const vm = createViewModel({ + ...defaultSnapshot, + forExport: true, + exportUrl: "mxc://server/file", + }); + const { container } = render(); + + const link = container.querySelector("a"); + expect(link?.getAttribute("href")).toBe("mxc://server/file"); + expect(link?.textContent).toContain("test-file.pdf"); + expect(container).toMatchSnapshot(); + }); + + it("renders error message", () => { + const vm = createViewModel({ + ...defaultSnapshot, + error: "Invalid file", + }); + const { container } = render(); + + expect(container.textContent).toContain("test-file.pdf"); + expect(container.textContent).toContain("Invalid file"); + expect(container.querySelector(".mx_MFileBody_download")).toBeFalsy(); + expect(container).toMatchSnapshot(); + }); + + it("applies custom className", () => { + const vm = createViewModel({ + ...defaultSnapshot, + className: "custom-class", + }); + const { container } = render(); + + expect(container.querySelector(".custom-class")).toBeTruthy(); + }); + + it("shows tooltip on filename", () => { + const vm = createViewModel({ + ...defaultSnapshot, + fileInfo: { ...defaultFileInfo, tooltip: "Full filename with path" }, + }); + const { container } = render(); + + const filenameElement = container.querySelector(".mx_MFileBody_info_filename"); + expect(filenameElement?.getAttribute("title")).toBe("Full filename with path"); + }); +}); diff --git a/packages/shared-components/src/event-tiles/FileBody/FileBody.tsx b/packages/shared-components/src/event-tiles/FileBody/FileBody.tsx new file mode 100644 index 00000000000..679c9ac485c --- /dev/null +++ b/packages/shared-components/src/event-tiles/FileBody/FileBody.tsx @@ -0,0 +1,190 @@ +/* +Copyright 2024 New Vector 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 React, { type JSX } from "react"; +import { Button } from "@vector-im/compound-web"; +import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import classNames from "classnames"; + +import styles from "./FileBody.module.css"; +import { type ViewModel } from "../../viewmodel/ViewModel"; +import { useViewModel } from "../../useViewModel"; + +export interface FileInfo { + /** The filename to display */ + filename: string; + /** The tooltip text for the file */ + tooltip: string; + /** MIME type of the file */ + mimeType?: string; +} + +/** + * Snapshot of the FileBody view state + */ +export interface FileBodyViewSnapshot { + /** Information about the file to display */ + fileInfo: FileInfo; + /** The text to display on the download button */ + downloadLabel: string; + /** Whether to show the generic file placeholder */ + showGenericPlaceholder: boolean; + /** Whether to show the download link */ + showDownloadLink: boolean; + /** Whether the file is encrypted */ + isEncrypted: boolean; + /** Whether an encrypted file has been decrypted */ + isDecrypted: boolean; + /** Whether this is for export mode */ + forExport: boolean; + /** The URL for export mode links */ + exportUrl?: string; + /** Error message to display instead of file content */ + error?: string; + /** The iframe src URL for encrypted downloads */ + iframeSrc?: string; + /** Ref for the iframe element */ + iframeRef?: React.RefObject; + /** Ref for the dummy download link (for styling encrypted downloads) */ + dummyLinkRef?: React.RefObject; + /** Additional className for the root element */ + className?: string; +} + +/** + * Actions that can be performed on the FileBody + */ +export interface FileBodyActions { + /** Called when the placeholder is clicked */ + onPlaceholderClick?: () => void; + /** Called when the download button is clicked (for unencrypted files) */ + onDownloadClick?: (e: React.MouseEvent) => void; + /** Called when the decrypt button is clicked */ + onDecryptClick?: (e: React.MouseEvent) => void; + /** Called when iframe loads (for encrypted, decrypted files) */ + onIframeLoad?: () => void; +} + +/** + * ViewModel type for FileBody component + */ +export type FileBodyViewModel = ViewModel & FileBodyActions; + +export interface FileBodyProps { + vm: FileBodyViewModel; +} + +/** + * FileBody is a presentational component for displaying file attachments. + * It handles the UI for encrypted/unencrypted files, download buttons, and placeholders. + */ +export function FileBody({ vm }: FileBodyProps): JSX.Element { + const { + fileInfo, + downloadLabel, + showGenericPlaceholder, + showDownloadLink, + isEncrypted, + isDecrypted, + forExport, + exportUrl, + error, + iframeSrc, + iframeRef, + dummyLinkRef, + className, + } = useViewModel(vm); + + const renderPlaceholder = (): React.ReactNode => { + return ( + + ); + }; + + const renderDownloadButton = (): React.ReactNode => { + // For encrypted files that haven't been decrypted yet + if (isEncrypted && !isDecrypted) { + return ( +
+ +
+ ); + } + + // For encrypted files that have been decrypted (with iframe) + if (isEncrypted && isDecrypted) { + return ( +
+
+ {/* Dummy copy of the button for style calculation */} +
+