mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 20:26:19 +02:00
Migrate file body to shared components
This commit is contained in:
parent
dcf3e536ab
commit
cc73ef94b9
@ -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;
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
170
packages/shared-components/src/event-tiles/FileBody/FileBody.tsx
Normal file
170
packages/shared-components/src/event-tiles/FileBody/FileBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
`;
|
||||
@ -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";
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user