From bde2f757aa1072f7874b943245c58e3799225850 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 29 Jul 2021 15:36:50 -0600
Subject: [PATCH 1/7] Unify and improve download interactions

With help from Palid.

This does two major things:
1. Makes the tile part of a file body clickable to trigger a download.
2. Refactors a lot of the recyclable code out of the DownloadActionButton in favour of a utility. It's not a perfect refactoring, but it sets the stage for future work in the area (if needed).

The refactoring still has a heavy reliance on being supplied an iframe, but simplifies the DownloadActionButton and a hair of the MFileBody download code. In future, we'd probably want to make the iframe completely managed by the FileDownloader rather than have it only sometimes manage a hidden iframe.
---
 res/css/views/messages/_MFileBody.scss        |   4 +-
 .../views/messages/DownloadActionButton.tsx   |  34 ++---
 src/components/views/messages/MFileBody.tsx   | 121 +++++++++++-------
 src/utils/FileDownloader.ts                   | 104 +++++++++++++++
 4 files changed, 196 insertions(+), 67 deletions(-)
 create mode 100644 src/utils/FileDownloader.ts

diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss
index 403f671673..d941a8132f 100644
--- a/res/css/views/messages/_MFileBody.scss
+++ b/res/css/views/messages/_MFileBody.scss
@@ -1,5 +1,5 @@
 /*
-Copyright 2015, 2016, 2021 The Matrix.org Foundation C.I.C.
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -60,6 +60,8 @@ limitations under the License.
 }
 
 .mx_MFileBody_info {
+    cursor: pointer;
+
     .mx_MFileBody_info_icon {
         background-color: $message-body-panel-icon-bg-color;
         border-radius: 20px;
diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx
index 2bdae04eda..262783a867 100644
--- a/src/components/views/messages/DownloadActionButton.tsx
+++ b/src/components/views/messages/DownloadActionButton.tsx
@@ -16,12 +16,13 @@ limitations under the License.
 
 import { MatrixEvent } from "matrix-js-sdk/src";
 import { MediaEventHelper } from "../../../utils/MediaEventHelper";
-import React, { createRef } from "react";
+import React from "react";
 import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
 import Spinner from "../elements/Spinner";
 import classNames from "classnames";
 import { _t } from "../../../languageHandler";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
+import { FileDownloader } from "../../../utils/FileDownloader";
 
 interface IProps {
     mxEvent: MatrixEvent;
@@ -39,7 +40,7 @@ interface IState {
 
 @replaceableComponent("views.messages.DownloadActionButton")
 export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
-    private iframe: React.RefObject<HTMLIFrameElement> = createRef();
+    private downloader = new FileDownloader();
 
     public constructor(props: IProps) {
         super(props);
@@ -56,27 +57,21 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
 
         if (this.state.blob) {
             // Cheat and trigger a download, again.
-            return this.onFrameLoad();
+            return this.doDownload();
         }
 
         const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
         this.setState({ blob });
+        await this.doDownload();
     };
 
-    private onFrameLoad = () => {
-        this.setState({ loading: false });
-
-        // we aren't showing the iframe, so we can send over the bare minimum styles and such.
-        this.iframe.current.contentWindow.postMessage({
-            imgSrc: "", // no image
-            imgStyle: null,
-            style: "",
+    private async doDownload() {
+        await this.downloader.download({
             blob: this.state.blob,
-            download: this.props.mediaEventHelperGet().fileName,
-            textContent: "",
-            auto: true, // autodownload
-        }, '*');
-    };
+            name: this.props.mediaEventHelperGet().fileName,
+        });
+        this.setState({ loading: false });
+    }
 
     public render() {
         let spinner: JSX.Element;
@@ -97,13 +92,6 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
             disabled={!!spinner}
         >
             { spinner }
-            { this.state.blob && <iframe
-                src="usercontent/" // XXX: Like MFileBody, this should come from the skin
-                ref={this.iframe}
-                onLoad={this.onFrameLoad}
-                sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation"
-                style={{ display: "none" }}
-            /> }
         </RovingAccessibleTooltipButton>;
     }
 }
diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index 116fd8bd43..4de4c4c804 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -26,6 +26,7 @@ import { TileShape } from "../rooms/EventTile";
 import { presentableTextForFile } from "../../../utils/FileUtils";
 import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
 import { IBodyProps } from "./IBodyProps";
+import { FileDownloader } from "../../../utils/FileDownloader";
 
 export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
 
@@ -111,6 +112,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
     private iframe: React.RefObject<HTMLIFrameElement> = createRef();
     private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
     private userDidClick = false;
+    private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current)
 
     public constructor(props: IProps) {
         super(props);
@@ -118,6 +120,32 @@ export default class MFileBody extends React.Component<IProps, IState> {
         this.state = {};
     }
 
+    private get content(): IMediaEventContent {
+        return this.props.mxEvent.getContent<IMediaEventContent>();
+    }
+
+    private get fileName(): string {
+        return this.content.body && this.content.body.length > 0 ? this.content.body : _t("Attachment");
+    }
+
+    private get linkText(): string {
+        return presentableTextForFile(this.content);
+    }
+
+    private downloadFile(fileName: string, text: string) {
+        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: _t("Download %(text)s", { text })
+            },
+        });
+    }
+
     private getContentUrl(): string {
         const media = mediaFromContent(this.props.mxEvent.getContent());
         return media.srcHttp;
@@ -129,24 +157,55 @@ export default class MFileBody extends React.Component<IProps, IState> {
         }
     }
 
+    private decryptFile = async (): Promise<void> => {
+        if (this.state.decryptedBlob) {
+            return;
+        }
+        try {
+            this.userDidClick = true;
+            this.setState({
+                decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
+            });
+        } catch (err) {
+            console.warn("Unable to decrypt attachment: ", err);
+            Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
+                title: _t("Error"),
+                description: _t("Error decrypting attachment"),
+            });
+        }
+    };
+
+    private onPlaceholderClick = 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 render() {
         const content = this.props.mxEvent.getContent<IMediaEventContent>();
-        const text = presentableTextForFile(content);
         const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
-        const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
         const contentUrl = this.getContentUrl();
         const fileSize = content.info ? content.info.size : null;
         const fileType = content.info ? content.info.mimetype : "application/octet-stream";
 
-        let placeholder = null;
+        let placeholder: React.ReactNode = null;
         if (this.props.showGenericPlaceholder) {
             placeholder = (
-                <div className="mx_MediaBody mx_MFileBody_info">
+                <AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
                     <span className="mx_MFileBody_info_icon" />
                     <span className="mx_MFileBody_info_filename">
                         { presentableTextForFile(content, _t("Attachment"), false) }
                     </span>
-                </div>
+                </AccessibleButton>
             );
         }
 
@@ -157,20 +216,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
                 // Need to decrypt the attachment
                 // Wait for the user to click on the link before downloading
                 // and decrypting the attachment.
-                const decrypt = async () => {
-                    try {
-                        this.userDidClick = true;
-                        this.setState({
-                            decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
-                        });
-                    } catch (err) {
-                        console.warn("Unable to decrypt attachment: ", err);
-                        Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
-                            title: _t("Error"),
-                            description: _t("Error decrypting attachment"),
-                        });
-                    }
-                };
 
                 // This button should actually Download because usercontent/ will try to click itself
                 // but it is not guaranteed between various browsers' settings.
@@ -178,31 +223,14 @@ export default class MFileBody extends React.Component<IProps, IState> {
                     <span className="mx_MFileBody">
                         { placeholder }
                         { showDownloadLink && <div className="mx_MFileBody_download">
-                            <AccessibleButton onClick={decrypt}>
-                                { _t("Decrypt %(text)s", { text: text }) }
+                            <AccessibleButton onClick={this.decryptFile}>
+                                { _t("Decrypt %(text)s", { text: this.linkText }) }
                             </AccessibleButton>
                         </div> }
                     </span>
                 );
             }
 
-            // When the iframe loads we tell it to render a download link
-            const onIframeLoad = (ev) => {
-                ev.target.contentWindow.postMessage({
-                    imgSrc: DOWNLOAD_ICON_URL,
-                    imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
-                    style: computedStyle(this.dummyLink.current),
-                    blob: this.state.decryptedBlob,
-                    // Set a download attribute for encrypted files so that the file
-                    // will have the correct name when the user tries to download it.
-                    // We can't provide a Content-Disposition header like we would for HTTP.
-                    download: fileName,
-                    textContent: _t("Download %(text)s", { text: text }),
-                    // only auto-download if a user triggered this iframe explicitly
-                    auto: this.userDidClick,
-                }, "*");
-            };
-
             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.
@@ -218,9 +246,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
                               */ }
                             <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
                             src={url}
-                            onLoad={onIframeLoad}
+                            onLoad={() => this.downloadFile(this.fileName, this.linkText)}
                             ref={this.iframe}
                             sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
                     </div> }
@@ -259,7 +294,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
 
                         // We have to create an anchor to download the file
                         const tempAnchor = document.createElement('a');
-                        tempAnchor.download = fileName;
+                        tempAnchor.download = this.fileName;
                         tempAnchor.href = blobUrl;
                         document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
                         tempAnchor.click();
@@ -268,7 +303,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
                 };
             } else {
                 // Else we are hoping the browser will do the right thing
-                downloadProps["download"] = fileName;
+                downloadProps["download"] = this.fileName;
             }
 
             return (
@@ -277,7 +312,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
                     { showDownloadLink && <div className="mx_MFileBody_download">
                         <a {...downloadProps}>
                             <span className="mx_MFileBody_download_icon" />
-                            { _t("Download %(text)s", { text: text }) }
+                            { _t("Download %(text)s", { text: this.linkText }) }
                         </a>
                         { this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
                             { content.info && content.info.size ? filesize(content.info.size) : "" }
@@ -286,7 +321,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
                 </span>
             );
         } else {
-            const extra = text ? (': ' + text) : '';
+            const extra = this.linkText ? (': ' + this.linkText) : '';
             return <span className="mx_MFileBody">
                 { placeholder }
                 { _t("Invalid file%(extra)s", { extra: extra }) }
diff --git a/src/utils/FileDownloader.ts b/src/utils/FileDownloader.ts
new file mode 100644
index 0000000000..0f329e6ada
--- /dev/null
+++ b/src/utils/FileDownloader.ts
@@ -0,0 +1,104 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export type getIframeFn = () => HTMLIFrameElement;
+
+export const DEFAULT_STYLES = {
+    imgSrc: "",
+    imgStyle: null, // css props
+    style: "",
+    textContent: "",
+}
+
+type DownloadOptions = {
+    blob: Blob,
+    name: string,
+    autoDownload?: boolean,
+    opts?: typeof DEFAULT_STYLES
+}
+
+// set up the iframe as a singleton so we don't have to figure out destruction of it down the line.
+let managedIframe: HTMLIFrameElement;
+let onLoadPromise: Promise<void>;
+function getManagedIframe(): { iframe: HTMLIFrameElement, onLoadPromise: Promise<void> } {
+    if (managedIframe) return { iframe: managedIframe, onLoadPromise };
+
+    managedIframe = document.createElement("iframe");
+
+    // Need to append the iframe in order for the browser to load it.
+    document.body.appendChild(managedIframe);
+
+    // Dev note: the reassignment warnings are entirely incorrect here.
+
+    // @ts-ignore
+    // noinspection JSConstantReassignment
+    managedIframe.style = { display: "none" };
+    // @ts-ignore
+    // noinspection JSConstantReassignment
+    managedIframe.sandbox = "allow-scripts allow-downloads allow-downloads-without-user-activation";
+
+    onLoadPromise = new Promise(resolve => {
+        managedIframe.onload = () => {
+            resolve();
+        };
+        managedIframe.src = "usercontent/"; // XXX: Should come from the skin
+    });
+
+    return { iframe: managedIframe, onLoadPromise };
+}
+
+// TODO: If we decide to keep the download link behaviour, we should bring the style management into here.
+
+/**
+ * Helper to handle safe file downloads. This operates off an iframe for reasons described
+ * by the blob helpers. By default, this will use a hidden iframe to manage the download
+ * through a user content wrapper, but can be given an iframe reference if the caller needs
+ * additional control over the styling/position of the iframe itself.
+ */
+export class FileDownloader {
+    private onLoadPromise: Promise<void>;
+
+    /**
+     * Creates a new file downloader
+     * @param iframeFn Function to get a pre-configured iframe. Set to null to have the downloader
+     * use a generic, hidden, iframe.
+     */
+    constructor(private iframeFn: getIframeFn = null) {
+    }
+
+    private get iframe(): HTMLIFrameElement {
+        const iframe = this.iframeFn?.();
+        if (!iframe) {
+            const managed = getManagedIframe();
+            this.onLoadPromise = managed.onLoadPromise;
+            return managed.iframe;
+        }
+        this.onLoadPromise = null;
+        return iframe;
+    }
+
+    public async download({blob, name, autoDownload = true, opts = DEFAULT_STYLES}: DownloadOptions) {
+        const iframe = this.iframe; // get the iframe first just in case we need to await onload
+        console.log("@@", {blob, name, autoDownload, opts, iframe, m: iframe === managedIframe, p: this.onLoadPromise});
+        if (this.onLoadPromise) await this.onLoadPromise;
+        iframe.contentWindow.postMessage({
+            ...opts,
+            blob: blob,
+            download: name,
+            auto: autoDownload,
+        }, '*');
+    }
+}

From 790696a4bbac5cf90925491e7b9f3e765fc329e6 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 29 Jul 2021 15:37:09 -0600
Subject: [PATCH 2/7] Change "Downloading" tooltip to "Decrypting"

Fixes https://github.com/vector-im/element-web/issues/18239
---
 src/components/views/messages/DownloadActionButton.tsx | 2 +-
 src/i18n/strings/en_EN.json                            | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx
index 262783a867..6dc48b0936 100644
--- a/src/components/views/messages/DownloadActionButton.tsx
+++ b/src/components/views/messages/DownloadActionButton.tsx
@@ -87,7 +87,7 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
 
         return <RovingAccessibleTooltipButton
             className={classes}
-            title={spinner ? _t("Downloading") : _t("Download")}
+            title={spinner ? _t("Decrypting") : _t("Download")}
             onClick={this.onDownloadClick}
             disabled={!!spinner}
         >
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 1093f478bb..a1d2ee053a 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1882,7 +1882,7 @@
     "Saturday": "Saturday",
     "Today": "Today",
     "Yesterday": "Yesterday",
-    "Downloading": "Downloading",
+    "Decrypting": "Decrypting",
     "Download": "Download",
     "View Source": "View Source",
     "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.",
@@ -1897,9 +1897,9 @@
     "Retry": "Retry",
     "Reply": "Reply",
     "Message Actions": "Message Actions",
+    "Download %(text)s": "Download %(text)s",
     "Error decrypting attachment": "Error decrypting attachment",
     "Decrypt %(text)s": "Decrypt %(text)s",
-    "Download %(text)s": "Download %(text)s",
     "Invalid file%(extra)s": "Invalid file%(extra)s",
     "Error decrypting image": "Error decrypting image",
     "Show image": "Show image",

From 15f6c6300adb81b6dd205aea5a9eefc870f4f2fb Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 29 Jul 2021 15:49:23 -0600
Subject: [PATCH 3/7] Improve general style per design direction

---
 src/components/views/messages/MFileBody.tsx | 10 +++++++---
 src/utils/FileUtils.ts                      | 17 +++++++++++++++++
 2 files changed, 24 insertions(+), 3 deletions(-)

diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index 4de4c4c804..ccf5fe6745 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -27,6 +27,7 @@ import { presentableTextForFile } from "../../../utils/FileUtils";
 import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
 import { IBodyProps } from "./IBodyProps";
 import { FileDownloader } from "../../../utils/FileDownloader";
+import TextWithTooltip from "../elements/TextWithTooltip";
 
 export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
 
@@ -202,9 +203,12 @@ export default class MFileBody extends React.Component<IProps, IState> {
             placeholder = (
                 <AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
                     <span className="mx_MFileBody_info_icon" />
-                    <span className="mx_MFileBody_info_filename">
-                        { presentableTextForFile(content, _t("Attachment"), false) }
-                    </span>
+                    <TextWithTooltip
+                        className="mx_MFileBody_info_filename"
+                        tooltip={presentableTextForFile(content, _t("Attachment"), false)}
+                    >
+                        { presentableTextForFile(content, _t("Attachment"), true, true) }
+                    </TextWithTooltip>
                 </AccessibleButton>
             );
         }
diff --git a/src/utils/FileUtils.ts b/src/utils/FileUtils.ts
index 355fa2135c..c83f2ed417 100644
--- a/src/utils/FileUtils.ts
+++ b/src/utils/FileUtils.ts
@@ -26,12 +26,14 @@ import { _t } from '../languageHandler';
  * @param {IMediaEventContent} content The "content" key of the matrix event.
  * @param {string} fallbackText The fallback text
  * @param {boolean} withSize Whether to include size information. Default true.
+ * @param {boolean} shortened Ensure the extension of the file name is visible. Default false.
  * @return {string} the human readable link text for the attachment.
  */
 export function presentableTextForFile(
     content: IMediaEventContent,
     fallbackText = _t("Attachment"),
     withSize = true,
+    shortened = false,
 ): string {
     let text = fallbackText;
     if (content.body && content.body.length > 0) {
@@ -40,6 +42,21 @@ export function presentableTextForFile(
         text = content.body;
     }
 
+    // We shorten to 15 characters somewhat arbitrarily, and assume most files
+    // will have a 3 character (plus full stop) extension. The goal is to knock
+    // the label down to 15-25 characters, not perfect accuracy.
+    if (shortened && text.length > 19) {
+        const parts = text.split('.');
+        let fileName = parts.slice(0, parts.length - 1).join('.').substring(0, 15);
+        const extension = parts[parts.length - 1];
+
+        // Trim off any full stops from the file name to avoid a case where we
+        // add an ellipsis that looks really funky.
+        fileName = fileName.replace(/\.*$/g, '');
+
+        text = `${fileName}...${extension}`;
+    }
+
     if (content.info && content.info.size && withSize) {
         // If we know the size of the file then add it as human readable
         // string to the end of the link text so that the user knows how

From ac1014ae3f046530d0b01dad2f091eb7c2670320 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 29 Jul 2021 15:55:45 -0600
Subject: [PATCH 4/7] Fix file name not ellipsizing

Turns out the tooltip component doesn't copy over class names.
---
 src/components/views/messages/MFileBody.tsx | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index ccf5fe6745..239d3b1316 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -203,11 +203,10 @@ export default class MFileBody extends React.Component<IProps, IState> {
             placeholder = (
                 <AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
                     <span className="mx_MFileBody_info_icon" />
-                    <TextWithTooltip
-                        className="mx_MFileBody_info_filename"
-                        tooltip={presentableTextForFile(content, _t("Attachment"), false)}
-                    >
-                        { presentableTextForFile(content, _t("Attachment"), true, true) }
+                    <TextWithTooltip tooltip={presentableTextForFile(content, _t("Attachment"), false)}>
+                        <span className="mx_MFileBody_info_filename">
+                            { presentableTextForFile(content, _t("Attachment"), true, true) }
+                        </span>
                     </TextWithTooltip>
                 </AccessibleButton>
             );

From b1090a35b5c111fe7181cbb1071bc23b8caf16fd Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 29 Jul 2021 15:57:54 -0600
Subject: [PATCH 5/7] Use new getter for content in MFileBody

---
 src/components/views/messages/MFileBody.tsx | 11 +++++------
 1 file changed, 5 insertions(+), 6 deletions(-)

diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index 239d3b1316..ecc60bc3e3 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -192,20 +192,19 @@ export default class MFileBody extends React.Component<IProps, IState> {
     };
 
     public render() {
-        const content = this.props.mxEvent.getContent<IMediaEventContent>();
         const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
         const contentUrl = this.getContentUrl();
-        const fileSize = content.info ? content.info.size : null;
-        const fileType = content.info ? content.info.mimetype : "application/octet-stream";
+        const fileSize = this.content.info ? this.content.info.size : null;
+        const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream";
 
         let placeholder: React.ReactNode = null;
         if (this.props.showGenericPlaceholder) {
             placeholder = (
                 <AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
                     <span className="mx_MFileBody_info_icon" />
-                    <TextWithTooltip tooltip={presentableTextForFile(content, _t("Attachment"), false)}>
+                    <TextWithTooltip tooltip={presentableTextForFile(this.content, _t("Attachment"), false)}>
                         <span className="mx_MFileBody_info_filename">
-                            { presentableTextForFile(content, _t("Attachment"), true, true) }
+                            { presentableTextForFile(this.content, _t("Attachment"), true, true) }
                         </span>
                     </TextWithTooltip>
                 </AccessibleButton>
@@ -318,7 +317,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
                             { _t("Download %(text)s", { text: this.linkText }) }
                         </a>
                         { this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
-                            { content.info && content.info.size ? filesize(content.info.size) : "" }
+                            { this.content.info && this.content.info.size ? filesize(this.content.info.size) : "" }
                         </div> }
                     </div> }
                 </span>

From a8ec8f4c4b27a0efb61ddc872dcdb84529ea3a08 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 29 Jul 2021 16:01:26 -0600
Subject: [PATCH 6/7] Appease the linter

---
 src/components/views/messages/MFileBody.tsx |  4 ++--
 src/utils/FileDownloader.ts                 | 17 ++++++++---------
 2 files changed, 10 insertions(+), 11 deletions(-)

diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index ecc60bc3e3..13449ff6d9 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -113,7 +113,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
     private iframe: React.RefObject<HTMLIFrameElement> = createRef();
     private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
     private userDidClick = false;
-    private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current)
+    private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current);
 
     public constructor(props: IProps) {
         super(props);
@@ -142,7 +142,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
                 imgSrc: DOWNLOAD_ICON_URL,
                 imgStyle: null,
                 style: computedStyle(this.dummyLink.current),
-                textContent: _t("Download %(text)s", { text })
+                textContent: _t("Download %(text)s", { text }),
             },
         });
     }
diff --git a/src/utils/FileDownloader.ts b/src/utils/FileDownloader.ts
index 0f329e6ada..a22ff506de 100644
--- a/src/utils/FileDownloader.ts
+++ b/src/utils/FileDownloader.ts
@@ -14,21 +14,21 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-export type getIframeFn = () => HTMLIFrameElement;
+export type getIframeFn = () => HTMLIFrameElement; // eslint-disable-line @typescript-eslint/naming-convention
 
 export const DEFAULT_STYLES = {
     imgSrc: "",
     imgStyle: null, // css props
     style: "",
     textContent: "",
-}
+};
 
 type DownloadOptions = {
-    blob: Blob,
-    name: string,
-    autoDownload?: boolean,
-    opts?: typeof DEFAULT_STYLES
-}
+    blob: Blob;
+    name: string;
+    autoDownload?: boolean;
+    opts?: typeof DEFAULT_STYLES;
+};
 
 // set up the iframe as a singleton so we don't have to figure out destruction of it down the line.
 let managedIframe: HTMLIFrameElement;
@@ -90,9 +90,8 @@ export class FileDownloader {
         return iframe;
     }
 
-    public async download({blob, name, autoDownload = true, opts = DEFAULT_STYLES}: DownloadOptions) {
+    public async download({ blob, name, autoDownload = true, opts = DEFAULT_STYLES }: DownloadOptions) {
         const iframe = this.iframe; // get the iframe first just in case we need to await onload
-        console.log("@@", {blob, name, autoDownload, opts, iframe, m: iframe === managedIframe, p: this.onLoadPromise});
         if (this.onLoadPromise) await this.onLoadPromise;
         iframe.contentWindow.postMessage({
             ...opts,

From c5c58a5e91bec4e72a32d13fc2d74496e5711480 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Fri, 30 Jul 2021 08:56:55 -0600
Subject: [PATCH 7/7] Add file size to tooltip

---
 src/components/views/messages/MFileBody.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index 13449ff6d9..13fc4b01e7 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -202,7 +202,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
             placeholder = (
                 <AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
                     <span className="mx_MFileBody_info_icon" />
-                    <TextWithTooltip tooltip={presentableTextForFile(this.content, _t("Attachment"), false)}>
+                    <TextWithTooltip tooltip={presentableTextForFile(this.content, _t("Attachment"), true)}>
                         <span className="mx_MFileBody_info_filename">
                             { presentableTextForFile(this.content, _t("Attachment"), true, true) }
                         </span>