diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index bc53ec43fa..eb7df5630f 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -1,295 +1,41 @@ /* Copyright 2024 New Vector Ltd. -Copyright 2015-2021 The Matrix.org Foundation C.I.C. 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 { logger } from "matrix-js-sdk/src/logger"; -import { type MediaEventContent } from "matrix-js-sdk/src/types"; -import { FileBody as SharedFileBody, type FileInfo } from "@element-hq/web-shared-components"; +import React, { useContext, useMemo } from "react"; +import { FileBody as SharedFileBody } from "@element-hq/web-shared-components"; -import { _t } from "../../../languageHandler"; -import Modal from "../../../Modal"; -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 RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; - -export let DOWNLOAD_ICON_URL: string; // cached copy of the download.svg asset for the sandboxed iframe later on - -async function cacheDownloadIcon(): Promise { - if (DOWNLOAD_ICON_URL) return; // cached already - // eslint-disable-next-line @typescript-eslint/no-require-imports - const svg = await fetch(require("@vector-im/compound-design-tokens/icons/download.svg").default).then((r) => - r.text(), - ); - DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg); -} - -// Cache the asset immediately -// noinspection JSIgnoredPromiseFromCall -cacheDownloadIcon(); - -// User supplied content can contain scripts, we have to be careful that -// we don't accidentally run those script within the same origin as the -// client. Otherwise those scripts written by remote users can read -// the access token and end-to-end keys that are in local storage. -// -// For attachments downloaded directly from the homeserver we can use -// Content-Security-Policy headers to disable script execution. -// -// But attachments with end-to-end encryption are more difficult to handle. -// We need to decrypt the attachment on the client and then display it. -// To display the attachment we need to turn the decrypted bytes into a URL. -// -// There are two ways to turn bytes into URLs, data URL and blob URLs. -// Data URLs aren't suitable for downloading a file because Chrome has a -// 2MB limit on the size of URLs that can be viewed in the browser or -// downloaded. This limit does not seem to apply when the url is used as -// the source attribute of an image tag. -// -// Blob URLs are generated using window.URL.createObjectURL and unfortunately -// for our purposes they inherit the origin of the page that created them. -// This means that any scripts that run when the URL is viewed will be able -// to access local storage. -// -// The easiest solution is to host the code that generates the blob URL on -// a different domain to the client. -// Another possibility is to generate the blob URL within a sandboxed iframe. -// The downside of using a second domain is that it complicates hosting, -// the downside of using a sandboxed iframe is that the browers are overly -// restrictive in what you are allowed to do with the generated URL. - -/** - * Get the current CSS style for a DOMElement. - * @param {HTMLElement} element The element to get the current style of. - * @return {string} The CSS style encoded as a string. - */ -export function computedStyle(element: HTMLElement | null): string { - if (!element) { - return ""; - } - const style = window.getComputedStyle(element, null); - let cssText = style.cssText; - // noinspection EqualityComparisonWithCoercionJS - if (cssText == "") { - // Firefox doesn't implement ".cssText" for computed styles. - // https://bugzilla.mozilla.org/show_bug.cgi?id=137687 - for (const rule of style) { - cssText += rule + ":"; - cssText += style.getPropertyValue(rule) + ";"; - } - } - return cssText; -} +import RoomContext from "../../../contexts/RoomContext"; +import { MFileBodyViewModel } from "../../../viewmodels/messages/MFileBodyViewModel"; interface IProps extends IBodyProps { /* whether or not to show the default placeholder for the file. Defaults to true. */ showGenericPlaceholder?: boolean; } -interface IState { - decryptedBlob?: Blob; -} +/** + * MFileBody component that wraps the shared FileBody with a ViewModel. + * This component creates and manages the MFileBodyViewModel instance. + */ +export default function MFileBody(props: IProps): React.ReactElement { + const context = useContext(RoomContext); -export default class MFileBody extends React.Component { - public static contextType = RoomContext; - declare public context: React.ContextType; - - public state: IState = {}; - private iframe = createRef(); - private dummyLink = createRef(); - private userDidClick = false; - private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current); - - private getContentUrl(): string | null { - if (this.props.forExport) return null; - const media = mediaFromContent(this.props.mxEvent.getContent()); - return media.srcHttp; - } - private get content(): MediaEventContent { - return this.props.mxEvent.getContent(); - } - - private get fileName(): string { - return this.props.mediaEventHelper?.fileName || _t("common|attachment"); - } - - private get linkText(): string { - return downloadLabelForFile(this.content, true); - } - - private downloadFile(fileName: string, text: string): void { - if (!this.state.decryptedBlob) return; - this.fileDownloader.download({ - blob: this.state.decryptedBlob, - name: fileName, - autoDownload: this.userDidClick, - opts: { - imgSrc: DOWNLOAD_ICON_URL, - imgStyle: null, - style: computedStyle(this.dummyLink.current), - textContent: text, - }, + const viewModel = useMemo(() => { + return new MFileBodyViewModel({ + ...props, + timelineRenderingType: context.timelineRenderingType, }); - } + }, [ + props.mxEvent, + props.forExport, + props.showGenericPlaceholder, + props.mediaEventHelper, + context.timelineRenderingType, + ]); - private decryptFile = async (): Promise => { - if (this.state.decryptedBlob) { - return; - } - try { - this.userDidClick = true; - this.setState({ - decryptedBlob: await this.props.mediaEventHelper!.sourceBlob.value, - }); - } catch (err) { - logger.warn("Unable to decrypt attachment: ", err); - Modal.createDialog(ErrorDialog, { - title: _t("common|error"), - description: _t("timeline|m.file|error_decrypting"), - }); - } - }; - - private onPlaceholderClick = async (): Promise => { - const mediaHelper = this.props.mediaEventHelper; - if (mediaHelper?.media.isEncrypted) { - await this.decryptFile(); - this.downloadFile(this.fileName, this.linkText); - } else { - // As a button we're missing the `download` attribute for styling reasons, so - // download with the file downloader. - this.fileDownloader.download({ - blob: await mediaHelper!.sourceBlob.value, - name: this.fileName, - }); - } - }; - - public render(): React.ReactNode { - const isEncrypted = this.props.mediaEventHelper?.media.isEncrypted; - const contentUrl = this.getContentUrl(); - const fileType = this.content.info?.mimetype ?? "application/octet-stream"; - // defaultProps breaks types on IBodyProps, so instead define the default here. - const showGenericPlaceholder = this.props.showGenericPlaceholder ?? true; - - let showDownloadLink = - !showGenericPlaceholder || - (this.context.timelineRenderingType !== TimelineRenderingType.Room && - this.context.timelineRenderingType !== TimelineRenderingType.Search && - this.context.timelineRenderingType !== TimelineRenderingType.Pinned); - - if (this.context.timelineRenderingType === TimelineRenderingType.Thread) { - showDownloadLink = false; - } - - const fileInfo: FileInfo = { - filename: presentableTextForFile(this.content, _t("common|attachment"), true, true), - tooltip: presentableTextForFile(this.content, _t("common|attachment"), true), - mimeType: fileType, - }; - - // Export mode - if (this.props.forExport) { - const content = this.props.mxEvent.getContent(); - return ( - - ); - } - - // Error state - if (!isEncrypted && !contentUrl) { - return ( - - ); - } - - // Encrypted file not yet decrypted - if (isEncrypted && !this.state.decryptedBlob) { - return ( - - ); - } - - // Encrypted file that has been decrypted - if (isEncrypted && this.state.decryptedBlob) { - return ( - this.downloadFile(this.fileName, this.linkText)} - /> - ); - } - - // Unencrypted file - return ( - - ); - } - - private onUnencryptedDownloadClick = (e: React.MouseEvent): void => { - logger.log(`Downloading ${this.content.info?.mimetype ?? "application/octet-stream"} as blob (unencrypted)`); - - // Avoid letting the 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 ; } diff --git a/src/utils/FileBodyUtils.ts b/src/utils/FileBodyUtils.ts new file mode 100644 index 0000000000..871bed61a0 --- /dev/null +++ b/src/utils/FileBodyUtils.ts @@ -0,0 +1,44 @@ +/* +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 let DOWNLOAD_ICON_URL: string; // cached copy of the download.svg asset for the sandboxed iframe later on + +export async function cacheDownloadIcon(): Promise { + if (DOWNLOAD_ICON_URL) return; // cached already + // eslint-disable-next-line @typescript-eslint/no-require-imports + const svg = await fetch(require("@vector-im/compound-design-tokens/icons/download.svg").default).then((r) => + r.text(), + ); + DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg); +} + +// Cache the asset immediately +// noinspection JSIgnoredPromiseFromCall +cacheDownloadIcon(); + +/** + * Get the current CSS style for a DOMElement. + * @param {HTMLElement} element The element to get the current style of. + * @return {string} The CSS style encoded as a string. + */ +export function computedStyle(element: HTMLElement | null): string { + if (!element) { + return ""; + } + const style = window.getComputedStyle(element, null); + let cssText = style.cssText; + // noinspection EqualityComparisonWithCoercionJS + if (cssText == "") { + // Firefox doesn't implement ".cssText" for computed styles. + // https://bugzilla.mozilla.org/show_bug.cgi?id=137687 + for (const rule of style) { + cssText += rule + ":"; + cssText += style.getPropertyValue(rule) + ";"; + } + } + return cssText; +} diff --git a/src/viewmodels/messages/MFileBodyViewModel.ts b/src/viewmodels/messages/MFileBodyViewModel.ts new file mode 100644 index 0000000000..ba6b7c762c --- /dev/null +++ b/src/viewmodels/messages/MFileBodyViewModel.ts @@ -0,0 +1,240 @@ +/* +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 { logger } from "matrix-js-sdk/src/logger"; +import { type MediaEventContent } from "matrix-js-sdk/src/types"; +import { + BaseViewModel, + type FileBodyViewSnapshot, + type FileBodyActions, + type FileInfo, +} from "@element-hq/web-shared-components"; +import { createRef } from "react"; + +import { _t } from "../../languageHandler"; +import Modal from "../../Modal"; +import { mediaFromContent } from "../../customisations/Media"; +import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; +import { downloadLabelForFile, presentableTextForFile } from "../../utils/FileUtils"; +import { type IBodyProps } from "../../components/views/messages/IBodyProps"; +import { FileDownloader } from "../../utils/FileDownloader"; +import { TimelineRenderingType } from "../../contexts/RoomContext"; +import { computedStyle, DOWNLOAD_ICON_URL } from "../../utils/FileBodyUtils"; + +export interface MFileBodyViewModelProps extends IBodyProps { + /* whether or not to show the default placeholder for the file. Defaults to true. */ + showGenericPlaceholder?: boolean; + timelineRenderingType?: TimelineRenderingType; +} + +export class MFileBodyViewModel + extends BaseViewModel + implements FileBodyActions +{ + private iframe = createRef(); + private dummyLink = createRef(); + private userDidClick = false; + private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current); + private decryptedBlob?: Blob; + + public constructor(props: MFileBodyViewModelProps) { + const snapshot = MFileBodyViewModel.createInitialSnapshot(props); + super(props, snapshot); + } + + private static createInitialSnapshot(props: MFileBodyViewModelProps): FileBodyViewSnapshot { + const content = props.mxEvent.getContent(); + const isEncrypted = props.mediaEventHelper?.media.isEncrypted; + const contentUrl = MFileBodyViewModel.getContentUrl(props); + const fileType = content.info?.mimetype ?? "application/octet-stream"; + const showGenericPlaceholder = props.showGenericPlaceholder ?? true; + + let showDownloadLink = + !showGenericPlaceholder || + (props.timelineRenderingType !== TimelineRenderingType.Room && + props.timelineRenderingType !== TimelineRenderingType.Search && + props.timelineRenderingType !== TimelineRenderingType.Pinned); + + if (props.timelineRenderingType === TimelineRenderingType.Thread) { + showDownloadLink = false; + } + + const fileInfo: FileInfo = { + filename: presentableTextForFile(content, _t("common|attachment"), true, true), + tooltip: presentableTextForFile(content, _t("common|attachment"), true), + mimeType: fileType, + }; + + const downloadLabel = downloadLabelForFile(content, true); + + // Export mode + if (props.forExport) { + return { + fileInfo, + downloadLabel, + showGenericPlaceholder, + showDownloadLink, + isEncrypted: false, + isDecrypted: false, + forExport: true, + exportUrl: content.file?.url || content.url, + }; + } + + // Error state + if (!isEncrypted && !contentUrl) { + return { + fileInfo, + downloadLabel, + showGenericPlaceholder, + showDownloadLink, + isEncrypted: false, + isDecrypted: false, + forExport: false, + error: _t("timeline|m.file|error_invalid"), + }; + } + + // Initial state for encrypted or unencrypted files + return { + fileInfo, + downloadLabel, + showGenericPlaceholder, + showDownloadLink, + isEncrypted: !!isEncrypted, + isDecrypted: false, + forExport: false, + }; + } + + private static getContentUrl(props: MFileBodyViewModelProps): string | null { + if (props.forExport) return null; + const media = mediaFromContent(props.mxEvent.getContent()); + return media.srcHttp; + } + + private get content(): MediaEventContent { + return this.props.mxEvent.getContent(); + } + + private get fileName(): string { + return this.props.mediaEventHelper?.fileName || _t("common|attachment"); + } + + private get linkText(): string { + return downloadLabelForFile(this.content, true); + } + + private downloadFile(fileName: string, text: string): void { + if (!this.decryptedBlob) return; + this.fileDownloader.download({ + blob: this.decryptedBlob, + name: fileName, + autoDownload: this.userDidClick, + opts: { + imgSrc: DOWNLOAD_ICON_URL, + imgStyle: null, + style: computedStyle(this.dummyLink.current), + textContent: text, + }, + }); + } + + private decryptFile = async (): Promise => { + if (this.decryptedBlob) { + return; + } + try { + this.userDidClick = true; + this.decryptedBlob = await this.props.mediaEventHelper!.sourceBlob.value; + + // Update snapshot to reflect decrypted state + this.snapshot.merge({ + isDecrypted: true, + iframeSrc: "usercontent/", + iframeRef: this.iframe, + dummyLinkRef: this.dummyLink, + }); + } catch (err) { + logger.warn("Unable to decrypt attachment: ", err); + Modal.createDialog(ErrorDialog, { + title: _t("common|error"), + description: _t("timeline|m.file|error_decrypting"), + }); + } + }; + + public get onPlaceholderClick(): (() => void) | undefined { + return async () => { + const mediaHelper = this.props.mediaEventHelper; + if (mediaHelper?.media.isEncrypted) { + await this.decryptFile(); + this.downloadFile(this.fileName, this.linkText); + } else { + // As a button we're missing the `download` attribute for styling reasons, so + // download with the file downloader. + this.fileDownloader.download({ + blob: await mediaHelper!.sourceBlob.value, + name: this.fileName, + }); + } + }; + } + + public get onDownloadClick(): ((e: React.MouseEvent) => void) | undefined { + const current = this.snapshot.current; + if (current.isEncrypted && !current.isDecrypted) { + return undefined; + } + + return (e: React.MouseEvent) => { + logger.log( + `Downloading ${this.content.info?.mimetype ?? "application/octet-stream"} as blob (unencrypted)`, + ); + + // Avoid letting the 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(); + }); + }; + } + + public get onDecryptClick(): ((e: React.MouseEvent) => void) | undefined { + const current = this.snapshot.current; + if (!current.isEncrypted || current.isDecrypted) { + return undefined; + } + + return async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + await this.decryptFile(); + }; + } + + public get onIframeLoad(): (() => void) | undefined { + const current = this.snapshot.current; + if (!current.isEncrypted || !current.isDecrypted) { + return undefined; + } + + return () => this.downloadFile(this.fileName, this.linkText); + } +}