Migrate file body to shared components

This commit is contained in:
David Langley 2025-11-04 08:31:14 +00:00
parent dcf3e536ab
commit cc73ef94b9
9 changed files with 950 additions and 142 deletions

View File

@ -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;
}

View File

@ -0,0 +1,143 @@
/*
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 type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { FileBody } from "./FileBody";
const meta: Meta<typeof FileBody> = {
title: "Event Tiles/FileBody",
component: FileBody,
tags: ["autodocs"],
args: {
fileInfo: {
filename: "Important Document.pdf",
tooltip: "Important Document.pdf",
mimeType: "application/pdf",
},
downloadLabel: "Download",
showGenericPlaceholder: true,
showDownloadLink: true,
onPlaceholderClick: fn(),
onDownloadClick: fn(),
},
};
export default meta;
type Story = StoryObj<typeof FileBody>;
/**
* Default file body with placeholder and download button
*/
export const Default: Story = {};
/**
* File body without the generic placeholder
*/
export const WithoutPlaceholder: Story = {
args: {
showGenericPlaceholder: false,
},
};
/**
* File body without download link
*/
export const WithoutDownloadLink: Story = {
args: {
showDownloadLink: false,
},
};
/**
* Encrypted file that hasn't been decrypted yet - shows decrypt button
*/
export const EncryptedNotDecrypted: Story = {
args: {
isEncrypted: true,
isDecrypted: false,
onDecryptClick: fn(),
},
};
/**
* Encrypted file that has been decrypted - shows iframe for download
*/
export const EncryptedDecrypted: Story = {
args: {
isEncrypted: true,
isDecrypted: true,
iframeSrc: "usercontent/",
onIframeLoad: fn(),
},
};
/**
* File body in export mode with a direct link
*/
export const ExportMode: Story = {
args: {
forExport: true,
exportUrl: "mxc://server/file123",
},
};
/**
* File body with an error message
*/
export const WithError: Story = {
args: {
error: "Invalid file",
},
};
/**
* Large file name that will be truncated
*/
export const LongFilename: Story = {
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: Story = {
args: {
fileInfo: {
filename: "photo.jpg",
tooltip: "photo.jpg (2.3 MB)",
mimeType: "image/jpeg",
},
},
};
export const VideoFile: Story = {
args: {
fileInfo: {
filename: "video.mp4",
tooltip: "video.mp4 (45 MB)",
mimeType: "video/mp4",
},
},
};
export const AudioFile: Story = {
args: {
fileInfo: {
filename: "song.mp3",
tooltip: "song.mp3 (5.2 MB)",
mimeType: "audio/mpeg",
},
},
};

View File

@ -0,0 +1,179 @@
/*
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 FileBodyProps } from "./FileBody";
describe("FileBody", () => {
const defaultFileInfo = {
filename: "test-file.pdf",
tooltip: "test-file.pdf",
mimeType: "application/pdf",
};
const defaultProps: FileBodyProps = {
fileInfo: defaultFileInfo,
downloadLabel: "Download",
showGenericPlaceholder: true,
showDownloadLink: true,
};
it("renders with placeholder and download button for unencrypted file", () => {
const onDownloadClick = jest.fn();
const { container } = render(
<FileBody {...defaultProps} onDownloadClick={onDownloadClick} />,
);
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 { container } = render(
<FileBody {...defaultProps} showGenericPlaceholder={false} />,
);
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 { container } = render(
<FileBody {...defaultProps} showDownloadLink={false} />,
);
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 { container } = render(<FileBody {...defaultProps} onPlaceholderClick={onPlaceholderClick} />);
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 { container } = render(<FileBody {...defaultProps} onDownloadClick={onDownloadClick} />);
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 { container } = render(
<FileBody
{...defaultProps}
isEncrypted={true}
isDecrypted={false}
onDecryptClick={onDecryptClick}
/>,
);
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 { container } = render(
<FileBody
{...defaultProps}
isEncrypted={true}
isDecrypted={false}
onDecryptClick={onDecryptClick}
/>,
);
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<HTMLIFrameElement>();
const dummyLinkRef = createRef<HTMLAnchorElement>();
const onIframeLoad = jest.fn();
const { container } = render(
<FileBody
{...defaultProps}
isEncrypted={true}
isDecrypted={true}
iframeSrc="usercontent/"
iframeRef={iframeRef}
dummyLinkRef={dummyLinkRef}
onIframeLoad={onIframeLoad}
/>,
);
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 { container } = render(
<FileBody
{...defaultProps}
forExport={true}
exportUrl="mxc://server/file"
/>,
);
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 { container } = render(
<FileBody
{...defaultProps}
error="Invalid file"
/>,
);
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 { container } = render(
<FileBody {...defaultProps} className="custom-class" />,
);
expect(container.querySelector(".custom-class")).toBeTruthy();
});
it("shows tooltip on filename", () => {
const { container } = render(
<FileBody {...defaultProps} fileInfo={{ ...defaultFileInfo, tooltip: "Full filename with path" }} />,
);
const filenameElement = container.querySelector(".mx_MFileBody_info_filename");
expect(filenameElement?.getAttribute("title")).toBe("Full filename with path");
});
});

View File

@ -0,0 +1,170 @@
/*
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 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";
export interface FileInfo {
/** The filename to display */
filename: string;
/** The tooltip text for the file */
tooltip: string;
/** MIME type of the file */
mimeType?: string;
}
export interface FileBodyProps {
/** 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;
/** 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;
/** The iframe src URL for encrypted downloads */
iframeSrc?: string;
/** Ref for the iframe element */
iframeRef?: React.RefObject<HTMLIFrameElement | null>;
/** Ref for the dummy download link (for styling encrypted downloads) */
dummyLinkRef?: React.RefObject<HTMLAnchorElement | null>;
/** Additional className for the root element */
className?: string;
}
/**
* FileBody is a presentational component for displaying file attachments.
* It handles the UI for encrypted/unencrypted files, download buttons, and placeholders.
*/
export class FileBody extends React.Component<FileBodyProps> {
private renderPlaceholder(): React.ReactNode {
const { fileInfo, onPlaceholderClick } = this.props;
return (
<button
type="button"
className={classNames("mx_MediaBody", styles.info, "mx_MFileBody_info")}
onClick={onPlaceholderClick}
>
<span className={classNames(styles.infoIcon, "mx_MFileBody_info_icon")} />
<span
className={classNames(styles.infoFilename, "mx_MFileBody_info_filename")}
title={fileInfo.tooltip}
>
{fileInfo.filename}
</span>
</button>
);
}
private renderDownloadButton(): React.ReactNode {
const { downloadLabel, isEncrypted, isDecrypted, onDecryptClick, onDownloadClick } = this.props;
// For encrypted files that haven't been decrypted yet
if (isEncrypted && !isDecrypted) {
return (
<div className={classNames(styles.download, "mx_MFileBody_download")}>
<Button size="sm" kind="secondary" Icon={DownloadIcon} onClick={onDecryptClick}>
{downloadLabel}
</Button>
</div>
);
}
// For encrypted files that have been decrypted (with iframe)
if (isEncrypted && isDecrypted) {
const { iframeSrc, onIframeLoad, iframeRef, dummyLinkRef, fileInfo } = this.props;
return (
<div className={classNames(styles.download, "mx_MFileBody_download")}>
<div aria-hidden style={{ display: "none" }}>
{/* Dummy copy of the button for style calculation */}
<Button size="sm" kind="secondary" Icon={DownloadIcon} as="a" ref={dummyLinkRef} />
</div>
<iframe
aria-hidden
title={fileInfo.filename}
src={iframeSrc}
onLoad={onIframeLoad}
ref={iframeRef}
sandbox="allow-scripts allow-downloads"
/>
</div>
);
}
// For unencrypted files
return (
<div className={classNames(styles.download, "mx_MFileBody_download")}>
<Button size="sm" kind="secondary" Icon={DownloadIcon} as="a" onClick={onDownloadClick}>
{downloadLabel}
</Button>
</div>
);
}
public render(): React.ReactNode {
const {
showGenericPlaceholder = true,
showDownloadLink = true,
forExport,
exportUrl,
error,
className,
} = this.props;
const placeholder = showGenericPlaceholder ? this.renderPlaceholder() : null;
// Export mode
if (forExport && exportUrl) {
return (
<span className={classNames(styles.root, "mx_MFileBody", className)}>
<a href={exportUrl}>{placeholder}</a>
</span>
);
}
// Error state
if (error) {
return (
<span className={classNames(styles.root, "mx_MFileBody", className)}>
{placeholder}
<span>{error}</span>
</span>
);
}
// Normal display
return (
<span className={classNames(styles.root, "mx_MFileBody", className)}>
{placeholder}
{showDownloadLink && this.renderDownloadButton()}
</span>
);
}
}

View File

@ -0,0 +1,263 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`FileBody renders decrypt button for encrypted file that hasn't been decrypted 1`] = `
<div>
<span
class="mx_MFileBody"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
type="button"
>
<span
class="infoIcon mx_MFileBody_info_icon"
/>
<span
class="infoFilename mx_MFileBody_info_filename"
title="test-file.pdf"
>
test-file.pdf
</span>
</button>
<div
class="download mx_MFileBody_download"
>
<button
class="_button_vczzf_8 _has-icon_vczzf_57"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-3.6-3.6a.95.95 0 0 1-.275-.7q0-.425.275-.7.274-.275.712-.288t.713.263L11 12.15V5q0-.424.287-.713A.97.97 0 0 1 12 4q.424 0 .713.287Q13 4.576 13 5v7.15l1.875-1.875q.274-.274.713-.263.437.014.712.288a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-3.6 3.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063M6 20q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
/>
</svg>
Download
</button>
</div>
</span>
</div>
`;
exports[`FileBody renders error message 1`] = `
<div>
<span
class="mx_MFileBody"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
type="button"
>
<span
class="infoIcon mx_MFileBody_info_icon"
/>
<span
class="infoFilename mx_MFileBody_info_filename"
title="test-file.pdf"
>
test-file.pdf
</span>
</button>
Invalid file
</span>
</div>
`;
exports[`FileBody renders export mode with link 1`] = `
<div>
<span
class="mx_MFileBody"
>
<a
href="mxc://server/file"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
type="button"
>
<span
class="infoIcon mx_MFileBody_info_icon"
/>
<span
class="infoFilename mx_MFileBody_info_filename"
title="test-file.pdf"
>
test-file.pdf
</span>
</button>
</a>
</span>
</div>
`;
exports[`FileBody renders iframe for encrypted file that has been decrypted 1`] = `
<div>
<span
class="mx_MFileBody"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
type="button"
>
<span
class="infoIcon mx_MFileBody_info_icon"
/>
<span
class="infoFilename mx_MFileBody_info_filename"
title="test-file.pdf"
>
test-file.pdf
</span>
</button>
<div
class="download mx_MFileBody_download"
>
<div
aria-hidden="true"
style="display: none;"
>
<a
class="_button_vczzf_8 _has-icon_vczzf_57"
data-kind="secondary"
data-size="sm"
role="link"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-3.6-3.6a.95.95 0 0 1-.275-.7q0-.425.275-.7.274-.275.712-.288t.713.263L11 12.15V5q0-.424.287-.713A.97.97 0 0 1 12 4q.424 0 .713.287Q13 4.576 13 5v7.15l1.875-1.875q.274-.274.713-.263.437.014.712.288a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-3.6 3.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063M6 20q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
/>
</svg>
</a>
</div>
<iframe
aria-hidden="true"
sandbox="allow-scripts allow-downloads"
src="usercontent/"
title="test-file.pdf"
/>
</div>
</span>
</div>
`;
exports[`FileBody renders with placeholder and download button for unencrypted file 1`] = `
<div>
<span
class="mx_MFileBody"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
type="button"
>
<span
class="infoIcon mx_MFileBody_info_icon"
/>
<span
class="infoFilename mx_MFileBody_info_filename"
title="test-file.pdf"
>
test-file.pdf
</span>
</button>
<div
class="download mx_MFileBody_download"
>
<a
class="_button_vczzf_8 _has-icon_vczzf_57"
data-kind="secondary"
data-size="sm"
role="link"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-3.6-3.6a.95.95 0 0 1-.275-.7q0-.425.275-.7.274-.275.712-.288t.713.263L11 12.15V5q0-.424.287-.713A.97.97 0 0 1 12 4q.424 0 .713.287Q13 4.576 13 5v7.15l1.875-1.875q.274-.274.713-.263.437.014.712.288a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-3.6 3.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063M6 20q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
/>
</svg>
Download
</a>
</div>
</span>
</div>
`;
exports[`FileBody renders without download link when showDownloadLink is false 1`] = `
<div>
<span
class="mx_MFileBody"
>
<button
class="mx_MediaBody info mx_MFileBody_info"
type="button"
>
<span
class="infoIcon mx_MFileBody_info_icon"
/>
<span
class="infoFilename mx_MFileBody_info_filename"
title="test-file.pdf"
>
test-file.pdf
</span>
</button>
</span>
</div>
`;
exports[`FileBody renders without placeholder when showGenericPlaceholder is false 1`] = `
<div>
<span
class="mx_MFileBody"
>
<div
class="download mx_MFileBody_download"
>
<a
class="_button_vczzf_8 _has-icon_vczzf_57"
data-kind="secondary"
data-size="sm"
role="link"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-3.6-3.6a.95.95 0 0 1-.275-.7q0-.425.275-.7.274-.275.712-.288t.713.263L11 12.15V5q0-.424.287-.713A.97.97 0 0 1 12 4q.424 0 .713.287Q13 4.576 13 5v7.15l1.875-1.875q.274-.274.713-.263.437.014.712.288a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-3.6 3.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063M6 20q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
/>
</svg>
Download
</a>
</div>
</span>
</div>
`;

View File

@ -0,0 +1,8 @@
/*
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.
*/
export { FileBody, type FileBodyProps, type FileInfo } from "./FileBody";

View File

@ -11,6 +11,7 @@ export * from "./audio/Clock";
export * from "./audio/PlayPauseButton";
export * from "./audio/SeekBar";
export * from "./avatar/AvatarWithDetails";
export * from "./event-tiles/FileBody";
export * from "./event-tiles/TextualEventView";
export * from "./message-body/MediaBody";
export * from "./pill-input/Pill";

View File

@ -6,21 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type AllHTMLAttributes, createRef } from "react";
import React, { createRef } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { type MediaEventContent } from "matrix-js-sdk/src/types";
import { Button } from "@vector-im/compound-web";
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { FileBody as SharedFileBody, type FileInfo } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import AccessibleButton from "../elements/AccessibleButton";
import { mediaFromContent } from "../../../customisations/Media";
import ErrorDialog from "../dialogs/ErrorDialog";
import { downloadLabelForFile, presentableTextForFile } from "../../../utils/FileUtils";
import { type IBodyProps } from "./IBodyProps";
import { FileDownloader } from "../../../utils/FileDownloader";
import TextWithTooltip from "../elements/TextWithTooltip";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
export let DOWNLOAD_ICON_URL: string; // cached copy of the download.svg asset for the sandboxed iframe later on
@ -188,146 +185,111 @@ export default class MFileBody extends React.Component<IProps, IState> {
this.context.timelineRenderingType !== TimelineRenderingType.Search &&
this.context.timelineRenderingType !== TimelineRenderingType.Pinned);
let placeholder: React.ReactNode = null;
if (showGenericPlaceholder) {
placeholder = (
<AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
<span className="mx_MFileBody_info_icon" />
<TextWithTooltip tooltip={presentableTextForFile(this.content, _t("common|attachment"), true)}>
<span className="mx_MFileBody_info_filename">
{presentableTextForFile(this.content, _t("common|attachment"), true, true)}
</span>
</TextWithTooltip>
</AccessibleButton>
);
showDownloadLink = false;
}
if (this.props.forExport) {
const content = this.props.mxEvent.getContent();
// During export, the content url will point to the MSC, which will later point to a local url
return (
<span className="mx_MFileBody">
<a href={content.file?.url || content.url}>{placeholder}</a>
</span>
);
}
if (this.context.timelineRenderingType === TimelineRenderingType.Thread) {
showDownloadLink = false;
}
if (isEncrypted) {
if (!this.state.decryptedBlob) {
// Need to decrypt the attachment
// Wait for the user to click on the link before downloading
// and decrypting the attachment.
const fileInfo: FileInfo = {
filename: presentableTextForFile(this.content, _t("common|attachment"), true, true),
tooltip: presentableTextForFile(this.content, _t("common|attachment"), true),
mimeType: fileType,
};
// This button should actually Download because usercontent/ will try to click itself
// but it is not guaranteed between various browsers' settings.
return (
<span className="mx_MFileBody">
{placeholder}
{showDownloadLink && (
<div className="mx_MFileBody_download">
<Button size="sm" kind="secondary" Icon={DownloadIcon} onClick={this.decryptFile}>
{this.linkText}
</Button>
</div>
)}
</span>
);
}
const url = "usercontent/"; // XXX: this path should probably be passed from the skin
// If the attachment is encrypted then put the link inside an iframe.
// Export mode
if (this.props.forExport) {
const content = this.props.mxEvent.getContent();
return (
<span className="mx_MFileBody">
{placeholder}
{showDownloadLink && (
<div className="mx_MFileBody_download">
<div aria-hidden style={{ display: "none" }}>
{/*
* Add dummy copy of the button
* We'll use it to learn how the download button
* would have been styled if it was rendered inline.
*/}
{/* this violates multiple eslint rules
so ignore it completely */}
<Button size="sm" kind="secondary" Icon={DownloadIcon} as="a" ref={this.dummyLink} />
</div>
{/*
TODO: Move iframe (and dummy link) into FileDownloader.
We currently have it set up this way because of styles applied to the iframe
itself which cannot be easily handled/overridden by the FileDownloader. In
future, the download link may disappear entirely at which point it could also
be suitable to just remove this bit of code.
*/}
<iframe
aria-hidden
title={presentableTextForFile(this.content, _t("common|attachment"), true, true)}
src={url}
onLoad={() => this.downloadFile(this.fileName, this.linkText)}
ref={this.iframe}
sandbox="allow-scripts allow-downloads"
/>
</div>
)}
</span>
);
} else if (contentUrl) {
const downloadProps: Pick<
AllHTMLAttributes<HTMLAnchorElement>,
"target" | "rel" | "href" | "onClick" | "download"
> = {
target: "_blank",
rel: "noreferrer noopener",
// We cannot rely on href+download to download media due to the authenticated media API as it relies
// on authentication via headers, so we'll have to download the file into memory and then download it.
onClick: (e) => {
logger.log(`Downloading ${fileType} as blob (unencrypted)`);
// Avoid letting the <a> do its thing
e.preventDefault();
e.stopPropagation();
// Start a fetch for the download
// Based upon https://stackoverflow.com/a/49500465
this.props.mediaEventHelper?.sourceBlob.value.then((blob) => {
const blobUrl = URL.createObjectURL(blob);
// We have to create an anchor to download the file
const tempAnchor = document.createElement("a");
tempAnchor.download = this.fileName;
tempAnchor.href = blobUrl;
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
tempAnchor.click();
tempAnchor.remove();
});
},
};
return (
<span className="mx_MFileBody">
{placeholder}
{showDownloadLink && (
<div className="mx_MFileBody_download">
<Button size="sm" kind="secondary" Icon={DownloadIcon} as="a" {...downloadProps}>
{this.linkText}
</Button>
</div>
)}
</span>
);
} else {
return (
<span className="mx_MFileBody">
{placeholder}
{_t("timeline|m.file|error_invalid")}
</span>
<SharedFileBody
fileInfo={fileInfo}
downloadLabel={this.linkText}
showGenericPlaceholder={showGenericPlaceholder}
showDownloadLink={showDownloadLink}
forExport={true}
exportUrl={content.file?.url || content.url}
/>
);
}
// Error state
if (!isEncrypted && !contentUrl) {
return (
<SharedFileBody
fileInfo={fileInfo}
downloadLabel={this.linkText}
showGenericPlaceholder={showGenericPlaceholder}
showDownloadLink={showDownloadLink}
error={_t("timeline|m.file|error_invalid")}
/>
);
}
// Encrypted file not yet decrypted
if (isEncrypted && !this.state.decryptedBlob) {
return (
<SharedFileBody
fileInfo={fileInfo}
downloadLabel={this.linkText}
showGenericPlaceholder={showGenericPlaceholder}
showDownloadLink={showDownloadLink}
isEncrypted={true}
isDecrypted={false}
onPlaceholderClick={this.onPlaceholderClick}
onDecryptClick={this.decryptFile}
/>
);
}
// Encrypted file that has been decrypted
if (isEncrypted && this.state.decryptedBlob) {
return (
<SharedFileBody
fileInfo={fileInfo}
downloadLabel={this.linkText}
showGenericPlaceholder={showGenericPlaceholder}
showDownloadLink={showDownloadLink}
isEncrypted={true}
isDecrypted={true}
iframeSrc="usercontent/"
iframeRef={this.iframe}
dummyLinkRef={this.dummyLink}
onPlaceholderClick={this.onPlaceholderClick}
onIframeLoad={() => this.downloadFile(this.fileName, this.linkText)}
/>
);
}
// Unencrypted file
return (
<SharedFileBody
fileInfo={fileInfo}
downloadLabel={this.linkText}
showGenericPlaceholder={showGenericPlaceholder}
showDownloadLink={showDownloadLink}
onPlaceholderClick={this.onPlaceholderClick}
onDownloadClick={this.onUnencryptedDownloadClick}
/>
);
}
private onUnencryptedDownloadClick = (e: React.MouseEvent): void => {
logger.log(`Downloading ${this.content.info?.mimetype ?? "application/octet-stream"} as blob (unencrypted)`);
// Avoid letting the <a> do its thing
e.preventDefault();
e.stopPropagation();
// Start a fetch for the download
// Based upon https://stackoverflow.com/a/49500465
this.props.mediaEventHelper?.sourceBlob.value.then((blob) => {
const blobUrl = URL.createObjectURL(blob);
// We have to create an anchor to download the file
const tempAnchor = document.createElement("a");
tempAnchor.download = this.fileName;
tempAnchor.href = blobUrl;
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
tempAnchor.click();
tempAnchor.remove();
});
};
}

View File

@ -3,19 +3,17 @@
exports[`<MFileBody/> should show a download button in file rendering type 1`] = `
<div>
<span
class="mx_MFileBody"
class="_root_crpne_8 mx_MFileBody"
>
<div
class="mx_MFileBody_download"
class="_download_crpne_17 mx_MFileBody_download"
>
<a
class="_button_vczzf_8 _has-icon_vczzf_57"
data-kind="secondary"
data-size="sm"
rel="noreferrer noopener"
role="link"
tabindex="0"
target="_blank"
>
<svg
aria-hidden="true"