From 0a99f76e7fc91be25a379a47f2217b2ae8f9fa4c Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Wed, 14 Jul 2021 20:51:20 -0600
Subject: [PATCH 01/16] Simple POC for moving download button to action bar

---
 src/components/views/messages/IMediaBody.ts   | 32 +++++++
 src/components/views/messages/MVideoBody.tsx  | 50 ++++++-----
 .../views/messages/MessageActionBar.js        | 22 +++++
 src/components/views/messages/MessageEvent.js |  8 ++
 src/utils/LazyValue.ts                        | 59 ++++++++++++
 src/utils/MediaEventHelper.ts                 | 90 +++++++++++++++++++
 6 files changed, 237 insertions(+), 24 deletions(-)
 create mode 100644 src/components/views/messages/IMediaBody.ts
 create mode 100644 src/utils/LazyValue.ts
 create mode 100644 src/utils/MediaEventHelper.ts

diff --git a/src/components/views/messages/IMediaBody.ts b/src/components/views/messages/IMediaBody.ts
new file mode 100644
index 0000000000..dcbdfff284
--- /dev/null
+++ b/src/components/views/messages/IMediaBody.ts
@@ -0,0 +1,32 @@
+/*
+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.
+*/
+
+import EventTile from "../rooms/EventTile";
+import { MediaEventHelper } from "../../../utils/MediaEventHelper";
+
+export interface IMediaBody {
+    getMediaHelper(): MediaEventHelper;
+}
+
+export function canTileDownload(tile: EventTile): boolean {
+    if (!tile) return false;
+
+    // Cast so we can check for IMediaBody interface safely.
+    // Note that we don't cast to the IMediaBody interface as that causes IDEs
+    // to complain about conditions always being true.
+    const tileAsAny = <any>tile;
+    return !!tileAsAny.getMediaHelper;
+}
diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx
index d882bb1eb0..bb58a13c4d 100644
--- a/src/components/views/messages/MVideoBody.tsx
+++ b/src/components/views/messages/MVideoBody.tsx
@@ -26,10 +26,14 @@ import InlineSpinner from '../elements/InlineSpinner';
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { mediaFromContent } from "../../../customisations/Media";
 import { BLURHASH_FIELD } from "../../../ContentMessages";
+import { IMediaBody } from "./IMediaBody";
+import { MediaEventHelper } from "../../../utils/MediaEventHelper";
+import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
+import { MatrixEvent } from "matrix-js-sdk/src";
 
 interface IProps {
     /* the MatrixEvent to show */
-    mxEvent: any;
+    mxEvent: MatrixEvent;
     /* called when the video has loaded */
     onHeightChanged: () => void;
 }
@@ -45,11 +49,13 @@ interface IState {
 }
 
 @replaceableComponent("views.messages.MVideoBody")
-export default class MVideoBody extends React.PureComponent<IProps, IState> {
+export default class MVideoBody extends React.PureComponent<IProps, IState> implements IMediaBody {
     private videoRef = React.createRef<HTMLVideoElement>();
+    private mediaHelper: MediaEventHelper;
 
     constructor(props) {
         super(props);
+
         this.state = {
             fetchingData: false,
             decryptedUrl: null,
@@ -59,6 +65,8 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
             posterLoading: false,
             blurhashUrl: null,
         };
+
+        this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
     }
 
     thumbScale(fullWidth: number, fullHeight: number, thumbWidth = 480, thumbHeight = 360) {
@@ -82,6 +90,10 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
         }
     }
 
+    public getMediaHelper(): MediaEventHelper {
+        return this.mediaHelper;
+    }
+
     private getContentUrl(): string|null {
         const media = mediaFromContent(this.props.mxEvent.getContent());
         if (media.isEncrypted) {
@@ -97,7 +109,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
     }
 
     private getThumbUrl(): string|null {
-        const content = this.props.mxEvent.getContent();
+        const content = this.props.mxEvent.getContent<IMediaEventContent>();
         const media = mediaFromContent(content);
 
         if (media.isEncrypted && this.state.decryptedThumbnailUrl) {
@@ -139,7 +151,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
             posterLoading: true,
         });
 
-        const content = this.props.mxEvent.getContent();
+        const content = this.props.mxEvent.getContent<IMediaEventContent>();
         const media = mediaFromContent(content);
         if (media.hasThumbnail) {
             const image = new Image();
@@ -152,30 +164,22 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
 
     async componentDidMount() {
         const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
-        const content = this.props.mxEvent.getContent();
         this.loadBlurhash();
 
-        if (content.file !== undefined && this.state.decryptedUrl === null) {
-            let thumbnailPromise = Promise.resolve(null);
-            if (content?.info?.thumbnail_file) {
-                thumbnailPromise = decryptFile(content.info.thumbnail_file)
-                    .then(blob => URL.createObjectURL(blob));
-            }
-
+        if (this.mediaHelper.media.isEncrypted && this.state.decryptedUrl === null) {
             try {
-                const thumbnailUrl = await thumbnailPromise;
+                const thumbnailUrl = await this.mediaHelper.thumbnailUrl.value;
                 if (autoplay) {
                     console.log("Preloading video");
-                    const decryptedBlob = await decryptFile(content.file);
-                    const contentUrl = URL.createObjectURL(decryptedBlob);
                     this.setState({
-                        decryptedUrl: contentUrl,
+                        decryptedUrl: await this.mediaHelper.sourceUrl.value,
                         decryptedThumbnailUrl: thumbnailUrl,
-                        decryptedBlob: decryptedBlob,
+                        decryptedBlob: await this.mediaHelper.sourceBlob.value,
                     });
                     this.props.onHeightChanged();
                 } else {
                     console.log("NOT preloading video");
+                    const content = this.props.mxEvent.getContent<IMediaEventContent>();
                     this.setState({
                         // For Chrome and Electron, we need to set some non-empty `src` to
                         // enable the play button. Firefox does not seem to care either
@@ -202,6 +206,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
         if (this.state.decryptedThumbnailUrl) {
             URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
         }
+        this.mediaHelper.destroy();
     }
 
     private videoOnPlay = async () => {
@@ -213,18 +218,15 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
             // To stop subsequent download attempts
             fetchingData: true,
         });
-        const content = this.props.mxEvent.getContent();
-        if (!content.file) {
+        if (!this.mediaHelper.media.isEncrypted) {
             this.setState({
                 error: "No file given in content",
             });
             return;
         }
-        const decryptedBlob = await decryptFile(content.file);
-        const contentUrl = URL.createObjectURL(decryptedBlob);
         this.setState({
-            decryptedUrl: contentUrl,
-            decryptedBlob: decryptedBlob,
+            decryptedUrl: await this.mediaHelper.sourceUrl.value,
+            decryptedBlob: await this.mediaHelper.sourceBlob.value,
             fetchingData: false,
         }, () => {
             if (!this.videoRef.current) return;
@@ -295,7 +297,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
                     onPlay={this.videoOnPlay}
                 >
                 </video>
-                <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
+                {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/}
             </span>
         );
     }
diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js
index 7532554666..13854aebfc 100644
--- a/src/components/views/messages/MessageActionBar.js
+++ b/src/components/views/messages/MessageActionBar.js
@@ -32,6 +32,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { canCancel } from "../context_menus/MessageContextMenu";
 import Resend from "../../../Resend";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
+import { canTileDownload } from "./IMediaBody";
 
 const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
     const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@@ -175,6 +176,16 @@ export default class MessageActionBar extends React.PureComponent {
         });
     };
 
+    onDownloadClick = async (ev) => {
+        // TODO: Maybe just call into MFileBody and render it as null
+        const src = this.props.getTile().getMediaHelper();
+        const a = document.createElement("a");
+        a.href = await src.sourceUrl.value;
+        a.download = "todo.png";
+        a.target = "_blank";
+        a.click();
+    };
+
     /**
      * Runs a given fn on the set of possible events to test. The first event
      * that passes the checkFn will have fn executed on it. Both functions take
@@ -267,6 +278,17 @@ export default class MessageActionBar extends React.PureComponent {
                         key="react"
                     />);
                 }
+
+                const tile = this.props.getTile && this.props.getTile();
+                if (canTileDownload(tile)) {
+                    toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
+                        className="mx_MessageActionBar_maskButton mx_MessageActionBar_downloadButton"
+                        title={_t("Download")}
+                        onClick={this.onDownloadClick}
+                        disabled={false}
+                        key="download"
+                    />);
+                }
             }
 
             if (allowCancel) {
diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js
index cd071ebb34..49b50b610c 100644
--- a/src/components/views/messages/MessageEvent.js
+++ b/src/components/views/messages/MessageEvent.js
@@ -22,6 +22,7 @@ import { Mjolnir } from "../../../mjolnir/Mjolnir";
 import RedactedBody from "./RedactedBody";
 import UnknownBody from "./UnknownBody";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
+import { IMediaBody } from "./IMediaBody";
 
 @replaceableComponent("views.messages.MessageEvent")
 export default class MessageEvent extends React.Component {
@@ -69,6 +70,13 @@ export default class MessageEvent extends React.Component {
         this.forceUpdate();
     };
 
+    getMediaHelper() {
+        if (!this._body.current || !this._body.current.getMediaHelper) {
+            return null;
+        }
+        return this._body.current.getMediaHelper();
+    }
+
     render() {
         const bodyTypes = {
             'm.text': sdk.getComponent('messages.TextualBody'),
diff --git a/src/utils/LazyValue.ts b/src/utils/LazyValue.ts
new file mode 100644
index 0000000000..9cdcda489a
--- /dev/null
+++ b/src/utils/LazyValue.ts
@@ -0,0 +1,59 @@
+/*
+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.
+*/
+
+/**
+ * Utility class for lazily getting a variable.
+ */
+export class LazyValue<T> {
+    private val: T;
+    private prom: Promise<T>;
+    private done = false;
+
+    public constructor(private getFn: () => Promise<T>) {
+    }
+
+    /**
+     * Whether or not a cached value is present.
+     */
+    public get present(): boolean {
+        // we use a tracking variable just in case the final value is falsey
+        return this.done;
+    }
+
+    /**
+     * Gets the value without invoking a get. May be undefined until the
+     * value is fetched properly.
+     */
+    public get cachedValue(): T {
+        return this.val;
+    }
+
+    /**
+     * Gets a promise which resolves to the value, eventually.
+     */
+    public get value(): Promise<T> {
+        if (this.prom) return this.prom;
+        this.prom = this.getFn();
+
+        // Fork the promise chain to avoid accidentally making it return undefined always.
+        this.prom.then(v => {
+            this.val = v;
+            this.done = true;
+        });
+
+        return this.prom;
+    }
+}
diff --git a/src/utils/MediaEventHelper.ts b/src/utils/MediaEventHelper.ts
new file mode 100644
index 0000000000..316ee54edf
--- /dev/null
+++ b/src/utils/MediaEventHelper.ts
@@ -0,0 +1,90 @@
+/*
+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.
+*/
+
+import { MatrixEvent } from "matrix-js-sdk/src";
+import { LazyValue } from "./LazyValue";
+import { Media, mediaFromContent } from "../customisations/Media";
+import { decryptFile } from "./DecryptFile";
+import { IMediaEventContent } from "../customisations/models/IMediaEventContent";
+import { IDestroyable } from "./IDestroyable";
+
+// TODO: We should consider caching the blobs. https://github.com/vector-im/element-web/issues/17192
+
+export class MediaEventHelper implements IDestroyable {
+    public readonly sourceUrl: LazyValue<string>;
+    public readonly thumbnailUrl: LazyValue<string>;
+    public readonly sourceBlob: LazyValue<Blob>;
+    public readonly thumbnailBlob: LazyValue<Blob>;
+    public readonly media: Media;
+
+    public constructor(private event: MatrixEvent) {
+        this.sourceUrl = new LazyValue(this.prepareSourceUrl);
+        this.thumbnailUrl = new LazyValue(this.prepareThumbnailUrl);
+        this.sourceBlob = new LazyValue(this.fetchSource);
+        this.thumbnailBlob = new LazyValue(this.fetchThumbnail);
+
+        this.media = mediaFromContent(this.event.getContent());
+    }
+
+    public destroy() {
+        if (this.media.isEncrypted) {
+            if (this.sourceUrl.present) URL.revokeObjectURL(this.sourceUrl.cachedValue);
+            if (this.thumbnailUrl.present) URL.revokeObjectURL(this.thumbnailUrl.cachedValue);
+        }
+    }
+
+    private prepareSourceUrl = async () => {
+        if (this.media.isEncrypted) {
+            const blob = await this.sourceBlob.value;
+            return URL.createObjectURL(blob);
+        } else {
+            return this.media.srcHttp;
+        }
+    };
+
+    private prepareThumbnailUrl = async () => {
+        if (this.media.isEncrypted) {
+            const blob = await this.thumbnailBlob.value;
+            return URL.createObjectURL(blob);
+        } else {
+            return this.media.thumbnailHttp;
+        }
+    };
+
+    private fetchSource = () => {
+        if (this.media.isEncrypted) {
+            return decryptFile(this.event.getContent<IMediaEventContent>().file);
+        }
+        return this.media.downloadSource().then(r => r.blob());
+    };
+
+    private fetchThumbnail = () => {
+        if (!this.media.hasThumbnail) return Promise.resolve(null);
+
+        if (this.media.isEncrypted) {
+            const content = this.event.getContent<IMediaEventContent>();
+            if (content.info?.thumbnail_file) {
+                return decryptFile(content.info.thumbnail_file);
+            } else {
+                // "Should never happen"
+                console.warn("Media claims to have thumbnail and is encrypted, but no thumbnail_file found");
+                return Promise.resolve(null);
+            }
+        }
+
+        return fetch(this.media.thumbnailHttp).then(r => r.blob());
+    };
+}

From 703cf7375912898597ec42a92ca85833c242e79a Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 15 Jul 2021 14:19:07 -0600
Subject: [PATCH 02/16] Convert MessageEvent to TS and hoist MediaEventHelper

---
 src/@types/common.ts                          |   3 +-
 .../context_menus/MessageContextMenu.tsx      |   6 +-
 src/components/views/messages/MVideoBody.tsx  |  37 +---
 src/components/views/messages/MessageEvent.js | 146 ---------------
 .../views/messages/MessageEvent.tsx           | 176 ++++++++++++++++++
 src/utils/MediaEventHelper.ts                 |  21 +++
 6 files changed, 213 insertions(+), 176 deletions(-)
 delete mode 100644 src/components/views/messages/MessageEvent.js
 create mode 100644 src/components/views/messages/MessageEvent.tsx

diff --git a/src/@types/common.ts b/src/@types/common.ts
index 1fb9ba4303..36ef7a9ace 100644
--- a/src/@types/common.ts
+++ b/src/@types/common.ts
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { JSXElementConstructor } from "react";
+import React, { JSXElementConstructor } from "react";
 
 // Based on https://stackoverflow.com/a/53229857/3532235
 export type Without<T, U> = {[P in Exclude<keyof T, keyof U>]?: never};
@@ -22,3 +22,4 @@ export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<
 export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
 
 export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>;
+export type ReactAnyComponent = React.Component | React.ExoticComponent;
diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx
index 999e98f4ad..7092be43e9 100644
--- a/src/components/views/context_menus/MessageContextMenu.tsx
+++ b/src/components/views/context_menus/MessageContextMenu.tsx
@@ -43,11 +43,15 @@ export function canCancel(eventStatus: EventStatus): boolean {
     return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
 }
 
-interface IEventTileOps {
+export interface IEventTileOps {
     isWidgetHidden(): boolean;
     unhideWidget(): void;
 }
 
+export interface IOperableEventTile {
+    getEventTileOps(): IEventTileOps;
+}
+
 interface IProps {
     /* the MatrixEvent associated with the context menu */
     mxEvent: MatrixEvent;
diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx
index bb58a13c4d..2b873f6506 100644
--- a/src/components/views/messages/MVideoBody.tsx
+++ b/src/components/views/messages/MVideoBody.tsx
@@ -18,15 +18,12 @@ limitations under the License.
 import React from 'react';
 import { decode } from "blurhash";
 
-import MFileBody from './MFileBody';
-import { decryptFile } from '../../../utils/DecryptFile';
 import { _t } from '../../../languageHandler';
 import SettingsStore from "../../../settings/SettingsStore";
 import InlineSpinner from '../elements/InlineSpinner';
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { mediaFromContent } from "../../../customisations/Media";
 import { BLURHASH_FIELD } from "../../../ContentMessages";
-import { IMediaBody } from "./IMediaBody";
 import { MediaEventHelper } from "../../../utils/MediaEventHelper";
 import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
 import { MatrixEvent } from "matrix-js-sdk/src";
@@ -36,6 +33,7 @@ interface IProps {
     mxEvent: MatrixEvent;
     /* called when the video has loaded */
     onHeightChanged: () => void;
+    mediaEventHelper: MediaEventHelper;
 }
 
 interface IState {
@@ -49,9 +47,8 @@ interface IState {
 }
 
 @replaceableComponent("views.messages.MVideoBody")
-export default class MVideoBody extends React.PureComponent<IProps, IState> implements IMediaBody {
+export default class MVideoBody extends React.PureComponent<IProps, IState> {
     private videoRef = React.createRef<HTMLVideoElement>();
-    private mediaHelper: MediaEventHelper;
 
     constructor(props) {
         super(props);
@@ -65,8 +62,6 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> impl
             posterLoading: false,
             blurhashUrl: null,
         };
-
-        this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
     }
 
     thumbScale(fullWidth: number, fullHeight: number, thumbWidth = 480, thumbHeight = 360) {
@@ -90,10 +85,6 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> impl
         }
     }
 
-    public getMediaHelper(): MediaEventHelper {
-        return this.mediaHelper;
-    }
-
     private getContentUrl(): string|null {
         const media = mediaFromContent(this.props.mxEvent.getContent());
         if (media.isEncrypted) {
@@ -166,15 +157,15 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> impl
         const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
         this.loadBlurhash();
 
-        if (this.mediaHelper.media.isEncrypted && this.state.decryptedUrl === null) {
+        if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
             try {
-                const thumbnailUrl = await this.mediaHelper.thumbnailUrl.value;
+                const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
                 if (autoplay) {
                     console.log("Preloading video");
                     this.setState({
-                        decryptedUrl: await this.mediaHelper.sourceUrl.value,
+                        decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
                         decryptedThumbnailUrl: thumbnailUrl,
-                        decryptedBlob: await this.mediaHelper.sourceBlob.value,
+                        decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
                     });
                     this.props.onHeightChanged();
                 } else {
@@ -199,16 +190,6 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> impl
         }
     }
 
-    componentWillUnmount() {
-        if (this.state.decryptedUrl) {
-            URL.revokeObjectURL(this.state.decryptedUrl);
-        }
-        if (this.state.decryptedThumbnailUrl) {
-            URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
-        }
-        this.mediaHelper.destroy();
-    }
-
     private videoOnPlay = async () => {
         if (this.hasContentUrl() || this.state.fetchingData || this.state.error) {
             // We have the file, we are fetching the file, or there is an error.
@@ -218,15 +199,15 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> impl
             // To stop subsequent download attempts
             fetchingData: true,
         });
-        if (!this.mediaHelper.media.isEncrypted) {
+        if (!this.props.mediaEventHelper.media.isEncrypted) {
             this.setState({
                 error: "No file given in content",
             });
             return;
         }
         this.setState({
-            decryptedUrl: await this.mediaHelper.sourceUrl.value,
-            decryptedBlob: await this.mediaHelper.sourceBlob.value,
+            decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
+            decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
             fetchingData: false,
         }, () => {
             if (!this.videoRef.current) return;
diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js
deleted file mode 100644
index 49b50b610c..0000000000
--- a/src/components/views/messages/MessageEvent.js
+++ /dev/null
@@ -1,146 +0,0 @@
-/*
-Copyright 2015, 2016 OpenMarket Ltd
-
-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.
-*/
-
-import React, { createRef } from 'react';
-import PropTypes from 'prop-types';
-import * as sdk from '../../../index';
-import SettingsStore from "../../../settings/SettingsStore";
-import { Mjolnir } from "../../../mjolnir/Mjolnir";
-import RedactedBody from "./RedactedBody";
-import UnknownBody from "./UnknownBody";
-import { replaceableComponent } from "../../../utils/replaceableComponent";
-import { IMediaBody } from "./IMediaBody";
-
-@replaceableComponent("views.messages.MessageEvent")
-export default class MessageEvent extends React.Component {
-    static propTypes = {
-        /* the MatrixEvent to show */
-        mxEvent: PropTypes.object.isRequired,
-
-        /* a list of words to highlight */
-        highlights: PropTypes.array,
-
-        /* link URL for the highlights */
-        highlightLink: PropTypes.string,
-
-        /* should show URL previews for this event */
-        showUrlPreview: PropTypes.bool,
-
-        /* callback called when dynamic content in events are loaded */
-        onHeightChanged: PropTypes.func,
-
-        /* the shape of the tile, used */
-        tileShape: PropTypes.string, // TODO: Use TileShape enum
-
-        /* the maximum image height to use, if the event is an image */
-        maxImageHeight: PropTypes.number,
-
-        /* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
-        overrideBodyTypes: PropTypes.object,
-        overrideEventTypes: PropTypes.object,
-
-        /* the permalinkCreator */
-        permalinkCreator: PropTypes.object,
-    };
-
-    constructor(props) {
-        super(props);
-
-        this._body = createRef();
-    }
-
-    getEventTileOps = () => {
-        return this._body.current && this._body.current.getEventTileOps ? this._body.current.getEventTileOps() : null;
-    };
-
-    onTileUpdate = () => {
-        this.forceUpdate();
-    };
-
-    getMediaHelper() {
-        if (!this._body.current || !this._body.current.getMediaHelper) {
-            return null;
-        }
-        return this._body.current.getMediaHelper();
-    }
-
-    render() {
-        const bodyTypes = {
-            'm.text': sdk.getComponent('messages.TextualBody'),
-            'm.notice': sdk.getComponent('messages.TextualBody'),
-            'm.emote': sdk.getComponent('messages.TextualBody'),
-            'm.image': sdk.getComponent('messages.MImageBody'),
-            'm.file': sdk.getComponent('messages.MFileBody'),
-            'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
-            'm.video': sdk.getComponent('messages.MVideoBody'),
-
-            ...(this.props.overrideBodyTypes || {}),
-        };
-        const evTypes = {
-            'm.sticker': sdk.getComponent('messages.MStickerBody'),
-            ...(this.props.overrideEventTypes || {}),
-        };
-
-        const content = this.props.mxEvent.getContent();
-        const type = this.props.mxEvent.getType();
-        const msgtype = content.msgtype;
-        let BodyType = RedactedBody;
-        if (!this.props.mxEvent.isRedacted()) {
-            // only resolve BodyType if event is not redacted
-            if (type && evTypes[type]) {
-                BodyType = evTypes[type];
-            } else if (msgtype && bodyTypes[msgtype]) {
-                BodyType = bodyTypes[msgtype];
-            } else if (content.url) {
-                // Fallback to MFileBody if there's a content URL
-                BodyType = bodyTypes['m.file'];
-            } else {
-                // Fallback to UnknownBody otherwise if not redacted
-                BodyType = UnknownBody;
-            }
-        }
-
-        if (SettingsStore.getValue("feature_mjolnir")) {
-            const key = `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`;
-            const allowRender = localStorage.getItem(key) === "true";
-
-            if (!allowRender) {
-                const userDomain = this.props.mxEvent.getSender().split(':').slice(1).join(':');
-                const userBanned = Mjolnir.sharedInstance().isUserBanned(this.props.mxEvent.getSender());
-                const serverBanned = Mjolnir.sharedInstance().isServerBanned(userDomain);
-
-                if (userBanned || serverBanned) {
-                    BodyType = sdk.getComponent('messages.MjolnirBody');
-                }
-            }
-        }
-
-        return BodyType ? <BodyType
-            ref={this._body}
-            mxEvent={this.props.mxEvent}
-            highlights={this.props.highlights}
-            highlightLink={this.props.highlightLink}
-            showUrlPreview={this.props.showUrlPreview}
-            tileShape={this.props.tileShape}
-            maxImageHeight={this.props.maxImageHeight}
-            replacingEventId={this.props.replacingEventId}
-            editState={this.props.editState}
-            onHeightChanged={this.props.onHeightChanged}
-            onMessageAllowed={this.onTileUpdate}
-            permalinkCreator={this.props.permalinkCreator}
-        /> : null;
-    }
-}
diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx
new file mode 100644
index 0000000000..3c59e68c8b
--- /dev/null
+++ b/src/components/views/messages/MessageEvent.tsx
@@ -0,0 +1,176 @@
+/*
+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.
+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.
+*/
+
+import React, { createRef } from 'react';
+import * as sdk from '../../../index';
+import SettingsStore from "../../../settings/SettingsStore";
+import { Mjolnir } from "../../../mjolnir/Mjolnir";
+import RedactedBody from "./RedactedBody";
+import UnknownBody from "./UnknownBody";
+import { replaceableComponent } from "../../../utils/replaceableComponent";
+import { IMediaBody } from "./IMediaBody";
+import { MatrixEvent } from "matrix-js-sdk/src";
+import { TileShape } from "../rooms/EventTile";
+import PermalinkConstructor from "../../../utils/permalinks/PermalinkConstructor";
+import { IOperableEventTile } from "../context_menus/MessageContextMenu";
+import { MediaEventHelper } from "../../../utils/MediaEventHelper";
+import { ReactAnyComponent } from "../../../@types/common";
+import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
+
+interface IProps {
+    /* the MatrixEvent to show */
+    mxEvent: MatrixEvent;
+
+    /* a list of words to highlight */
+    highlights: string[];
+
+    /* link URL for the highlights */
+    highlightLink: string;
+
+    /* should show URL previews for this event */
+    showUrlPreview: boolean;
+
+    /* callback called when dynamic content in events are loaded */
+    onHeightChanged: () => void;
+
+    /* the shape of the tile, used */
+    tileShape: TileShape;
+
+    /* the maximum image height to use, if the event is an image */
+    maxImageHeight?: number;
+
+    /* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
+    overrideBodyTypes?: Record<string, React.Component>;
+    overrideEventTypes?: Record<string, React.Component>;
+
+    /* the permalinkCreator */
+    permalinkCreator: PermalinkConstructor;
+
+    replacingEventId?: string;
+    editState?: unknown;
+}
+
+@replaceableComponent("views.messages.MessageEvent")
+export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile {
+    private body: React.RefObject<React.Component | IOperableEventTile> = createRef();
+    private mediaHelper: MediaEventHelper;
+
+    public constructor(props: IProps) {
+        super(props);
+
+        if (MediaEventHelper.isEligible(this.props.mxEvent)) {
+            this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
+        }
+    }
+
+    public componentWillUnmount() {
+        this.mediaHelper?.destroy();
+    }
+
+    public componentDidUpdate(prevProps: Readonly<IProps>) {
+        if (this.props.mxEvent !== prevProps.mxEvent && MediaEventHelper.isEligible(this.props.mxEvent)) {
+            this.mediaHelper?.destroy();
+            this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
+        }
+    }
+
+    private get bodyTypes(): Record<string, React.Component> {
+        return {
+            [MsgType.Text]: sdk.getComponent('messages.TextualBody'),
+            [MsgType.Notice]: sdk.getComponent('messages.TextualBody'),
+            [MsgType.Emote]: sdk.getComponent('messages.TextualBody'),
+            [MsgType.Image]: sdk.getComponent('messages.MImageBody'),
+            [MsgType.File]: sdk.getComponent('messages.MFileBody'),
+            [MsgType.Audio]: sdk.getComponent('messages.MVoiceOrAudioBody'),
+            [MsgType.Video]: sdk.getComponent('messages.MVideoBody'),
+
+            ...(this.props.overrideBodyTypes || {}),
+        };
+    }
+
+    private get evTypes(): Record<string, React.Component> {
+        return {
+            [EventType.Sticker]: sdk.getComponent('messages.MStickerBody'),
+
+            ...(this.props.overrideEventTypes || {}),
+        };
+    }
+
+    public getEventTileOps = () => {
+        return (this.body.current as IOperableEventTile)?.getEventTileOps?.() || null;
+    };
+
+    public getMediaHelper() {
+        return this.mediaHelper;
+    }
+
+    private onTileUpdate = () => {
+        this.forceUpdate();
+    };
+
+    public render() {
+        const content = this.props.mxEvent.getContent();
+        const type = this.props.mxEvent.getType();
+        const msgtype = content.msgtype;
+        let BodyType: ReactAnyComponent = RedactedBody;
+        if (!this.props.mxEvent.isRedacted()) {
+            // only resolve BodyType if event is not redacted
+            if (type && this.evTypes[type]) {
+                BodyType = this.evTypes[type];
+            } else if (msgtype && this.bodyTypes[msgtype]) {
+                BodyType = this.bodyTypes[msgtype];
+            } else if (content.url) {
+                // Fallback to MFileBody if there's a content URL
+                BodyType = this.bodyTypes[MsgType.File];
+            } else {
+                // Fallback to UnknownBody otherwise if not redacted
+                BodyType = UnknownBody;
+            }
+        }
+
+        if (SettingsStore.getValue("feature_mjolnir")) {
+            const key = `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`;
+            const allowRender = localStorage.getItem(key) === "true";
+
+            if (!allowRender) {
+                const userDomain = this.props.mxEvent.getSender().split(':').slice(1).join(':');
+                const userBanned = Mjolnir.sharedInstance().isUserBanned(this.props.mxEvent.getSender());
+                const serverBanned = Mjolnir.sharedInstance().isServerBanned(userDomain);
+
+                if (userBanned || serverBanned) {
+                    BodyType = sdk.getComponent('messages.MjolnirBody');
+                }
+            }
+        }
+
+        // @ts-ignore - this is a dynamic react component
+        return BodyType ? <BodyType
+            ref={this.body}
+            mxEvent={this.props.mxEvent}
+            highlights={this.props.highlights}
+            highlightLink={this.props.highlightLink}
+            showUrlPreview={this.props.showUrlPreview}
+            tileShape={this.props.tileShape}
+            maxImageHeight={this.props.maxImageHeight}
+            replacingEventId={this.props.replacingEventId}
+            editState={this.props.editState}
+            onHeightChanged={this.props.onHeightChanged}
+            onMessageAllowed={this.onTileUpdate}
+            permalinkCreator={this.props.permalinkCreator}
+            mediaEventHelper={this.mediaHelper}
+        /> : null;
+    }
+}
diff --git a/src/utils/MediaEventHelper.ts b/src/utils/MediaEventHelper.ts
index 316ee54edf..b4deb1a8ce 100644
--- a/src/utils/MediaEventHelper.ts
+++ b/src/utils/MediaEventHelper.ts
@@ -20,6 +20,7 @@ import { Media, mediaFromContent } from "../customisations/Media";
 import { decryptFile } from "./DecryptFile";
 import { IMediaEventContent } from "../customisations/models/IMediaEventContent";
 import { IDestroyable } from "./IDestroyable";
+import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
 
 // TODO: We should consider caching the blobs. https://github.com/vector-im/element-web/issues/17192
 
@@ -87,4 +88,24 @@ export class MediaEventHelper implements IDestroyable {
 
         return fetch(this.media.thumbnailHttp).then(r => r.blob());
     };
+
+    public static isEligible(event: MatrixEvent): boolean {
+        if (!event) return false;
+        if (event.isRedacted()) return false;
+        if (event.getType() === EventType.Sticker) return true;
+        if (event.getType() !== EventType.RoomMessage) return false;
+
+        const content = event.getContent();
+        const mediaMsgTypes: string[] = [
+            MsgType.Video,
+            MsgType.Audio,
+            MsgType.Image,
+            MsgType.File,
+        ];
+        if (mediaMsgTypes.includes(content.msgtype)) return true;
+        if (typeof(content.url) === 'string') return true;
+
+        // Finally, it's probably not media
+        return false;
+    }
 }

From 584ffbd32777199263ad67c6b5331edc1a03b6a2 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 15 Jul 2021 14:25:43 -0600
Subject: [PATCH 03/16] Fix refreshing the page not showing a download

---
 src/components/views/messages/MessageActionBar.js | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js
index 13854aebfc..730a929ddd 100644
--- a/src/components/views/messages/MessageActionBar.js
+++ b/src/components/views/messages/MessageActionBar.js
@@ -33,6 +33,7 @@ import { canCancel } from "../context_menus/MessageContextMenu";
 import Resend from "../../../Resend";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
 import { canTileDownload } from "./IMediaBody";
+import {MediaEventHelper} from "../../../utils/MediaEventHelper";
 
 const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
     const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@@ -177,6 +178,11 @@ export default class MessageActionBar extends React.PureComponent {
     };
 
     onDownloadClick = async (ev) => {
+        if (!this.props.getTile || !this.props.getTile().getMediaHelper) {
+            console.warn("Action bar triggered a download but the event tile is missing a media helper");
+            return;
+        }
+
         // TODO: Maybe just call into MFileBody and render it as null
         const src = this.props.getTile().getMediaHelper();
         const a = document.createElement("a");
@@ -279,8 +285,8 @@ export default class MessageActionBar extends React.PureComponent {
                     />);
                 }
 
-                const tile = this.props.getTile && this.props.getTile();
-                if (canTileDownload(tile)) {
+                // XXX: Assuming that the underlying tile will be a media event if it is eligible media.
+                if (MediaEventHelper.isEligible(this.props.mxEvent)) {
                     toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
                         className="mx_MessageActionBar_maskButton mx_MessageActionBar_downloadButton"
                         title={_t("Download")}

From 040802e29f0d1935672a693ac2f83ca2d8db0786 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 15 Jul 2021 14:34:40 -0600
Subject: [PATCH 04/16] Clean up after POC

---
 src/components/views/messages/IMediaBody.ts       | 11 -----------
 src/components/views/messages/MessageActionBar.js |  3 +--
 2 files changed, 1 insertion(+), 13 deletions(-)

diff --git a/src/components/views/messages/IMediaBody.ts b/src/components/views/messages/IMediaBody.ts
index dcbdfff284..27b5f24275 100644
--- a/src/components/views/messages/IMediaBody.ts
+++ b/src/components/views/messages/IMediaBody.ts
@@ -14,19 +14,8 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import EventTile from "../rooms/EventTile";
 import { MediaEventHelper } from "../../../utils/MediaEventHelper";
 
 export interface IMediaBody {
     getMediaHelper(): MediaEventHelper;
 }
-
-export function canTileDownload(tile: EventTile): boolean {
-    if (!tile) return false;
-
-    // Cast so we can check for IMediaBody interface safely.
-    // Note that we don't cast to the IMediaBody interface as that causes IDEs
-    // to complain about conditions always being true.
-    const tileAsAny = <any>tile;
-    return !!tileAsAny.getMediaHelper;
-}
diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js
index 730a929ddd..1cb86f168d 100644
--- a/src/components/views/messages/MessageActionBar.js
+++ b/src/components/views/messages/MessageActionBar.js
@@ -32,8 +32,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { canCancel } from "../context_menus/MessageContextMenu";
 import Resend from "../../../Resend";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
-import { canTileDownload } from "./IMediaBody";
-import {MediaEventHelper} from "../../../utils/MediaEventHelper";
+import { MediaEventHelper } from "../../../utils/MediaEventHelper";
 
 const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
     const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();

From 5fce0ccd9d23e5dd8c5114b4cf2a50f506f608a5 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 15 Jul 2021 16:37:48 -0600
Subject: [PATCH 05/16] Convert images, audio, and voice messages over to the
 new helper

---
 src/components/views/messages/MAudioBody.tsx  | 43 +++++++------
 src/components/views/messages/MImageBody.tsx  | 51 +++++----------
 .../views/messages/MVoiceMessageBody.tsx      | 64 ++-----------------
 3 files changed, 41 insertions(+), 117 deletions(-)

diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx
index bc7216f42c..4f688fd136 100644
--- a/src/components/views/messages/MAudioBody.tsx
+++ b/src/components/views/messages/MAudioBody.tsx
@@ -18,22 +18,22 @@ import React from "react";
 import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { Playback } from "../../../voice/Playback";
-import MFileBody from "./MFileBody";
 import InlineSpinner from '../elements/InlineSpinner';
 import { _t } from "../../../languageHandler";
-import { mediaFromContent } from "../../../customisations/Media";
-import { decryptFile } from "../../../utils/DecryptFile";
-import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
 import AudioPlayer from "../audio_messages/AudioPlayer";
+import { MediaEventHelper } from "../../../utils/MediaEventHelper";
+import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
+import { TileShape } from "../rooms/EventTile";
 
 interface IProps {
     mxEvent: MatrixEvent;
+    tileShape?: TileShape;
+    mediaEventHelper: MediaEventHelper;
 }
 
 interface IState {
     error?: Error;
     playback?: Playback;
-    decryptedBlob?: Blob;
 }
 
 @replaceableComponent("views.messages.MAudioBody")
@@ -46,33 +46,34 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
 
     public async componentDidMount() {
         let buffer: ArrayBuffer;
-        const content: IMediaEventContent = this.props.mxEvent.getContent();
-        const media = mediaFromContent(content);
-        if (media.isEncrypted) {
+
+        try {
             try {
-                const blob = await decryptFile(content.file);
+                const blob = await this.props.mediaEventHelper.sourceBlob.value;
                 buffer = await blob.arrayBuffer();
-                this.setState({ decryptedBlob: blob });
             } catch (e) {
                 this.setState({ error: e });
                 console.warn("Unable to decrypt audio message", e);
                 return; // stop processing the audio file
             }
-        } else {
-            try {
-                buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
-            } catch (e) {
-                this.setState({ error: e });
-                console.warn("Unable to download audio message", e);
-                return; // stop processing the audio file
-            }
+        } catch (e) {
+            this.setState({ error: e });
+            console.warn("Unable to decrypt/download audio message", e);
+            return; // stop processing the audio file
         }
 
         // We should have a buffer to work with now: let's set it up
-        const playback = new Playback(buffer);
+
+        // Note: we don't actually need a waveform to render an audio event, but voice messages do.
+        const content = this.props.mxEvent.getContent<IMediaEventContent>();
+        const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
+
+        // We should have a buffer to work with now: let's set it up
+        const playback = new Playback(buffer, waveform);
         playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
         this.setState({ playback });
-        // Note: the RecordingPlayback component will handle preparing the Playback class for us.
+
+        // Note: the components later on will handle preparing the Playback class for us.
     }
 
     public componentWillUnmount() {
@@ -103,7 +104,7 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
         return (
             <span className="mx_MAudioBody">
                 <AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
-                <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
+                {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/}
             </span>
         );
     }
diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx
index 96c8652aee..9325c39982 100644
--- a/src/components/views/messages/MImageBody.tsx
+++ b/src/components/views/messages/MImageBody.tsx
@@ -1,6 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
-Copyright 2018 New Vector Ltd
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
 Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com>
 
 Licensed under the Apache License, Version 2.0 (the "License");
@@ -21,7 +20,6 @@ import { Blurhash } from "react-blurhash";
 
 import MFileBody from './MFileBody';
 import Modal from '../../../Modal';
-import { decryptFile } from '../../../utils/DecryptFile';
 import { _t } from '../../../languageHandler';
 import SettingsStore from "../../../settings/SettingsStore";
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
@@ -34,6 +32,7 @@ import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
 import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
 import ImageView from '../elements/ImageView';
 import { SyncState } from 'matrix-js-sdk/src/sync.api';
+import { MediaEventHelper } from "../../../utils/MediaEventHelper";
 
 export interface IProps {
     /* the MatrixEvent to show */
@@ -46,6 +45,7 @@ export interface IProps {
 
     /* the permalinkCreator */
     permalinkCreator?: RoomPermalinkCreator;
+    mediaEventHelper: MediaEventHelper;
 }
 
 interface IState {
@@ -257,38 +257,24 @@ export default class MImageBody extends React.Component<IProps, IState> {
         }
     }
 
-    private downloadImage(): void {
+    private async downloadImage() {
         const content = this.props.mxEvent.getContent();
-        if (content.file !== undefined && this.state.decryptedUrl === null) {
-            let thumbnailPromise = Promise.resolve(null);
-            if (content.info && content.info.thumbnail_file) {
-                thumbnailPromise = decryptFile(
-                    content.info.thumbnail_file,
-                ).then(function(blob) {
-                    return URL.createObjectURL(blob);
+        if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
+            try {
+                const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
+                this.setState({
+                    decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
+                    decryptedThumbnailUrl: thumbnailUrl,
+                    decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
                 });
-            }
-            let decryptedBlob;
-            thumbnailPromise.then((thumbnailUrl) => {
-                return decryptFile(content.file).then(function(blob) {
-                    decryptedBlob = blob;
-                    return URL.createObjectURL(blob);
-                }).then((contentUrl) => {
-                    if (this.unmounted) return;
-                    this.setState({
-                        decryptedUrl: contentUrl,
-                        decryptedThumbnailUrl: thumbnailUrl,
-                        decryptedBlob: decryptedBlob,
-                    });
-                });
-            }).catch((err) => {
+            } catch (err) {
                 if (this.unmounted) return;
                 console.warn("Unable to decrypt attachment: ", err);
                 // Set a placeholder image when we can't decrypt the image.
                 this.setState({
                     error: err,
                 });
-            });
+            }
         }
     }
 
@@ -300,10 +286,10 @@ export default class MImageBody extends React.Component<IProps, IState> {
             localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
 
         if (showImage) {
-            // Don't download anything becaue we don't want to display anything.
+            // noinspection JSIgnoredPromiseFromCall
             this.downloadImage();
             this.setState({ showImage: true });
-        }
+        } // else don't download anything because we don't want to display anything.
 
         this._afterComponentDidMount();
     }
@@ -316,13 +302,6 @@ export default class MImageBody extends React.Component<IProps, IState> {
     componentWillUnmount() {
         this.unmounted = true;
         this.context.removeListener('sync', this.onClientSync);
-
-        if (this.state.decryptedUrl) {
-            URL.revokeObjectURL(this.state.decryptedUrl);
-        }
-        if (this.state.decryptedThumbnailUrl) {
-            URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
-        }
     }
 
     protected messageContent(
diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx
index bec224dd2d..65426cdad2 100644
--- a/src/components/views/messages/MVoiceMessageBody.tsx
+++ b/src/components/views/messages/MVoiceMessageBody.tsx
@@ -15,72 +15,16 @@ limitations under the License.
 */
 
 import React from "react";
-import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
-import { Playback } from "../../../voice/Playback";
-import MFileBody from "./MFileBody";
 import InlineSpinner from '../elements/InlineSpinner';
 import { _t } from "../../../languageHandler";
-import { mediaFromContent } from "../../../customisations/Media";
-import { decryptFile } from "../../../utils/DecryptFile";
 import RecordingPlayback from "../audio_messages/RecordingPlayback";
-import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
-import { TileShape } from "../rooms/EventTile";
-
-interface IProps {
-    mxEvent: MatrixEvent;
-    tileShape?: TileShape;
-}
-
-interface IState {
-    error?: Error;
-    playback?: Playback;
-    decryptedBlob?: Blob;
-}
+import MAudioBody from "./MAudioBody";
 
 @replaceableComponent("views.messages.MVoiceMessageBody")
-export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> {
-    constructor(props: IProps) {
-        super(props);
+export default class MVoiceMessageBody extends MAudioBody {
 
-        this.state = {};
-    }
-
-    public async componentDidMount() {
-        let buffer: ArrayBuffer;
-        const content: IMediaEventContent = this.props.mxEvent.getContent();
-        const media = mediaFromContent(content);
-        if (media.isEncrypted) {
-            try {
-                const blob = await decryptFile(content.file);
-                buffer = await blob.arrayBuffer();
-                this.setState({ decryptedBlob: blob });
-            } catch (e) {
-                this.setState({ error: e });
-                console.warn("Unable to decrypt voice message", e);
-                return; // stop processing the audio file
-            }
-        } else {
-            try {
-                buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
-            } catch (e) {
-                this.setState({ error: e });
-                console.warn("Unable to download voice message", e);
-                return; // stop processing the audio file
-            }
-        }
-
-        const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
-
-        // We should have a buffer to work with now: let's set it up
-        const playback = new Playback(buffer, waveform);
-        this.setState({ playback });
-        // Note: the RecordingPlayback component will handle preparing the Playback class for us.
-    }
-
-    public componentWillUnmount() {
-        this.state.playback?.destroy();
-    }
+    // A voice message is an audio file but rendered in a special way.
 
     public render() {
         if (this.state.error) {
@@ -106,7 +50,7 @@ export default class MVoiceMessageBody extends React.PureComponent<IProps, IStat
         return (
             <span className="mx_MVoiceMessageBody">
                 <RecordingPlayback playback={this.state.playback} tileShape={this.props.tileShape} />
-                <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
+                {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/}
             </span>
         );
     }

From ea7513fc16fd902d86069d45274f02dca2d292e8 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Thu, 15 Jul 2021 16:48:21 -0600
Subject: [PATCH 06/16] Convert MFileBody to TS and use media helper

---
 .../messages/{MFileBody.js => MFileBody.tsx}  | 93 ++++++++++---------
 1 file changed, 47 insertions(+), 46 deletions(-)
 rename src/components/views/messages/{MFileBody.js => MFileBody.tsx} (86%)

diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.tsx
similarity index 86%
rename from src/components/views/messages/MFileBody.js
rename to src/components/views/messages/MFileBody.tsx
index 9236c77e8d..f1f004ef21 100644
--- a/src/components/views/messages/MFileBody.js
+++ b/src/components/views/messages/MFileBody.tsx
@@ -25,6 +25,9 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { mediaFromContent } from "../../../customisations/Media";
 import ErrorDialog from "../dialogs/ErrorDialog";
 import { TileShape } from "../rooms/EventTile";
+import { IContent, MatrixEvent } from "matrix-js-sdk/src";
+import { MediaEventHelper } from "../../../utils/MediaEventHelper";
+import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
 
 let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on
 
@@ -35,6 +38,7 @@ async function cacheDownloadIcon() {
 }
 
 // Cache the asset immediately
+// noinspection JSIgnoredPromiseFromCall
 cacheDownloadIcon();
 
 // User supplied content can contain scripts, we have to be careful that
@@ -98,7 +102,7 @@ function computedStyle(element) {
  * @param {boolean} withSize Whether to include size information. Default true.
  * @return {string} the human readable link text for the attachment.
  */
-export function presentableTextForFile(content, withSize = true) {
+export function presentableTextForFile(content: IContent, withSize = true): string {
     let linkText = _t("Attachment");
     if (content.body && content.body.length > 0) {
         // The content body should be the name of the file including a
@@ -119,53 +123,56 @@ export function presentableTextForFile(content, withSize = true) {
     return linkText;
 }
 
-@replaceableComponent("views.messages.MFileBody")
-export default class MFileBody extends React.Component {
-    static propTypes = {
-        /* the MatrixEvent to show */
-        mxEvent: PropTypes.object.isRequired,
-        /* already decrypted blob */
-        decryptedBlob: PropTypes.object,
-        /* called when the download link iframe is shown */
-        onHeightChanged: PropTypes.func,
-        /* the shape of the tile, used */
-        tileShape: PropTypes.string,
-        /* whether or not to show the default placeholder for the file. Defaults to true. */
-        showGenericPlaceholder: PropTypes.bool,
-    };
+interface IProps {
+    /* the MatrixEvent to show */
+    mxEvent: MatrixEvent;
+    /* called when the download link iframe is shown */
+    onHeightChanged: () => void;
+    /* the shape of the tile, used */
+    tileShape: TileShape;
+    /* whether or not to show the default placeholder for the file. Defaults to true. */
+    showGenericPlaceholder: boolean;
+    /* helper which contains the file access */
+    mediaEventHelper: MediaEventHelper;
+}
 
+interface IState {
+    decryptedBlob?: Blob;
+}
+
+@replaceableComponent("views.messages.MFileBody")
+export default class MFileBody extends React.Component<IProps, IState> {
     static defaultProps = {
         showGenericPlaceholder: true,
     };
 
-    constructor(props) {
+    private iframe: React.RefObject<HTMLIFrameElement> = createRef();
+    private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
+    private userDidClick = false;
+
+    public constructor(props: IProps) {
         super(props);
 
-        this.state = {
-            decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null),
-        };
-
-        this._iframe = createRef();
-        this._dummyLink = createRef();
+        this.state = {};
     }
 
-    _getContentUrl() {
+    private getContentUrl(): string {
         const media = mediaFromContent(this.props.mxEvent.getContent());
         return media.srcHttp;
     }
 
-    componentDidUpdate(prevProps, prevState) {
+    public componentDidUpdate(prevProps, prevState) {
         if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) {
             this.props.onHeightChanged();
         }
     }
 
-    render() {
-        const content = this.props.mxEvent.getContent();
+    public render() {
+        const content = this.props.mxEvent.getContent<IMediaEventContent>();
         const text = presentableTextForFile(content);
-        const isEncrypted = content.file !== undefined;
+        const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
         const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
-        const contentUrl = this._getContentUrl();
+        const contentUrl = this.getContentUrl();
         const fileSize = content.info ? content.info.size : null;
         const fileType = content.info ? content.info.mimetype : "application/octet-stream";
 
@@ -182,29 +189,23 @@ export default class MFileBody extends React.Component {
         }
 
         if (isEncrypted) {
-            if (this.state.decryptedBlob === null) {
+            if (!this.state.decryptedBlob) {
                 // Need to decrypt the attachment
                 // Wait for the user to click on the link before downloading
                 // and decrypting the attachment.
-                let decrypting = false;
-                const decrypt = (e) => {
-                    if (decrypting) {
-                        return false;
-                    }
-                    decrypting = true;
-                    decryptFile(content.file).then((blob) => {
+                const decrypt = async () => {
+                    try {
+                        this.userDidClick = true;
                         this.setState({
-                            decryptedBlob: blob,
+                            decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
                         });
-                    }).catch((err) => {
+                    } catch (err) {
                         console.warn("Unable to decrypt attachment: ", err);
                         Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
                             title: _t("Error"),
                             description: _t("Error decrypting attachment"),
                         });
-                    }).finally(() => {
-                        decrypting = false;
-                    });
+                    }
                 };
 
                 // This button should actually Download because usercontent/ will try to click itself
@@ -226,7 +227,7 @@ export default class MFileBody extends React.Component {
                 ev.target.contentWindow.postMessage({
                     imgSrc: downloadIconUrl,
                     imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
-                    style: computedStyle(this._dummyLink.current),
+                    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.
@@ -234,7 +235,7 @@ export default class MFileBody extends React.Component {
                     download: fileName,
                     textContent: _t("Download %(text)s", { text: text }),
                     // only auto-download if a user triggered this iframe explicitly
-                    auto: !this.props.decryptedBlob,
+                    auto: this.userDidClick,
                 }, "*");
             };
 
@@ -251,12 +252,12 @@ export default class MFileBody extends React.Component {
                               * We'll use it to learn how the download link
                               * would have been styled if it was rendered inline.
                               */ }
-                            <a ref={this._dummyLink} />
+                            <a ref={this.dummyLink} />
                         </div>
                         <iframe
                             src={url}
                             onLoad={onIframeLoad}
-                            ref={this._iframe}
+                            ref={this.iframe}
                             sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
                     </div>
                 </span>
@@ -289,7 +290,7 @@ export default class MFileBody extends React.Component {
 
                     // Start a fetch for the download
                     // Based upon https://stackoverflow.com/a/49500465
-                    fetch(contentUrl).then((response) => response.blob()).then((blob) => {
+                    this.props.mediaEventHelper.sourceBlob.value.then((blob) => {
                         const blobUrl = URL.createObjectURL(blob);
 
                         // We have to create an anchor to download the file

From d156a56603d68e1835b416338e549aded59fd43b Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Fri, 16 Jul 2021 10:57:14 -0600
Subject: [PATCH 07/16] Share body prop types with an interface

---
 src/components/views/messages/IBodyProps.ts   | 43 +++++++++++++++++++
 src/components/views/messages/MAudioBody.tsx  | 15 ++-----
 src/components/views/messages/MFileBody.tsx   | 11 +----
 src/components/views/messages/MImageBody.tsx  | 22 ++--------
 src/components/views/messages/MVideoBody.tsx  | 16 ++-----
 .../views/messages/MVoiceOrAudioBody.tsx      |  8 +---
 .../views/messages/MessageEvent.tsx           | 34 ++-------------
 .../views/messages/RedactedBody.tsx           | 10 ++---
 src/components/views/messages/TextualBody.tsx | 29 +------------
 9 files changed, 65 insertions(+), 123 deletions(-)
 create mode 100644 src/components/views/messages/IBodyProps.ts

diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts
new file mode 100644
index 0000000000..8aabd3080c
--- /dev/null
+++ b/src/components/views/messages/IBodyProps.ts
@@ -0,0 +1,43 @@
+/*
+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.
+*/
+
+import { MatrixEvent } from "matrix-js-sdk/src";
+import { TileShape } from "../rooms/EventTile";
+import { MediaEventHelper } from "../../../utils/MediaEventHelper";
+import EditorStateTransfer from "../../../utils/EditorStateTransfer";
+import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
+
+export interface IBodyProps {
+    mxEvent: MatrixEvent;
+
+    /* a list of words to highlight */
+    highlights: string[];
+
+    /* link URL for the highlights */
+    highlightLink: string;
+
+    /* callback called when dynamic content in events are loaded */
+    onHeightChanged: () => void;
+
+    showUrlPreview?: boolean;
+    tileShape: TileShape;
+    maxImageHeight?: number;
+    replacingEventId?: string;
+    editState?: EditorStateTransfer;
+    onMessageAllowed: () => void; // TODO: Docs
+    permalinkCreator: RoomPermalinkCreator;
+    mediaEventHelper: MediaEventHelper;
+}
diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx
index 4f688fd136..26982f242f 100644
--- a/src/components/views/messages/MAudioBody.tsx
+++ b/src/components/views/messages/MAudioBody.tsx
@@ -15,21 +15,14 @@ limitations under the License.
 */
 
 import React from "react";
-import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { Playback } from "../../../voice/Playback";
 import InlineSpinner from '../elements/InlineSpinner';
 import { _t } from "../../../languageHandler";
 import AudioPlayer from "../audio_messages/AudioPlayer";
-import { MediaEventHelper } from "../../../utils/MediaEventHelper";
 import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
-import { TileShape } from "../rooms/EventTile";
-
-interface IProps {
-    mxEvent: MatrixEvent;
-    tileShape?: TileShape;
-    mediaEventHelper: MediaEventHelper;
-}
+import MFileBody from "./MFileBody";
+import { IBodyProps } from "./IBodyProps";
 
 interface IState {
     error?: Error;
@@ -37,8 +30,8 @@ interface IState {
 }
 
 @replaceableComponent("views.messages.MAudioBody")
-export default class MAudioBody extends React.PureComponent<IProps, IState> {
-    constructor(props: IProps) {
+export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
+    constructor(props: IBodyProps) {
         super(props);
 
         this.state = {};
diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index f1f004ef21..e0a1119b62 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -28,6 +28,7 @@ import { TileShape } from "../rooms/EventTile";
 import { IContent, MatrixEvent } from "matrix-js-sdk/src";
 import { MediaEventHelper } from "../../../utils/MediaEventHelper";
 import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
+import { IBodyProps } from "./IBodyProps";
 
 let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on
 
@@ -123,17 +124,9 @@ export function presentableTextForFile(content: IContent, withSize = true): stri
     return linkText;
 }
 
-interface IProps {
-    /* the MatrixEvent to show */
-    mxEvent: MatrixEvent;
-    /* called when the download link iframe is shown */
-    onHeightChanged: () => void;
-    /* the shape of the tile, used */
-    tileShape: TileShape;
+interface IProps extends IBodyProps {
     /* whether or not to show the default placeholder for the file. Defaults to true. */
     showGenericPlaceholder: boolean;
-    /* helper which contains the file access */
-    mediaEventHelper: MediaEventHelper;
 }
 
 interface IState {
diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx
index 9325c39982..a6e9816e35 100644
--- a/src/components/views/messages/MImageBody.tsx
+++ b/src/components/views/messages/MImageBody.tsx
@@ -27,26 +27,10 @@ import InlineSpinner from '../elements/InlineSpinner';
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { mediaFromContent } from "../../../customisations/Media";
 import { BLURHASH_FIELD } from "../../../ContentMessages";
-import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
-import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
 import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
 import ImageView from '../elements/ImageView';
 import { SyncState } from 'matrix-js-sdk/src/sync.api';
-import { MediaEventHelper } from "../../../utils/MediaEventHelper";
-
-export interface IProps {
-    /* the MatrixEvent to show */
-    mxEvent: MatrixEvent;
-    /* called when the image has loaded */
-    onHeightChanged(): void;
-
-    /* the maximum image height to use */
-    maxImageHeight?: number;
-
-    /* the permalinkCreator */
-    permalinkCreator?: RoomPermalinkCreator;
-    mediaEventHelper: MediaEventHelper;
-}
+import { IBodyProps } from "./IBodyProps";
 
 interface IState {
     decryptedUrl?: string;
@@ -64,12 +48,12 @@ interface IState {
 }
 
 @replaceableComponent("views.messages.MImageBody")
-export default class MImageBody extends React.Component<IProps, IState> {
+export default class MImageBody extends React.Component<IBodyProps, IState> {
     static contextType = MatrixClientContext;
     private unmounted = true;
     private image = createRef<HTMLImageElement>();
 
-    constructor(props: IProps) {
+    constructor(props: IBodyProps) {
         super(props);
 
         this.state = {
diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx
index 2b873f6506..e58e3ef3c0 100644
--- a/src/components/views/messages/MVideoBody.tsx
+++ b/src/components/views/messages/MVideoBody.tsx
@@ -1,6 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
-Copyright 2019 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.
@@ -24,17 +23,8 @@ import InlineSpinner from '../elements/InlineSpinner';
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { mediaFromContent } from "../../../customisations/Media";
 import { BLURHASH_FIELD } from "../../../ContentMessages";
-import { MediaEventHelper } from "../../../utils/MediaEventHelper";
 import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
-import { MatrixEvent } from "matrix-js-sdk/src";
-
-interface IProps {
-    /* the MatrixEvent to show */
-    mxEvent: MatrixEvent;
-    /* called when the video has loaded */
-    onHeightChanged: () => void;
-    mediaEventHelper: MediaEventHelper;
-}
+import { IBodyProps } from "./IBodyProps";
 
 interface IState {
     decryptedUrl?: string;
@@ -47,7 +37,7 @@ interface IState {
 }
 
 @replaceableComponent("views.messages.MVideoBody")
-export default class MVideoBody extends React.PureComponent<IProps, IState> {
+export default class MVideoBody extends React.PureComponent<IBodyProps, IState> {
     private videoRef = React.createRef<HTMLVideoElement>();
 
     constructor(props) {
diff --git a/src/components/views/messages/MVoiceOrAudioBody.tsx b/src/components/views/messages/MVoiceOrAudioBody.tsx
index 676b5a2c47..adfd102e19 100644
--- a/src/components/views/messages/MVoiceOrAudioBody.tsx
+++ b/src/components/views/messages/MVoiceOrAudioBody.tsx
@@ -15,18 +15,14 @@ limitations under the License.
 */
 
 import React from "react";
-import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import MAudioBody from "./MAudioBody";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import SettingsStore from "../../../settings/SettingsStore";
 import MVoiceMessageBody from "./MVoiceMessageBody";
-
-interface IProps {
-    mxEvent: MatrixEvent;
-}
+import { IBodyProps } from "./IBodyProps";
 
 @replaceableComponent("views.messages.MVoiceOrAudioBody")
-export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
+export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
     public render() {
         // MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
         const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx
index 3c59e68c8b..53592e3985 100644
--- a/src/components/views/messages/MessageEvent.tsx
+++ b/src/components/views/messages/MessageEvent.tsx
@@ -22,45 +22,17 @@ import RedactedBody from "./RedactedBody";
 import UnknownBody from "./UnknownBody";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { IMediaBody } from "./IMediaBody";
-import { MatrixEvent } from "matrix-js-sdk/src";
-import { TileShape } from "../rooms/EventTile";
-import PermalinkConstructor from "../../../utils/permalinks/PermalinkConstructor";
 import { IOperableEventTile } from "../context_menus/MessageContextMenu";
 import { MediaEventHelper } from "../../../utils/MediaEventHelper";
 import { ReactAnyComponent } from "../../../@types/common";
 import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
+import { IBodyProps } from "./IBodyProps";
 
-interface IProps {
-    /* the MatrixEvent to show */
-    mxEvent: MatrixEvent;
-
-    /* a list of words to highlight */
-    highlights: string[];
-
-    /* link URL for the highlights */
-    highlightLink: string;
-
-    /* should show URL previews for this event */
-    showUrlPreview: boolean;
-
-    /* callback called when dynamic content in events are loaded */
-    onHeightChanged: () => void;
-
-    /* the shape of the tile, used */
-    tileShape: TileShape;
-
-    /* the maximum image height to use, if the event is an image */
-    maxImageHeight?: number;
-
+// onMessageAllowed is handled internally
+interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
     /* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
     overrideBodyTypes?: Record<string, React.Component>;
     overrideEventTypes?: Record<string, React.Component>;
-
-    /* the permalinkCreator */
-    permalinkCreator: PermalinkConstructor;
-
-    replacingEventId?: string;
-    editState?: unknown;
 }
 
 @replaceableComponent("views.messages.MessageEvent")
diff --git a/src/components/views/messages/RedactedBody.tsx b/src/components/views/messages/RedactedBody.tsx
index 3e5da1dd43..c2e137c97b 100644
--- a/src/components/views/messages/RedactedBody.tsx
+++ b/src/components/views/messages/RedactedBody.tsx
@@ -1,5 +1,5 @@
 /*
-Copyright 2020 The Matrix.org Foundation C.I.C.
+Copyright 2020 - 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.
@@ -16,17 +16,13 @@ limitations under the License.
 
 import React, { useContext } from "react";
 import { MatrixClient } from "matrix-js-sdk/src/client";
-import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { _t } from "../../../languageHandler";
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
 import { formatFullDate } from "../../../DateUtils";
 import SettingsStore from "../../../settings/SettingsStore";
+import { IBodyProps } from "./IBodyProps";
 
-interface IProps {
-    mxEvent: MatrixEvent;
-}
-
-const RedactedBody = React.forwardRef<any, IProps>(({ mxEvent }, ref) => {
+const RedactedBody = React.forwardRef<any, IBodyProps>(({ mxEvent }, ref) => {
     const cli: MatrixClient = useContext(MatrixClientContext);
 
     let text = _t("Message deleted");
diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx
index 9009b9ee1b..c7277557a3 100644
--- a/src/components/views/messages/TextualBody.tsx
+++ b/src/components/views/messages/TextualBody.tsx
@@ -17,7 +17,6 @@ limitations under the License.
 import React, { createRef, SyntheticEvent } from 'react';
 import ReactDOM from 'react-dom';
 import highlight from 'highlight.js';
-import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
 import { MsgType } from "matrix-js-sdk/src/@types/event";
 
 import * as HtmlUtils from '../../../HtmlUtils';
@@ -38,37 +37,13 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
 import UIStore from "../../../stores/UIStore";
 import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
 import { Action } from "../../../dispatcher/actions";
-import { TileShape } from '../rooms/EventTile';
-import EditorStateTransfer from "../../../utils/EditorStateTransfer";
 import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
 import Spoiler from "../elements/Spoiler";
 import QuestionDialog from "../dialogs/QuestionDialog";
 import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
 import EditMessageComposer from '../rooms/EditMessageComposer';
 import LinkPreviewGroup from '../rooms/LinkPreviewGroup';
-
-interface IProps {
-    /* the MatrixEvent to show */
-    mxEvent: MatrixEvent;
-
-    /* a list of words to highlight */
-    highlights?: string[];
-
-    /* link URL for the highlights */
-    highlightLink?: string;
-
-    /* should show URL previews for this event */
-    showUrlPreview?: boolean;
-
-    /* the shape of the tile, used */
-    tileShape?: TileShape;
-
-    editState?: EditorStateTransfer;
-    replacingEventId?: string;
-
-    /* callback for when our widget has loaded */
-    onHeightChanged(): void;
-}
+import { IBodyProps } from "./IBodyProps";
 
 interface IState {
     // the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
@@ -79,7 +54,7 @@ interface IState {
 }
 
 @replaceableComponent("views.messages.TextualBody")
-export default class TextualBody extends React.Component<IProps, IState> {
+export default class TextualBody extends React.Component<IBodyProps, IState> {
     private readonly contentRef = createRef<HTMLSpanElement>();
 
     private unmounted = false;

From 7ba0adba77b26a51a2054f6f23243157a0f79f2f Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Fri, 16 Jul 2021 10:57:41 -0600
Subject: [PATCH 08/16] Appease type system

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

diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx
index 44acf18004..5f7f0da3ca 100644
--- a/src/components/views/messages/MImageReplyBody.tsx
+++ b/src/components/views/messages/MImageReplyBody.tsx
@@ -33,7 +33,7 @@ export default class MImageReplyBody extends MImageBody {
 
     // Don't show "Download this_file.png ..."
     public getFileBody(): JSX.Element {
-        return presentableTextForFile(this.props.mxEvent.getContent());
+        return <>{ presentableTextForFile(this.props.mxEvent.getContent()) }</>;
     }
 
     render() {

From 623f2e7613829d989154d7e6a709e1fa57a970dc Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Fri, 16 Jul 2021 11:05:04 -0600
Subject: [PATCH 09/16] Only show download link below media if it's not in the
 timeline

---
 src/components/views/messages/MAudioBody.tsx  |  2 +-
 src/components/views/messages/MFileBody.tsx   | 65 +++++++------------
 src/components/views/messages/MImageBody.tsx  |  5 +-
 src/components/views/messages/MVideoBody.tsx  |  3 +-
 .../views/messages/MVoiceMessageBody.tsx      |  3 +-
 5 files changed, 33 insertions(+), 45 deletions(-)

diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx
index 26982f242f..3444c2a3d0 100644
--- a/src/components/views/messages/MAudioBody.tsx
+++ b/src/components/views/messages/MAudioBody.tsx
@@ -97,7 +97,7 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, IState>
         return (
             <span className="mx_MAudioBody">
                 <AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
-                {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/}
+                { this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
             </span>
         );
     }
diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index e0a1119b62..6f81e4ae10 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -15,18 +15,15 @@ limitations under the License.
 */
 
 import React, { createRef } from 'react';
-import PropTypes from 'prop-types';
 import filesize from 'filesize';
 import { _t } from '../../../languageHandler';
-import { decryptFile } from '../../../utils/DecryptFile';
 import Modal from '../../../Modal';
 import AccessibleButton from "../elements/AccessibleButton";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 import { mediaFromContent } from "../../../customisations/Media";
 import ErrorDialog from "../dialogs/ErrorDialog";
 import { TileShape } from "../rooms/EventTile";
-import { IContent, MatrixEvent } from "matrix-js-sdk/src";
-import { MediaEventHelper } from "../../../utils/MediaEventHelper";
+import { IContent } from "matrix-js-sdk/src";
 import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
 import { IBodyProps } from "./IBodyProps";
 
@@ -181,6 +178,8 @@ export default class MFileBody extends React.Component<IProps, IState> {
             );
         }
 
+        const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder;
+
         if (isEncrypted) {
             if (!this.state.decryptedBlob) {
                 // Need to decrypt the attachment
@@ -205,12 +204,12 @@ export default class MFileBody extends React.Component<IProps, IState> {
                 // but it is not guaranteed between various browsers' settings.
                 return (
                     <span className="mx_MFileBody">
-                        {placeholder}
-                        <div className="mx_MFileBody_download">
+                        { placeholder }
+                        { showDownloadLink && <div className="mx_MFileBody_download">
                             <AccessibleButton onClick={decrypt}>
                                 { _t("Decrypt %(text)s", { text: text }) }
                             </AccessibleButton>
-                        </div>
+                        </div> }
                     </span>
                 );
             }
@@ -237,8 +236,8 @@ export default class MFileBody extends React.Component<IProps, IState> {
             // If the attachment is encrypted then put the link inside an iframe.
             return (
                 <span className="mx_MFileBody">
-                    {placeholder}
-                    <div className="mx_MFileBody_download">
+                    { placeholder }
+                    { showDownloadLink && <div className="mx_MFileBody_download">
                         <div style={{ display: "none" }}>
                             { /*
                               * Add dummy copy of the "a" tag
@@ -252,7 +251,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
                             onLoad={onIframeLoad}
                             ref={this.iframe}
                             sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
-                    </div>
+                    </div> }
                 </span>
             );
         } else if (contentUrl) {
@@ -300,40 +299,24 @@ export default class MFileBody extends React.Component<IProps, IState> {
                 downloadProps["download"] = fileName;
             }
 
-            // If the attachment is not encrypted then we check whether we
-            // are being displayed in the room timeline or in a list of
-            // files in the right hand side of the screen.
-            if (this.props.tileShape === TileShape.FileGrid) {
-                return (
-                    <span className="mx_MFileBody">
-                        {placeholder}
-                        <div className="mx_MFileBody_download">
-                            <a className="mx_MFileBody_downloadLink" {...downloadProps}>
-                                { fileName }
-                            </a>
-                            <div className="mx_MImageBody_size">
-                                { content.info && content.info.size ? filesize(content.info.size) : "" }
-                            </div>
-                        </div>
-                    </span>
-                );
-            } else {
-                return (
-                    <span className="mx_MFileBody">
-                        {placeholder}
-                        <div className="mx_MFileBody_download">
-                            <a {...downloadProps}>
-                                <span className="mx_MFileBody_download_icon" />
-                                { _t("Download %(text)s", { text: text }) }
-                            </a>
-                        </div>
-                    </span>
-                );
-            }
+            return (
+                <span className="mx_MFileBody">
+                    { placeholder }
+                    { showDownloadLink && <div className="mx_MFileBody_download">
+                        <a {...downloadProps}>
+                            <span className="mx_MFileBody_download_icon" />
+                            { _t("Download %(text)s", { text: text }) }
+                        </a>
+                        { this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
+                            { content.info && content.info.size ? filesize(content.info.size) : "" }
+                        </div>}
+                    </div> }
+                </span>
+            );
         } else {
             const extra = text ? (': ' + text) : '';
             return <span className="mx_MFileBody">
-                {placeholder}
+                { placeholder }
                 { _t("Invalid file%(extra)s", { extra: extra }) }
             </span>;
         }
diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx
index a6e9816e35..bb1ddd6dc6 100644
--- a/src/components/views/messages/MImageBody.tsx
+++ b/src/components/views/messages/MImageBody.tsx
@@ -415,7 +415,10 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
 
     // Overidden by MStickerBody
     protected getFileBody(): JSX.Element {
-        return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />;
+        // We only ever need the download bar if we're appearing outside of the timeline
+        if (this.props.tileShape) {
+            return <MFileBody {...this.props} showGenericPlaceholder={false} />;
+        }
     }
 
     render() {
diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx
index e58e3ef3c0..6121a23752 100644
--- a/src/components/views/messages/MVideoBody.tsx
+++ b/src/components/views/messages/MVideoBody.tsx
@@ -25,6 +25,7 @@ import { mediaFromContent } from "../../../customisations/Media";
 import { BLURHASH_FIELD } from "../../../ContentMessages";
 import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
 import { IBodyProps } from "./IBodyProps";
+import MFileBody from "./MFileBody";
 
 interface IState {
     decryptedUrl?: string;
@@ -268,7 +269,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState>
                     onPlay={this.videoOnPlay}
                 >
                 </video>
-                {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/}
+                { this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
             </span>
         );
     }
diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx
index 65426cdad2..cfa4d145dc 100644
--- a/src/components/views/messages/MVoiceMessageBody.tsx
+++ b/src/components/views/messages/MVoiceMessageBody.tsx
@@ -20,6 +20,7 @@ import InlineSpinner from '../elements/InlineSpinner';
 import { _t } from "../../../languageHandler";
 import RecordingPlayback from "../audio_messages/RecordingPlayback";
 import MAudioBody from "./MAudioBody";
+import MFileBody from "./MFileBody";
 
 @replaceableComponent("views.messages.MVoiceMessageBody")
 export default class MVoiceMessageBody extends MAudioBody {
@@ -50,7 +51,7 @@ export default class MVoiceMessageBody extends MAudioBody {
         return (
             <span className="mx_MVoiceMessageBody">
                 <RecordingPlayback playback={this.state.playback} tileShape={this.props.tileShape} />
-                {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/}
+                { this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
             </span>
         );
     }

From b57fff57395a6152a6b6443e58a9d3fbdf9b3928 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Fri, 16 Jul 2021 15:55:07 -0600
Subject: [PATCH 10/16] Update visual style and sandbox properly

---
 res/css/views/messages/_MessageActionBar.scss |   9 ++
 .../views/messages/DownloadActionButton.tsx   | 110 ++++++++++++++++++
 src/components/views/messages/MFileBody.tsx   |  10 +-
 .../views/messages/MessageActionBar.js        |  24 +---
 src/i18n/strings/en_EN.json                   |   2 +-
 src/usercontent/index.js                      |   7 ++
 src/utils/MediaEventHelper.ts                 |   4 +
 7 files changed, 140 insertions(+), 26 deletions(-)
 create mode 100644 src/components/views/messages/DownloadActionButton.tsx

diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss
index e2fafe6c62..975fe24899 100644
--- a/res/css/views/messages/_MessageActionBar.scss
+++ b/res/css/views/messages/_MessageActionBar.scss
@@ -107,3 +107,12 @@ limitations under the License.
 .mx_MessageActionBar_cancelButton::after {
     mask-image: url('$(res)/img/element-icons/trashcan.svg');
 }
+
+.mx_MessageActionBar_downloadButton::after {
+    mask-size: 16px;
+    mask-image: url('$(res)/img/download.svg');
+}
+
+.mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after {
+    background-color: transparent; // hide the download icon mask
+}
diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx
new file mode 100644
index 0000000000..35413975b6
--- /dev/null
+++ b/src/components/views/messages/DownloadActionButton.tsx
@@ -0,0 +1,110 @@
+/*
+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.
+*/
+
+import { MatrixEvent } from "matrix-js-sdk/src";
+import { MediaEventHelper } from "../../../utils/MediaEventHelper";
+import React, { createRef } 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 { DOWNLOAD_ICON_URL } from "./MFileBody";
+
+interface IProps {
+    mxEvent: MatrixEvent;
+
+    // XXX: It can take a cycle or two for the MessageActionBar to have all the props/setup
+    // required to get us a MediaEventHelper, so we use a getter function instead to prod for
+    // one.
+    mediaEventHelperGet: () => MediaEventHelper;
+}
+
+interface IState {
+    loading: boolean;
+    blob?: Blob;
+}
+
+@replaceableComponent("views.messages.DownloadActionButton")
+export class DownloadActionButton extends React.PureComponent<IProps, IState> {
+    private iframe: React.RefObject<HTMLIFrameElement> = createRef();
+
+    public constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            loading: false,
+        };
+    }
+
+    private onDownloadClick = async () => {
+        if (this.state.loading) return;
+
+        this.setState({ loading: true });
+
+        if (this.state.blob) {
+            // Cheat and trigger a download, again.
+            return this.onFrameLoad();
+        }
+
+        const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
+        this.setState({ blob });
+    };
+
+    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: DOWNLOAD_ICON_URL,
+            imgStyle: null,
+            style: "",
+            blob: this.state.blob,
+            download: this.props.mediaEventHelperGet().fileName,
+            textContent: "",
+            auto: true, // autodownload
+        }, '*');
+    };
+
+    public render() {
+        let spinner: JSX.Element;
+        if (this.state.loading) {
+            spinner = <Spinner w={18} h={18} />;
+        }
+
+        const classes = classNames({
+            'mx_MessageActionBar_maskButton': true,
+            'mx_MessageActionBar_downloadButton': true,
+            'mx_MessageActionBar_downloadSpinnerButton': !!spinner,
+        });
+
+        return <RovingAccessibleTooltipButton
+            className={classes}
+            title={_t("Download")}
+            onClick={this.onDownloadClick}
+            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 6f81e4ae10..8367b8ee32 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -27,12 +27,12 @@ import { IContent } from "matrix-js-sdk/src";
 import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
 import { IBodyProps } from "./IBodyProps";
 
-let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on
+export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
 
 async function cacheDownloadIcon() {
-    if (downloadIconUrl) return; // cached already
+    if (DOWNLOAD_ICON_URL) return; // cached already
     const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text());
-    downloadIconUrl = "data:image/svg+xml;base64," + window.btoa(svg);
+    DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg);
 }
 
 // Cache the asset immediately
@@ -74,7 +74,7 @@ cacheDownloadIcon();
  * @param {HTMLElement} element The element to get the current style of.
  * @return {string} The CSS style encoded as a string.
  */
-function computedStyle(element) {
+export function computedStyle(element: HTMLElement) {
     if (!element) {
         return "";
     }
@@ -217,7 +217,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
             // When the iframe loads we tell it to render a download link
             const onIframeLoad = (ev) => {
                 ev.target.contentWindow.postMessage({
-                    imgSrc: downloadIconUrl,
+                    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,
diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js
index 1cb86f168d..cf4e93a21b 100644
--- a/src/components/views/messages/MessageActionBar.js
+++ b/src/components/views/messages/MessageActionBar.js
@@ -33,6 +33,7 @@ import { canCancel } from "../context_menus/MessageContextMenu";
 import Resend from "../../../Resend";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
 import { MediaEventHelper } from "../../../utils/MediaEventHelper";
+import { DownloadActionButton } from "./DownloadActionButton";
 
 const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
     const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@@ -176,21 +177,6 @@ export default class MessageActionBar extends React.PureComponent {
         });
     };
 
-    onDownloadClick = async (ev) => {
-        if (!this.props.getTile || !this.props.getTile().getMediaHelper) {
-            console.warn("Action bar triggered a download but the event tile is missing a media helper");
-            return;
-        }
-
-        // TODO: Maybe just call into MFileBody and render it as null
-        const src = this.props.getTile().getMediaHelper();
-        const a = document.createElement("a");
-        a.href = await src.sourceUrl.value;
-        a.download = "todo.png";
-        a.target = "_blank";
-        a.click();
-    };
-
     /**
      * Runs a given fn on the set of possible events to test. The first event
      * that passes the checkFn will have fn executed on it. Both functions take
@@ -286,11 +272,9 @@ export default class MessageActionBar extends React.PureComponent {
 
                 // XXX: Assuming that the underlying tile will be a media event if it is eligible media.
                 if (MediaEventHelper.isEligible(this.props.mxEvent)) {
-                    toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
-                        className="mx_MessageActionBar_maskButton mx_MessageActionBar_downloadButton"
-                        title={_t("Download")}
-                        onClick={this.onDownloadClick}
-                        disabled={false}
+                    toolbarOpts.splice(0, 0, <DownloadActionButton
+                        mxEvent={this.props.mxEvent}
+                        mediaEventHelperGet={() => this.props.getTile?.().getMediaHelper?.()}
                         key="download"
                     />);
                 }
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index abdb8c2fb2..ed137cdf6c 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1870,6 +1870,7 @@
     "Saturday": "Saturday",
     "Today": "Today",
     "Yesterday": "Yesterday",
+    "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.",
     "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.",
@@ -2002,7 +2003,6 @@
     "Zoom in": "Zoom in",
     "Rotate Left": "Rotate Left",
     "Rotate Right": "Rotate Right",
-    "Download": "Download",
     "Information": "Information",
     "Language Dropdown": "Language Dropdown",
     "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
diff --git a/src/usercontent/index.js b/src/usercontent/index.js
index 13f38cc31a..c03126ec80 100644
--- a/src/usercontent/index.js
+++ b/src/usercontent/index.js
@@ -1,6 +1,13 @@
+let hasCalled = false;
 function remoteRender(event) {
     const data = event.data;
 
+    // If we're handling secondary calls, start from scratch
+    if (hasCalled) {
+        document.body.replaceWith(document.createElement("BODY"));
+    }
+    hasCalled = true;
+
     const img = document.createElement("span"); // we'll mask it as an image
     img.id = "img";
 
diff --git a/src/utils/MediaEventHelper.ts b/src/utils/MediaEventHelper.ts
index b4deb1a8ce..63616dbb12 100644
--- a/src/utils/MediaEventHelper.ts
+++ b/src/utils/MediaEventHelper.ts
@@ -40,6 +40,10 @@ export class MediaEventHelper implements IDestroyable {
         this.media = mediaFromContent(this.event.getContent());
     }
 
+    public get fileName(): string {
+        return this.event.getContent<IMediaEventContent>().body || "download";
+    }
+
     public destroy() {
         if (this.media.isEncrypted) {
             if (this.sourceUrl.present) URL.revokeObjectURL(this.sourceUrl.cachedValue);

From 31d7de628e85209c162bf91494906389842da6ea Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Fri, 16 Jul 2021 16:04:23 -0600
Subject: [PATCH 11/16] Small tweaks

---
 res/css/views/messages/_MessageActionBar.scss          | 2 +-
 src/components/views/messages/DownloadActionButton.tsx | 2 +-
 src/i18n/strings/en_EN.json                            | 1 +
 3 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss
index 975fe24899..69f3c672b7 100644
--- a/res/css/views/messages/_MessageActionBar.scss
+++ b/res/css/views/messages/_MessageActionBar.scss
@@ -109,7 +109,7 @@ limitations under the License.
 }
 
 .mx_MessageActionBar_downloadButton::after {
-    mask-size: 16px;
+    mask-size: 14px;
     mask-image: url('$(res)/img/download.svg');
 }
 
diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx
index 35413975b6..eb25e50d83 100644
--- a/src/components/views/messages/DownloadActionButton.tsx
+++ b/src/components/views/messages/DownloadActionButton.tsx
@@ -93,7 +93,7 @@ export class DownloadActionButton extends React.PureComponent<IProps, IState> {
 
         return <RovingAccessibleTooltipButton
             className={classes}
-            title={_t("Download")}
+            title={spinner ? _t("Downloading") : _t("Download")}
             onClick={this.onDownloadClick}
             disabled={!!spinner}
         >
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index ed137cdf6c..8f00e70d97 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1870,6 +1870,7 @@
     "Saturday": "Saturday",
     "Today": "Today",
     "Yesterday": "Yesterday",
+    "Downloading": "Downloading",
     "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.",

From 4d38218e24f7615bddb0f0de42f66662aac92167 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Fri, 16 Jul 2021 16:11:27 -0600
Subject: [PATCH 12/16] Appease the linter

---
 src/components/views/messages/MFileBody.tsx         | 1 +
 src/components/views/messages/MImageBody.tsx        | 1 -
 src/components/views/messages/MVoiceMessageBody.tsx | 2 --
 src/components/views/rooms/PinnedEventTile.tsx      | 1 +
 4 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index 8367b8ee32..419b45b908 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -31,6 +31,7 @@ export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the s
 
 async function cacheDownloadIcon() {
     if (DOWNLOAD_ICON_URL) return; // cached already
+    // eslint-disable-next-line @typescript-eslint/no-var-requires
     const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text());
     DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg);
 }
diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx
index bb1ddd6dc6..667f60cb22 100644
--- a/src/components/views/messages/MImageBody.tsx
+++ b/src/components/views/messages/MImageBody.tsx
@@ -242,7 +242,6 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
     }
 
     private async downloadImage() {
-        const content = this.props.mxEvent.getContent();
         if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
             try {
                 const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx
index cfa4d145dc..f184caf448 100644
--- a/src/components/views/messages/MVoiceMessageBody.tsx
+++ b/src/components/views/messages/MVoiceMessageBody.tsx
@@ -24,9 +24,7 @@ import MFileBody from "./MFileBody";
 
 @replaceableComponent("views.messages.MVoiceMessageBody")
 export default class MVoiceMessageBody extends MAudioBody {
-
     // A voice message is an audio file but rendered in a special way.
-
     public render() {
         if (this.state.error) {
             // TODO: @@TR: Verify error state
diff --git a/src/components/views/rooms/PinnedEventTile.tsx b/src/components/views/rooms/PinnedEventTile.tsx
index 0e3396e9b0..250efc1278 100644
--- a/src/components/views/rooms/PinnedEventTile.tsx
+++ b/src/components/views/rooms/PinnedEventTile.tsx
@@ -85,6 +85,7 @@ export default class PinnedEventTile extends React.Component<IProps> {
             <div className="mx_PinnedEventTile_message">
                 <MessageEvent
                     mxEvent={this.props.event}
+                    // @ts-ignore - complaining that className is invalid when it's not
                     className="mx_PinnedEventTile_body"
                     maxImageHeight={150}
                     onHeightChanged={() => {}} // we need to give this, apparently

From 70e525d672f1788cf4d22decb2bb12f7c688148a Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Sun, 18 Jul 2021 17:06:30 -0600
Subject: [PATCH 13/16] Fix component export

---
 src/components/views/messages/DownloadActionButton.tsx | 2 +-
 src/components/views/messages/MessageActionBar.js      | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx
index eb25e50d83..4f81f1b14f 100644
--- a/src/components/views/messages/DownloadActionButton.tsx
+++ b/src/components/views/messages/DownloadActionButton.tsx
@@ -39,7 +39,7 @@ interface IState {
 }
 
 @replaceableComponent("views.messages.DownloadActionButton")
-export class DownloadActionButton extends React.PureComponent<IProps, IState> {
+export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
     private iframe: React.RefObject<HTMLIFrameElement> = createRef();
 
     public constructor(props: IProps) {
diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js
index cf4e93a21b..dd8b3afcef 100644
--- a/src/components/views/messages/MessageActionBar.js
+++ b/src/components/views/messages/MessageActionBar.js
@@ -33,7 +33,7 @@ import { canCancel } from "../context_menus/MessageContextMenu";
 import Resend from "../../../Resend";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
 import { MediaEventHelper } from "../../../utils/MediaEventHelper";
-import { DownloadActionButton } from "./DownloadActionButton";
+import DownloadActionButton from "./DownloadActionButton";
 
 const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
     const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();

From 32d8da2c74ce6b666a5a02b509875b5e91e30116 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Tue, 20 Jul 2021 08:52:43 -0600
Subject: [PATCH 14/16] Remove useless image reference

---
 src/components/views/messages/DownloadActionButton.tsx | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx
index 4f81f1b14f..cc666485b1 100644
--- a/src/components/views/messages/DownloadActionButton.tsx
+++ b/src/components/views/messages/DownloadActionButton.tsx
@@ -22,7 +22,6 @@ import Spinner from "../elements/Spinner";
 import classNames from "classnames";
 import { _t } from "../../../languageHandler";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
-import { DOWNLOAD_ICON_URL } from "./MFileBody";
 
 interface IProps {
     mxEvent: MatrixEvent;
@@ -69,7 +68,7 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
 
         // we aren't showing the iframe, so we can send over the bare minimum styles and such.
         this.iframe.current.contentWindow.postMessage({
-            imgSrc: DOWNLOAD_ICON_URL,
+            imgSrc: "", // no image
             imgStyle: null,
             style: "",
             blob: this.state.blob,

From 44e240e12d32c0e67bbf86741f017c265a4d0721 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Tue, 20 Jul 2021 08:55:07 -0600
Subject: [PATCH 15/16] Doc some values

---
 src/utils/MediaEventHelper.ts | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/utils/MediaEventHelper.ts b/src/utils/MediaEventHelper.ts
index 63616dbb12..cf34d5dea4 100644
--- a/src/utils/MediaEventHelper.ts
+++ b/src/utils/MediaEventHelper.ts
@@ -25,10 +25,14 @@ import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
 // TODO: We should consider caching the blobs. https://github.com/vector-im/element-web/issues/17192
 
 export class MediaEventHelper implements IDestroyable {
+    // Either an HTTP or Object URL (when encrypted) to the media.
     public readonly sourceUrl: LazyValue<string>;
     public readonly thumbnailUrl: LazyValue<string>;
+
+    // Either the raw or decrypted (when encrypted) contents of the file.
     public readonly sourceBlob: LazyValue<Blob>;
     public readonly thumbnailBlob: LazyValue<Blob>;
+
     public readonly media: Media;
 
     public constructor(private event: MatrixEvent) {

From 7892539a9b049d8d20def20da5e186645a11b8cc Mon Sep 17 00:00:00 2001
From: Travis Ralston <travisr@matrix.org>
Date: Tue, 20 Jul 2021 09:03:26 -0600
Subject: [PATCH 16/16] delint

---
 src/components/views/messages/DownloadActionButton.tsx | 2 +-
 src/components/views/messages/MFileBody.tsx            | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx
index cc666485b1..2bdae04eda 100644
--- a/src/components/views/messages/DownloadActionButton.tsx
+++ b/src/components/views/messages/DownloadActionButton.tsx
@@ -98,7 +98,7 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
         >
             { spinner }
             { this.state.blob && <iframe
-                src={"usercontent/" /* XXX: Like MFileBody, this should come from the skin */}
+                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"
diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx
index 419b45b908..b1e42976db 100644
--- a/src/components/views/messages/MFileBody.tsx
+++ b/src/components/views/messages/MFileBody.tsx
@@ -310,7 +310,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
                         </a>
                         { this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
                             { content.info && content.info.size ? filesize(content.info.size) : "" }
-                        </div>}
+                        </div> }
                     </div> }
                 </span>
             );